diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index fafac7f286..225f9235ea 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -3553,6 +3553,7 @@ describe("ForgeService", () => { const runner = createScriptedRunner([ JSON.stringify(other), JSON.stringify(crowd), + JSON.stringify({ full_name: "same/wanted" }), JSON.stringify({ owner: { login: "base" }, name: "project", parent: null }), JSON.stringify([{ number: 42, state: "open", head: { repo: { full_name: "same/wanted" } } }]), wanted, @@ -3880,6 +3881,80 @@ describe("ForgeService", () => { ).toMatchObject({ number: 42 }); }); + it("finds a transferred fork when the branch candidate window is full", async () => { + const pr = JSON.parse( + currentPullRequestJson({ + url: "https://github.com/base/repo/pull/42", + headRepositoryOwner: { login: "new-fork" }, + headRepository: { name: "repo" }, + }), + ); + const unrelated = Array.from({ length: 9 }, (_, i) => ({ + ...pr, + number: i + 50, + headRepositoryOwner: { login: "other" }, + })); + const runner = createScriptedRunner([ + JSON.stringify(pr), + JSON.stringify([...unrelated, pr]), + JSON.stringify({ full_name: "new-fork/repo" }), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + expect( + await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + headRepositoryOwner: "old-fork", + headRepositoryPath: "old-fork/repo", + }), + ).toMatchObject({ number: 42, state: "open" }); + }); + + it("uses the canonical fork in the bounded terminal fallback after a transfer", async () => { + const pr = JSON.parse( + currentPullRequestJson({ + url: "https://github.com/base/repo/pull/42", + state: "MERGED", + mergedAt: "2026-01-01T00:00:00Z", + headRepositoryOwner: { login: "new-fork" }, + headRepository: { name: "repo" }, + }), + ); + const unrelated = Array.from({ length: 9 }, (_, i) => ({ + ...pr, + number: i + 50, + headRepositoryOwner: { login: "other" }, + })); + const runner = createScriptedRunner([ + JSON.stringify(pr), + JSON.stringify([...unrelated, pr]), + JSON.stringify({ full_name: "new-fork/repo" }), + JSON.stringify({ owner: { login: "base" }, name: "repo", parent: null }), + JSON.stringify([ + { number: 42, state: "closed", head: { repo: { full_name: "new-fork/repo" } } }, + ]), + JSON.stringify(pr), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + expect( + await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + headRepositoryOwner: "old-fork", + headRepositoryPath: "old-fork/repo", + }), + ).toMatchObject({ number: 42, state: "merged" }); + expect( + runner.calls.some((call) => call.args[1]?.includes("head=new-fork%3Afeature%2Ffork")), + ).toBe(true); + }); + it("propagates a failed fork PR lookup instead of reporting no pull request", async () => { const rateLimitError = new GitHubCommandError({ args: ["api", "repos/parentOwner/parentRepo/pulls"], diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index a74ff31898..a636b4dadc 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -3149,9 +3149,6 @@ async function resolveListedPullRequest( headRef: options.headRef, run: options.run, }); - if (candidates.length >= BATCH_PR_CANDIDATE_LIMIT && options.headRepositoryPath) { - return resolveCrowdedPullRequests(options, options.headRepositoryPath); - } let match = pickPullRequestCandidate({ candidates: viewMatch ? [viewMatch, ...candidates] : candidates, headRef: options.headRef, @@ -3175,6 +3172,11 @@ async function resolveListedPullRequest( repository.full_name && repository.full_name.toLowerCase() !== options.headRepositoryPath.toLowerCase() ) { + options = { + ...options, + headRepositoryPath: repository.full_name, + headRepositoryOwner: repository.full_name.split("/")[0], + }; match = pickPullRequestCandidate({ candidates: viewMatch ? [viewMatch, ...candidates] : candidates, headRef: options.headRef, @@ -3183,6 +3185,10 @@ async function resolveListedPullRequest( }); } } + if (candidates.length >= BATCH_PR_CANDIDATE_LIMIT && options.headRepositoryPath) { + if (match?.status.state === "open") return match.status; + return resolveCrowdedPullRequests(options, options.headRepositoryPath); + } return match?.status ?? null; }