fix(forges): retain transferred forks in crowded searches

Resolve the head repository before choosing the bounded fallback so transferred forks keep their pull request association.
This commit is contained in:
Mohamed Boudra
2026-11-02 06:42:09 +01:00
parent f1e262a142
commit a7d5f99a1c
2 changed files with 84 additions and 3 deletions
@@ -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"],
@@ -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;
}