Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ const REACTION_POLL_RATE_LIMIT_FLOOR = 100;
const RATE_LIMIT_LOW_REMAINING_THRESHOLD = 10;
/** Minimum backoff applied when scheduling the next poll after rate-limit detection. */
const RATE_LIMIT_MIN_BACKOFF_MS = 60_000;
/**
* A terminal delivery failure only counts toward the health rollup's
* `recentErrors` (and thus the Degraded badge) while it is within this window.
* The full, unbounded delivery log remains available for diagnostics; this only
* bounds the health snapshot so one old failure cannot flag the space forever.
*/
const HEALTH_RECENT_ERROR_WINDOW_MS = 24 * 60 * 60 * 1000;
const COMMENT_ENDPOINT_INITIAL_LOOKBACK_MS = 24 * 60 * 60 * 1000;

/**
Expand Down Expand Up @@ -160,6 +167,107 @@ interface GitHubHookResponse {
};
}

/**
* Per-repository rollup included in {@link GitHubHealthSnapshot}. Mirrors the
* fields an operator needs to triage a single repo at a glance without paging
* through the full watched-repo list.
*/
export interface GitHubHealthRepoSummary {
owner: string;
repo: string;
enabled: boolean;
webhookEnabled: boolean;
webhookActive: boolean | null;
/**
* True when the remote hook is daemon-managed (created via
* autoConfigureWebhook). Bulk re-registration only targets these rows so a
* manually-configured hook is not orphaned behind a replaced secret.
*/
webhookAutoRegistered: boolean;
pollingEnabled: boolean;
lastWebhookAt: number | null;
lastPollAt: number | null;
webhookLastError: string | null;
/** Number of open PRs currently tracked for reaction polling in this repo. */
reactionTrackedPullRequests: number;
}

/**
* Consolidated health snapshot returned by the `space.github.health` RPC.
*
* Aggregates token availability, polling configuration, the current rate-limit
* window, webhook registration status, reaction-polling freshness, recent
* delivery failures, and a per-repo rollup into a single response so operators
* have one place to verify the GitHub event subsystem is healthy. Timestamps are
* wall-clock epoch milliseconds; `null` means "never".
*/
export interface GitHubHealthSnapshot {
source: 'github';
spaceId: string;
/** When the snapshot was assembled. */
timestamp: number;
token: GitHubTokenStatus;
polling: {
/** Global capability + globallyEnabled both on. */
globallyEnabled: boolean;
/** Resolved poll interval in ms (0 = polling disabled). */
intervalMs: number;
/** A poll timer is currently armed and the extension is running. */
active: boolean;
/** Count of enabled + polling-configured repos in this space. */
pollingRepoCount: number;
/** Most recent successful poll across this space's repos. */
lastPollAt: number | null;
};
rateLimit: {
/** True while inside an active cooldown (`now < until`). */
limited: boolean;
/** Epoch ms until which polling is deferred; 0 when no cooldown is active. */
until: number;
/** Cooldown derived from a Retry-After (secondary) limit. */
fromRetryAfter: boolean;
/** Remaining requests in the current window; null when never observed. */
remaining: number | null;
/** Epoch ms when the window resets; null when unknown/never observed. */
resetAt: number | null;
/** Epoch ms of the last rate-limit observation; 0 when never observed. */
observedAt: number;
};
webhook: {
total: number;
/** Repos with webhook delivery enabled. */
configured: number;
active: number;
inactive: number;
/** Repos whose remote hook status has never been checked. */
unknown: number;
/**
* Whether inbound webhook delivery is globally enabled (source globally on
* AND the webhooks capability not disabled). When false the handler rejects
* every delivery, so active hooks are not a working path regardless of count.
*/
deliveryEnabled: boolean;
lastWebhookAt: number | null;
lastCheckedAt: number | null;
errors: Array<{ owner: string; repo: string; error: string; at: number | null }>;
};
reactions: {
/** Total open PRs tracked for reaction polling across this space. */
trackedPullRequests: number;
/** Most recent poll among repos tracking reactions; null if none. */
lastActivityAt: number | null;
};
recentErrors: Array<{
eventId: string;
topic: string;
agentName: string | null;
failureReason: string | null;
updatedAt: number;
occurredAt: number;
}>;
repositories: GitHubHealthRepoSummary[];
}

export class GitHubEventExtension implements HttpExternalEventExtension, RpcExternalEventExtension {
readonly sourceId = 'github';
readonly routes = [
Expand Down Expand Up @@ -189,6 +297,15 @@ export class GitHubEventExtension implements HttpExternalEventExtension, RpcExte
* honored exactly rather than floored to `RATE_LIMIT_MIN_BACKOFF_MS`.
*/
private rateLimitedFromRetryAfter = false;
/**
* Most recent rate-limit snapshot observed during a poll cycle (the merged
* `latestRateLimit` value, preserving a finite `remaining` across 304s).
* Exposed via `buildHealthSnapshot` for the integration health panel.
* `undefined` until the first poll observes rate-limit headers.
*/
private lastRateLimitInfo?: GitHubRateLimitInfo;
/** Wall-clock epoch (ms) when `lastRateLimitInfo` was last updated; 0 if never. */
private lastRateLimitObservedAt = 0;
Comment on lines +314 to +316

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate quota observations when credentials change

These observations are stored globally on the extension without identifying the credential that produced them, and neither space.github.setToken nor space.github.clearToken resets them. After replacing a PAT with one for another account—or clearing a keychain token and falling back to unauthenticated or env credentials—the refreshed panel continues showing the previous credential's remaining/reset budget, potentially including an obsolete exhausted quota, until a later poll returns finite headers; clear or re-key this state when the effective credential changes.

Useful? React with 👍 / 👎.

private readonly credentialStore?: CredentialStore;
private readonly eventStore: ExternalEventStore;

Expand Down Expand Up @@ -464,6 +581,18 @@ export class GitHubEventExtension implements HttpExternalEventExtension, RpcExte
return await this.getTokenStatus();
});

/**
* Consolidated health snapshot for the GitHub event subsystem in a space.
* Aggregates token, polling, rate-limit, webhook, reaction, and recent
* delivery-error state into one response for the integration health panel.
*/
hub.onRequest('space.github.health', async (data) => {
await assertRpcConfigEnabled(context, this.sourceId);
const params = data as { spaceId?: string };
if (!params.spaceId) throw new Error('spaceId is required');
return await this.buildHealthSnapshot(params.spaceId);
});

/**
* Remove the daemon-wide GitHub PAT from the credential store.
* Falls back to env-var token on the next resolveToken() call.
Expand Down Expand Up @@ -670,6 +799,18 @@ export class GitHubEventExtension implements HttpExternalEventExtension, RpcExte
return global.globallyEnabled && global.capabilities.polling !== false;
}

/**
* Whether inbound webhook delivery is globally enabled. Mirrors the gate in
* `handleWebhook`: the handler short-circuits every delivery (202 "Event
* ignored") when the source is globally off or the webhooks capability is
* disabled, so active hooks are not a working path in that state.
*/
private async isWebhookDeliveryEnabled(): Promise<boolean> {
if (!this.context) return false;
const global = await this.context.config.getGlobalConfig(this.sourceId);
return global.globallyEnabled && global.capabilities.webhooks !== false;
}

async refreshPollingInterval(): Promise<void> {
if (this.stopped) return;
if (!(await this.isPollingGloballyEnabled()) || this.getPollIntervalMs() <= 0) {
Expand Down Expand Up @@ -842,6 +983,156 @@ export class GitHubEventExtension implements HttpExternalEventExtension, RpcExte
}
}

/**
* Assemble a consolidated health snapshot for one space. Pulls token state,
* the resolved polling config, the current rate-limit window, per-repo
* webhook/poll freshness, reaction-poll targets, and the most recent failed
* deliveries into a single response for the integration health panel.
*
* `getTokenStatus` is robust to network failures (it returns a structured
* error rather than throwing), so this method never throws on token
* validation — only on an absent spaceId (enforced by the RPC caller).
*/
private async buildHealthSnapshot(spaceId: string): Promise<GitHubHealthSnapshot> {
const now = Date.now();
const watched = this.repo.listWatchedRepos(spaceId);
const token = await this.getTokenStatus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound token validation while assembling health

When GitHub's /user request stalls, this await prevents every locally available polling, webhook, and delivery metric from being returned; the client eventually times out the RPC and leaves Poll now and re-registration disabled because no snapshot exists. The parent settings flow explicitly fetches token status in the background to remain usable during this condition, so the health RPC should likewise bound validation or return the local snapshot with an unknown/token-error status instead of blocking the entire panel on an unbounded upstream fetch.

Useful? React with 👍 / 👎.

const globallyEnabled = await this.isPollingGloballyEnabled();
const webhookDeliveryEnabled = await this.isWebhookDeliveryEnabled();
const intervalMs = this.getPollIntervalMs();

let webhookConfigured = 0;
let webhookActive = 0;
let webhookInactive = 0;
let webhookUnknown = 0;
let lastWebhookAt: number | null = null;
let lastCheckedAt: number | null = null;
let reactionTrackedPullRequests = 0;
let lastPollAt: number | null = null;
let lastReactionActivityAt: number | null = null;
const webhookErrors: GitHubHealthSnapshot['webhook']['errors'] = [];

for (const repo of watched) {
// A disabled space (space.github.disable) flips every row's `enabled`
// flag, and the webhook/poll paths skip disabled rows before delivering.
// Exclude them from the health aggregates so the summary reflects the
// active delivery path — otherwise a disabled space with stale active
// hooks would read as Healthy. The `repositories` rollup still includes
// them (with their `enabled` flag) for diagnostics.
if (!repo.enabled) continue;
if (repo.webhookEnabled) {
Comment thread
lsm marked this conversation as resolved.
webhookConfigured++;
if (repo.webhookActive === true) webhookActive++;
else if (repo.webhookActive === false) webhookInactive++;
else webhookUnknown++;
Comment thread
lsm marked this conversation as resolved.
Comment thread
lsm marked this conversation as resolved.
Comment thread
lsm marked this conversation as resolved.
}
if (repo.lastWebhookAt && (lastWebhookAt === null || repo.lastWebhookAt > lastWebhookAt)) {
lastWebhookAt = repo.lastWebhookAt;
}
Comment thread
lsm marked this conversation as resolved.
Outdated
Comment thread
lsm marked this conversation as resolved.
Outdated
if (
repo.webhookLastCheckedAt &&
(lastCheckedAt === null || repo.webhookLastCheckedAt > lastCheckedAt)
) {
lastCheckedAt = repo.webhookLastCheckedAt;
}
if (repo.webhookLastError) {
webhookErrors.push({
owner: repo.owner,
repo: repo.repo,
error: repo.webhookLastError,
at: repo.webhookLastCheckedAt,
});
}
const trackedPrs = repo.pollCursor?.recentPullRequestNumbers?.length ?? 0;
reactionTrackedPullRequests += trackedPrs;
if (repo.lastPollAt && (lastPollAt === null || repo.lastPollAt > lastPollAt)) {
lastPollAt = repo.lastPollAt;
}
// Reaction freshness is bounded by the repo's poll cycle (reactions are
// polled in the same pass), so the most recent poll of any repo that
// tracks reaction targets represents reaction-polling freshness.
if (trackedPrs > 0 && repo.lastPollAt) {
if (lastReactionActivityAt === null || repo.lastPollAt > lastReactionActivityAt) {
lastReactionActivityAt = repo.lastPollAt;
Comment on lines +1075 to +1077

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track completion of the reaction requests themselves

When the primary endpoint responses leave fewer than 100 requests remaining, canPollReactions skips every reaction request without activating the cooldown used below 10, but updatePollCursor still advances repo.lastPollAt. Using that repository timestamp here makes the panel report fresh reaction activity—and potentially a Healthy integration—even though approval reactions were not checked in that cycle; persist and aggregate a timestamp only after an actual successful reaction request.

Useful? React with 👍 / 👎.

}
}
}

const rateLimitInfo = this.lastRateLimitInfo;
// Bound the health rollup to a recency window: a terminal failure only
// counts toward recentErrors (and the Degraded badge) while it is recent.
// The full unbounded log stays available via the deliveries view.
const recentCutoff = now - HEALTH_RECENT_ERROR_WINDOW_MS;
const recentDeliveries = this.eventStore
.listDeliveryLog({
spaceId,
status: 'failed',
limit: 5,
})
.filter((delivery) => delivery.updatedAt >= recentCutoff);

return {
source: 'github',
spaceId,
timestamp: now,
token,
polling: {
globallyEnabled,
intervalMs,
active: !this.stopped && this.pollTimer !== null,
pollingRepoCount: this.repo.listPollingRepos(spaceId).length,
lastPollAt,
},
rateLimit: {
limited: now < this.rateLimitedUntil,
until: this.rateLimitedUntil,
fromRetryAfter: this.rateLimitedFromRetryAfter,
remaining:
rateLimitInfo && Number.isFinite(rateLimitInfo.remaining)
? rateLimitInfo.remaining
: null,
resetAt: rateLimitInfo && rateLimitInfo.resetAt ? rateLimitInfo.resetAt : null,
observedAt: this.lastRateLimitObservedAt,
},
webhook: {
total: watched.length,
configured: webhookConfigured,
active: webhookActive,
inactive: webhookInactive,
unknown: webhookUnknown,
deliveryEnabled: webhookDeliveryEnabled,
lastWebhookAt,
lastCheckedAt,
errors: webhookErrors,
},
reactions: {
trackedPullRequests: reactionTrackedPullRequests,
lastActivityAt: lastReactionActivityAt,
},
recentErrors: recentDeliveries.map((delivery) => ({
eventId: delivery.event.id,
topic: delivery.event.topic,
agentName: delivery.agentName,
failureReason: delivery.failureReason,
updatedAt: delivery.updatedAt,
occurredAt: delivery.event.occurredAt,
})),
repositories: watched.map((repo) => ({
owner: repo.owner,
repo: repo.repo,
enabled: repo.enabled,
webhookEnabled: repo.webhookEnabled,
webhookActive: repo.webhookActive,
webhookAutoRegistered: repo.webhookAutoRegistered,
pollingEnabled: repo.pollingEnabled,
lastWebhookAt: repo.lastWebhookAt,
lastPollAt: repo.lastPollAt,
webhookLastError: repo.webhookLastError,
reactionTrackedPullRequests: repo.pollCursor?.recentPullRequestNumbers?.length ?? 0,
})),
};
}

/**
* Flip the global polling capability on. Called when a polling-enabled repo
* is added so subsequent poll cycles are not blocked by capability gating.
Expand Down Expand Up @@ -1970,6 +2261,17 @@ export class GitHubEventExtension implements HttpExternalEventExtension, RpcExte
endpointPendingLastSeenAt,
};
this.repo.updatePollCursor(watched.id, cursorPayload);
// Retain the cycle's rate-limit snapshot so the health panel can report the
// current `remaining`/`reset` budget. Merge (not overwrite) so an all-304
// cycle — which carries no rate-limit headers and parses as a non-finite
// snapshot — does not clobber a previously observed finite budget. Only
// stamp `observedAt` when this cycle actually saw finite rate-limit headers.
if (latestRateLimit) {
Comment thread
lsm marked this conversation as resolved.
this.lastRateLimitInfo = mergeRateLimitInfo(this.lastRateLimitInfo, latestRateLimit);
if (Number.isFinite(latestRateLimit.remaining)) {
this.lastRateLimitObservedAt = Date.now();
}
}
return count;
}
}
Expand Down
Loading