-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add GitHub event integration health panel #2254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 3 commits
78e92f5
42fd1b2
6e72b02
b0331f8
8d5c661
dc26a70
3598545
7a6dd03
3809a96
9dd7a61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -160,6 +160,101 @@ 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; | ||
| 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 = [ | ||
|
|
@@ -189,6 +284,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; | ||
| private readonly credentialStore?: CredentialStore; | ||
| private readonly eventStore: ExternalEventStore; | ||
|
|
||
|
|
@@ -464,6 +568,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. | ||
|
|
@@ -842,6 +958,148 @@ 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When GitHub's Useful? React with 👍 / 👎. |
||
| const globallyEnabled = await this.isPollingGloballyEnabled(); | ||
| 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) { | ||
|
lsm marked this conversation as resolved.
|
||
| webhookConfigured++; | ||
| if (repo.webhookActive === true) webhookActive++; | ||
| else if (repo.webhookActive === false) webhookInactive++; | ||
| else webhookUnknown++; | ||
|
lsm marked this conversation as resolved.
lsm marked this conversation as resolved.
lsm marked this conversation as resolved.
|
||
| } | ||
| if (repo.lastWebhookAt && (lastWebhookAt === null || repo.lastWebhookAt > lastWebhookAt)) { | ||
| lastWebhookAt = repo.lastWebhookAt; | ||
| } | ||
|
lsm marked this conversation as resolved.
Outdated
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the primary endpoint responses leave fewer than 100 requests remaining, Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| const rateLimitInfo = this.lastRateLimitInfo; | ||
| const recentDeliveries = this.eventStore.listDeliveryLog({ | ||
| spaceId, | ||
| status: 'failed', | ||
| limit: 5, | ||
| }); | ||
|
lsm marked this conversation as resolved.
Outdated
lsm marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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, | ||
| 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. | ||
|
|
@@ -1970,6 +2228,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) { | ||
|
lsm marked this conversation as resolved.
|
||
| this.lastRateLimitInfo = mergeRateLimitInfo(this.lastRateLimitInfo, latestRateLimit); | ||
| if (Number.isFinite(latestRateLimit.remaining)) { | ||
| this.lastRateLimitObservedAt = Date.now(); | ||
| } | ||
| } | ||
| return count; | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These observations are stored globally on the extension without identifying the credential that produced them, and neither
space.github.setTokennorspace.github.clearTokenresets 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 👍 / 👎.