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 2a1266d1c..967467292 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -879,6 +879,53 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { github.dispose?.(); }); + test("GitHub self-heal polling uses the fork PR head branch instead of the owner-prefixed local branch", async () => { + const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() })); + const github = { + ...createGitHubServiceStub(), + retainCurrentPullRequestStatusPoll, + }; + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => + createCheckoutFacts(cwd, { + currentBranch: "fork-owner/open-button-targets-active-file", + branchRemoteName: "paseo-pr-1285", + branchMergeRef: "refs/heads/open-button-targets-active-file", + trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file", + pullRequestLookupTarget: { + headRef: "open-button-targets-active-file", + headRepositoryOwner: "fork-owner", + }, + }), + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { + currentBranch: "fork-owner/open-button-targets-active-file", + remoteUrl: "git@github.com:getpaseo/paseo.git", + }), + ); + const service = createService({ + getCheckoutSnapshotFacts, + getCheckoutStatus, + github, + }); + + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await flushPromises(); + + await vi.waitFor(() => { + expect(retainCurrentPullRequestStatusPoll).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: REPO_CWD, + headRef: "open-button-targets-active-file", + headRepositoryOwner: "fork-owner", + }), + ); + }); + + subscription.unsubscribe(); + service.dispose(); + }); + test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => { let nowMs = 0; const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = []; diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index f29d7fdb3..def8f4320 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -270,7 +270,7 @@ interface WorkspaceGitTarget { debounceTimer: NodeJS.Timeout | null; selfHealTimer: NodeJS.Timeout | null; githubPollSubscription: { unsubscribe: () => void } | null; - githubPollHeadRef: string | null; + githubPollKey: string | null; refreshState: WorkspaceGitRefreshState; latestGit: WorkspaceGitRuntimeSnapshot["git"] | null; latestGitLoadedAtMs: number | null; @@ -317,6 +317,11 @@ interface WorkspaceGitAuxiliaryReadCacheEntry { inFlight: Promise | null; } +interface WorkspaceGitHubPollTarget { + headRef: string; + headRepositoryOwner?: string; +} + function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies { return { watch, @@ -796,7 +801,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { debounceTimer: null, selfHealTimer: null, githubPollSubscription: null, - githubPollHeadRef: null, + githubPollKey: null, refreshState: { status: "idle" }, latestGit: null, latestGitLoadedAtMs: null, @@ -1156,23 +1161,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } - const headRef = git.currentBranch; + const pollTarget = this.resolveGitHubPollTarget(target); + const remoteUrl = git.remoteUrl; const hasGitHubRemote = - target.cachedGitHubRemote?.remoteUrl === git.remoteUrl && + target.cachedGitHubRemote?.remoteUrl === remoteUrl && target.cachedGitHubRemote.identity !== null; - if (!headRef || !hasGitHubRemote) { + if (!pollTarget || remoteUrl === null || !hasGitHubRemote) { this.stopGitHubPollForTarget(target); return; } - if (target.githubPollHeadRef === headRef && target.githubPollSubscription) { + const pollKey = buildWorkspaceGitHubPollKey(remoteUrl, pollTarget); + if (target.githubPollKey === pollKey && target.githubPollSubscription) { return; } this.stopGitHubPollForTarget(target); - target.githubPollHeadRef = headRef; + target.githubPollKey = pollKey; target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({ cwd: target.cwd, - headRef, + headRef: pollTarget.headRef, + ...(pollTarget.headRepositoryOwner + ? { headRepositoryOwner: pollTarget.headRepositoryOwner } + : {}), onStatus: (status) => { if (!this.isActiveObservedWorkspaceTarget(target)) { return; @@ -1183,17 +1193,40 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }, onError: (error) => { this.logger.warn( - { err: error, cwd: target.cwd, headRef, reason: "self-heal-github" }, + { + err: error, + cwd: target.cwd, + headRef: pollTarget.headRef, + headRepositoryOwner: pollTarget.headRepositoryOwner, + reason: "self-heal-github", + }, "Failed to run GitHub self-heal refresh", ); }, }); } + private resolveGitHubPollTarget(target: WorkspaceGitTarget): WorkspaceGitHubPollTarget | null { + const git = target.latestGit; + if (!git?.currentBranch) { + return null; + } + + const lookupTarget = + target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch + ? target.latestFacts.pullRequestLookupTarget + : null; + if (lookupTarget) { + return lookupTarget; + } + + return { headRef: git.currentBranch }; + } + private stopGitHubPollForTarget(target: WorkspaceGitTarget): void { target.githubPollSubscription?.unsubscribe(); target.githubPollSubscription = null; - target.githubPollHeadRef = null; + target.githubPollKey = null; } private addWorkingTreeWatcher( @@ -1669,7 +1702,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return null; } - return JSON.stringify([git.remoteUrl, git.currentBranch]); + const pollTarget = this.resolveGitHubPollTarget(target); + if (!pollTarget) { + return null; + } + + return buildWorkspaceGitHubPollKey(git.remoteUrl, pollTarget); } private rememberGitHubSnapshot( @@ -1976,6 +2014,10 @@ function buildGitHubSnapshotFromStatus( }; } +function buildWorkspaceGitHubPollKey(remoteUrl: string, target: WorkspaceGitHubPollTarget): string { + return JSON.stringify([remoteUrl, target.headRef, target.headRepositoryOwner ?? null]); +} + async function runGitFetch(cwd: string): Promise { await runGitCommand(["fetch", "origin", "--prune"], { cwd, diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 91723248e..5ece52308 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -13,7 +13,6 @@ import { type GitHubCommandRunner, type GitHubCommandRunnerOptions, type GitHubCurrentPullRequestStatus, - type GitHubReadOptions, } from "./github-service.js"; import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages"; @@ -186,7 +185,7 @@ function githubStatusFacts( } function recordCurrentPullRequestStatusReads(service: ReturnType) { - const reads: GitHubReadOptions[] = []; + const reads: Parameters[0][] = []; const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service); service.getCurrentPullRequestStatus = vi.fn(async (options) => { reads.push(options); @@ -662,6 +661,41 @@ describe("GitHubService", () => { service.dispose?.(); }); + it("retained fork PR status polls keep the head repository owner", async () => { + const runner = createRunner([ + currentPullRequestJson({ + headRefName: "open-button-targets-active-file", + headRepositoryOwner: { login: "fork-owner" }, + }), + ]); + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + const reads = recordCurrentPullRequestStatusReads(service); + + const subscription = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/repo", + headRef: "open-button-targets-active-file", + headRepositoryOwner: "fork-owner", + }); + await vi.advanceTimersByTimeAsync(0); + + expect(reads).toEqual([ + expect.objectContaining({ + cwd: "/repo", + headRef: "open-button-targets-active-file", + headRepositoryOwner: "fork-owner", + reason: "self-heal-github", + }), + ]); + expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); + + subscription?.unsubscribe(); + service.dispose?.(); + }); + it("polls PR status at slow cadence after stable checks", async () => { let now = 0; const runner = createRunner([ diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 93ec6fe68..d0b15e990 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -642,6 +642,7 @@ export interface GitHubService { retainCurrentPullRequestStatusPoll?(options: { cwd: string; headRef: string; + headRepositoryOwner?: string; onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void; onError?: (error: unknown) => void; }): { unsubscribe: () => void }; @@ -720,6 +721,7 @@ interface InFlightCacheEntry { interface GitHubPollTarget { cwd: string; headRef: string; + headRepositoryOwner?: string; retainCount: number; timer: NodeJS.Timeout | null; latestStatus: GitHubCurrentPullRequestStatus | null; @@ -813,17 +815,25 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G } } - function getPollTargetKey(target: { cwd: string; headRef: string }): string { + function getPollTargetKey(target: { + cwd: string; + headRef: string; + headRepositoryOwner?: string; + }): string { return buildCacheKey({ cwd: target.cwd, method: "getCurrentPullRequestStatus", - args: { headRef: target.headRef }, + args: { + headRef: target.headRef, + headRepositoryOwner: target.headRepositoryOwner, + }, }); } function updatePollTargetAfterSuccess(update: { cwd: string; headRef: string; + headRepositoryOwner?: string; status: GitHubCurrentPullRequestStatus | null; notify: boolean; }): void { @@ -872,6 +882,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G await api.getCurrentPullRequestStatus({ cwd: target.cwd, headRef: target.headRef, + headRepositoryOwner: target.headRepositoryOwner, reason: "self-heal-github", }); } catch (error) { @@ -1027,6 +1038,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G updatePollTargetAfterSuccess({ cwd: input.cwd, headRef: input.headRef, + headRepositoryOwner: input.headRepositoryOwner, status, notify: input.reason === "self-heal-github", }); @@ -1241,6 +1253,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G target = { cwd: input.cwd, headRef: input.headRef, + headRepositoryOwner: input.headRepositoryOwner, retainCount: 0, timer: null, latestStatus: null,