diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 00f84f2aa5..65eb4a0ad5 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1081,6 +1081,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { stderr: "", })), resolveGhPath: async () => "/usr/bin/gh", + resolveRepoHost: async () => null, + resolveRepoSlug: async () => null, now: () => nowMs, }); const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github); @@ -1186,6 +1188,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { ttlMs: 0, runner, resolveGhPath: async () => "/usr/bin/gh", + resolveRepoHost: async () => null, + resolveRepoSlug: async () => null, now: () => nowMs, }); const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github); @@ -1207,7 +1211,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); await vi.advanceTimersByTimeAsync(0); await vi.waitFor(() => { - expect(runner).toHaveBeenCalledTimes(1); + expect(githubReadCalls).toContainEqual({ + reason: "self-heal-github", + tickMs: 0, + }); }); await flushPromises(); const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length; diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index cb7b273615..194a0c7487 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -7,6 +7,7 @@ import { GitHubAuthenticationError, GitHubCliMissingError, GitHubCommandError, + GITHUB_POLL_ALIGNMENT_MS, computeGithubNextInterval, createGitHubService, parseStatusCheckRollup, @@ -876,6 +877,8 @@ describe("ForgeService", () => { ttlMs: 0, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", + resolveRepoHost: async () => null, + resolveRepoSlug: async () => null, }); const reads = recordCurrentPullRequestStatusReads(service); @@ -885,6 +888,7 @@ describe("ForgeService", () => { headRepositoryOwner: "fork-owner", }); await vi.advanceTimersByTimeAsync(0); + await flushMicrotasks(); expect(reads).toEqual([ expect.objectContaining({ @@ -1158,6 +1162,138 @@ describe("ForgeService", () => { service.dispose?.(); }); + it("batches the first PR status poll after repository identity resolves", async () => { + let now = 0; + let resolveRepositoryIdentity!: () => void; + const repositoryIdentityReady = new Promise((resolve) => { + resolveRepositoryIdentity = resolve; + }); + const runner = createScriptedRunner([ + batchPollStatusJson({ + t0: batchPollRepositoryJson([ + batchPollPrNodeJson({ + number: 41, + url: "https://github.com/acme/widgets/pull/41", + headRefName: "feat-a", + headRefOid: "oid-a", + }), + ]), + t1: batchPollRepositoryJson([ + batchPollPrNodeJson({ + number: 52, + url: "https://github.com/acme/gadgets/pull/52", + headRefName: "feat-b", + headRefOid: "oid-b", + }), + ]), + }), + batchPollStatusJson({ + t0: batchPollChecksAliasJson([]), + t1: batchPollChecksAliasJson([]), + }), + ]); + const slugs: Record = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" }; + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + resolveRepoHost: async () => { + await repositoryIdentityReady; + return null; + }, + resolveRepoSlug: async (cwd) => { + await repositoryIdentityReady; + return slugs[cwd] ?? null; + }, + now: () => now, + }); + + const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/ws-a", + headRef: "feat-a", + headSha: "oid-a", + }); + const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/ws-b", + headRef: "feat-b", + headSha: "oid-b", + }); + vi.advanceTimersByTime(0); + await flushMicrotasks(); + + expect(runner.calls).toHaveLength(0); + + resolveRepositoryIdentity(); + await flushMicrotasks(); + now = GITHUB_POLL_ALIGNMENT_MS; + await vi.advanceTimersByTimeAsync(GITHUB_POLL_ALIGNMENT_MS); + await flushMicrotasks(); + + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0); + expect(runner.calls).toHaveLength(2); + expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]); + expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")'); + expect(runner.calls[0]?.args[3]).toContain('t1: repository(owner: "acme", name: "gadgets")'); + + subscriptionA?.unsubscribe(); + subscriptionB?.unsubscribe(); + service.dispose?.(); + }); + + it("does not let one unresolved repository identity block ready poll targets", async () => { + let now = 0; + const neverResolves = new Promise(() => {}); + const runner = createScriptedRunner([ + batchPollStatusJson({ + t0: batchPollRepositoryJson([ + batchPollPrNodeJson({ + number: 41, + url: "https://github.com/acme/widgets/pull/41", + state: "OPEN", + mergedAt: null, + headRefName: "feat-ready", + headRefOid: "oid-ready", + }), + ]), + }), + batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }), + ]); + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + resolveRepoHost: async (cwd) => (cwd === "/ws-blocked" ? neverResolves : null), + resolveRepoSlug: async (cwd) => (cwd === "/ws-blocked" ? neverResolves : "acme/widgets"), + now: () => now, + }); + const readyStatuses: Array = []; + + const blockedSubscription = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/ws-blocked", + headRef: "feat-blocked", + }); + const readySubscription = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/ws-ready", + headRef: "feat-ready", + headSha: "oid-ready", + onStatus: (status) => readyStatuses.push(status), + }); + vi.advanceTimersByTime(0); + await flushMicrotasks(); + now = GITHUB_POLL_ALIGNMENT_MS; + await vi.advanceTimersByTimeAsync(GITHUB_POLL_ALIGNMENT_MS); + await flushMicrotasks(); + + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0); + expect(runner.calls).toHaveLength(2); + expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")'); + expect(readyStatuses).toEqual([expect.objectContaining({ number: 41 })]); + + blockedSubscription?.unsubscribe(); + readySubscription?.unsubscribe(); + service.dispose?.(); + }); + it("refetches checks for open PRs on every batched poll at fast cadence", async () => { let now = 0; const openNode = batchPollPrNodeJson({ diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 124216674e..4e857d5062 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -1471,13 +1471,29 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G const legacyTargets: GitHubPollTarget[] = []; const batchGroups = new Map(); for (const target of due) { + if (target.retainCount <= 0) { + continue; + } const host = peekRepoHost(target.cwd); const slug = peekRepoSlug(target.cwd); + if (!host.settled || !slug.settled) { + target.pollCycleStartedAt = null; + void Promise.all([ + resolveRepoHostCached(target.cwd).catch(() => null), + resolveRepoSlugCached(target.cwd), + ]).then(() => { + if (target.retainCount > 0 && target.nextDueAt === null) { + // A non-zero delay rejoins the shared polling grid. + scheduleGitHubPollAfter(target, 1); + } + return undefined; + }); + continue; + } const [owner, name, extra] = slug.value?.split("/") ?? []; - // A cwd whose host or slug is still resolving (or has no origin slug at - // all) polls through the legacy per-target path this cycle; batch - // grouping needs both facts synchronously. - if (!host.settled || !slug.settled || !owner || !name || extra !== undefined) { + // A cwd with no origin slug polls through the legacy per-target path; + // batch grouping needs a complete owner/name identity. + if (!owner || !name || extra !== undefined) { legacyTargets.push(target); continue; }