From 24ef4c48ccf21593ef17c4b3341073779cc8631f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 06:55:23 +0100 Subject: [PATCH] fix(forges): retain pull requests after repository renames Compare the head repository with the forge-resolved identity when its configured path has moved. --- .../server/src/services/gitea-service.test.ts | 123 ++++++++++++++++++ packages/server/src/services/gitea-service.ts | 61 +++++++-- 2 files changed, 172 insertions(+), 12 deletions(-) diff --git a/packages/server/src/services/gitea-service.test.ts b/packages/server/src/services/gitea-service.test.ts index 8feae65572..c09598fd77 100644 --- a/packages/server/src/services/gitea-service.test.ts +++ b/packages/server/src/services/gitea-service.test.ts @@ -2185,6 +2185,8 @@ describe("createGiteaService", () => { foreign.head.repo.id = 2; foreign.head.repo.full_name = "example-user/other"; const { service } = makeService((args) => { + if (args[0] === "api" && args[1] === "repos/example-user/sample-repo") + return ok(JSON.stringify({ full_name: "example-user/sample-repo" })); if (args[0] === "pr" && args[1] === "list") return ok("[]"); if (args[0] === "api" && args[1].includes("/pulls?state=all")) return ok(JSON.stringify([foreign])); @@ -2195,6 +2197,123 @@ describe("createGiteaService", () => { ).resolves.toBeNull(); }); + it.each(["open", "merged"] as const)( + "retains a %s PR when its repository was renamed", + async (state) => { + const original = currentPullRequestApi({ + number: 8, + state: state === "open" ? "open" : "closed", + headRef: "topic", + headSha: "1".repeat(40), + merged: state === "merged", + }); + const pr = { + ...original, + html_url: "https://gitea.example/acme/renamed/pulls/8", + head: { + ...original.head, + repo: { ...original.head.repo, full_name: "acme/renamed", owner: { login: "acme" } }, + }, + base: { + ...original.base, + repo: { ...original.base.repo, full_name: "acme/renamed", owner: { login: "acme" } }, + }, + }; + const { service, calls } = makeService( + (args) => { + if (args[0] === "pr" && args[1] === "list") + return ok( + JSON.stringify( + state === "open" + ? [{ ...OPEN_PR, index: "8", head: "topic", url: pr.html_url }] + : [], + ), + ); + if (args[0] === "api" && args[1].endsWith("/pulls/8")) return ok(JSON.stringify(pr)); + if (args[0] === "api" && args[1] === "repos/acme/original") + return ok(JSON.stringify({ full_name: "acme/renamed" })); + if (args[0] === "api" && args[1].includes("pulls?state=all")) + return ok(JSON.stringify([pr])); + if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) + return ok(JSON.stringify({ state: "", statuses: [] })); + throw new Error(`unexpected call: ${args.join(" ")}`); + }, + { resolveRemoteUrl: async () => "https://gitea.example/acme/original.git" }, + ); + const input = { cwd: "/repo", headRef: "topic", headRepositoryPath: "acme/original" }; + expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8); + expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8); + await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }); + expect(calls.filter((args) => args[1] === "repos/acme/original")).toHaveLength(2); + }, + ); + + it("bounds canonical repository reads when many branch candidates differ", async () => { + const other = currentPullRequestApi({ + number: 1, + state: "closed", + headRef: "topic", + headSha: "1".repeat(40), + }); + other.head.repo.full_name = "example-user/other"; + const candidates = Array.from({ length: 50 }, (_, i) => ({ ...other, number: i + 1 })); + const { service, calls } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok("[]"); + if (args[0] === "api" && args[1] === "repos/example-user/sample-repo") + return ok(JSON.stringify({ full_name: "example-user/sample-repo" })); + if (args[0] === "api" && args[1].includes("pulls?state=all")) + return ok(JSON.stringify(candidates)); + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "topic", + force: true, + reason: "archive", + }), + ).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError); + expect(calls.filter((args) => args[1] === "repos/example-user/sample-repo")).toHaveLength(1); + }); + + it("isolates renamed repository identities when the forge remote changes", async () => { + let host = "first.example"; + const original = currentPullRequestApi({ + number: 8, + state: "closed", + headRef: "topic", + headSha: "1".repeat(40), + merged: true, + }); + const { service, calls } = makeService( + (args) => { + if (args[0] === "pr" && args[1] === "list") return ok("[]"); + if (args[0] === "api" && args[1] === "repos/example-user/sample-repo") + return ok(JSON.stringify({ full_name: `example-user/${host}` })); + if (args[0] === "api" && args[1].includes("pulls?state=all")) + return ok( + JSON.stringify([ + { + ...original, + head: { + ...original.head, + repo: { ...original.head.repo, full_name: `example-user/${host}` }, + }, + }, + ]), + ); + throw new Error(`unexpected call: ${args.join(" ")}`); + }, + { resolveRemoteUrl: async () => `https://${host}/example-user/sample-repo.git` }, + ); + const input = { cwd: "/repo", headRef: "topic" }; + expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8); + host = "second.example"; + expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8); + expect(calls.filter((args) => args[1] === "repos/example-user/sample-repo")).toHaveLength(2); + }); + it.each(["merged", "open"] as const)( "finds a recent %s candidate after 200 unrelated open requests", async (state) => { @@ -2366,6 +2485,8 @@ describe("createGiteaService", () => { foreign.head.repo.id = 2; foreign.head.repo.full_name = "example-user/other"; const { service } = makeService((args) => { + if (args[0] === "api" && args[1] === "repos/example-user/sample-repo") + return ok(JSON.stringify({ full_name: "example-user/sample-repo" })); if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([{ ...OPEN_PR, head: "dev" }])); if (args[0] === "api" && args[1].endsWith("/pulls/5")) return ok(JSON.stringify(foreign)); @@ -2428,6 +2549,8 @@ describe("createGiteaService", () => { head: "topic", })); const { service, calls } = makeService((args) => { + if (args[0] === "api" && args[1] === "repos/example-user/sample-repo") + return ok(JSON.stringify({ full_name: "example-user/sample-repo" })); if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify(candidates)); if (args[0] === "api" && args[1].includes("pulls?state=all")) return ok("[]"); if (args[0] === "api" && /\/pulls\/\d+$/.test(args[1])) { diff --git a/packages/server/src/services/gitea-service.ts b/packages/server/src/services/gitea-service.ts index 3699ab9468..de3a1f74c6 100644 --- a/packages/server/src/services/gitea-service.ts +++ b/packages/server/src/services/gitea-service.ts @@ -1404,6 +1404,10 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For const now = options.now ?? Date.now; const currentPrHeadShaCache = new Map(); const pullRequestIdentityCache = new LRUCache({ max: 512, ttl: 120_000 }); + const canonicalRepositoryPaths = new LRUCache({ + max: 512, + ttl: 120_000, + }); async function run(args: string[], runOptions: TeaCommandRunnerOptions): Promise { const teaPath = await resolveTea(); @@ -1658,7 +1662,27 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For } if (!expectedPath) return null; const expectedHeadOwner = expectedPath.split("/")[0] ?? null; - const matchesRepository = (item: GiteaCurrentPullRequestApi): boolean => { + let canonicalLookup: Promise | undefined; + const loadCanonicalPath = async (): Promise => { + const key = JSON.stringify([ + input.cwd, + await resolveRemoteUrl(input.cwd), + repoPath, + expectedPath.toLowerCase(), + ]); + const cached = input.force ? undefined : canonicalRepositoryPaths.get(key); + if (cached !== undefined) return cached.path; + const path = expectedPath.split("/").map(encodeURIComponent).join("/"); + const repository = await runJson( + ["api", `repos/${path}`], + { cwd: input.cwd }, + GiteaPullRequestRepoSchema, + ); + const result = repository.full_name ?? null; + canonicalRepositoryPaths.set(key, { path: result }); + return result; + }; + const matchesRepository = async (item: GiteaCurrentPullRequestApi): Promise => { const headPath = item.head.repo?.full_name ?? (item.head.repo?.id !== undefined && @@ -1666,8 +1690,16 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For repoIdentity ? `${repoIdentity.owner}/${repoIdentity.name}` : null); + if (!headPath) return false; + let canonicalPath = expectedPath; + if (headPath.toLowerCase() !== expectedPath.toLowerCase()) { + canonicalLookup ??= loadCanonicalPath(); + const resolved = await canonicalLookup; + if (!resolved) return false; + canonicalPath = resolved; + } return ( - headPath?.toLowerCase() === expectedPath.toLowerCase() && + headPath.toLowerCase() === canonicalPath.toLowerCase() && matchesHeadRepositoryEndpoint(input.headRepositoryEndpoint, [ item.head.repo?.ssh_url, item.head.repo?.clone_url, @@ -1701,7 +1733,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For previousPullRequest?: { number?: number; url: string; state: string } | null; expectedHeadOwner: string | null; repoPath: string; - matchesRepository: (item: GiteaCurrentPullRequestApi) => boolean; + matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise; exhausted: boolean; }): Promise { let recent: GiteaPrListItem | null; @@ -1740,7 +1772,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For headRef: string; repoPath: string; expectedHeadOwner: string | null; - matchesRepository: (item: GiteaCurrentPullRequestApi) => boolean; + matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise; previousPullRequest?: { number?: number; url: string; state: string } | null; recent: GiteaPrListItem | null; }): Promise { @@ -1763,7 +1795,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For const candidate = currentPullRequestApiToListItem(current); return candidate.url === previous.url && matchesCurrentHeadRef(candidate, input.headRef, input.expectedHeadOwner) && - input.matchesRepository(current) + (await input.matchesRepository(current)) ? candidate : null; } @@ -1772,7 +1804,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For input: { cwd: string; headRef: string }, expectedHeadOwner: string | null, repoPath: string, - matchesRepository: (item: GiteaCurrentPullRequestApi) => boolean, + matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise, ): Promise { // tea discovers repository context from any configured git remote, while // the default remote resolver reads origin only. Preserve that broader @@ -1785,14 +1817,18 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For { cwd: input.cwd }, GiteaCurrentPullRequestApiSchema, ); - const candidates = items.filter( - (item) => + const candidates: GiteaCurrentPullRequestApi[] = []; + for (const item of items) { + if ( matchesCurrentHeadRef( currentPullRequestApiToListItem(item), input.headRef, expectedHeadOwner, - ) && matchesRepository(item), - ); + ) && + (await matchesRepository(item)) + ) + candidates.push(item); + } candidates.sort( (a, b) => Number(b.state === "open") - Number(a.state === "open") || @@ -1822,7 +1858,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For }, expectedHeadOwner: string | null, repoPath: string, - matchesRepository: (item: GiteaCurrentPullRequestApi) => boolean, + matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise, expectedPath: string, endpoint?: import("./forge-service.js").HeadRepositoryEndpoint, ): Promise<{ match: GiteaPrListItem | null; exhausted: boolean }> { @@ -1854,7 +1890,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For { cwd: input.cwd }, GiteaCurrentPullRequestApiSchema, ); - const matches = matchesRepository(detail); + const matches = await matchesRepository(detail); pullRequestIdentityCache.set(identityKey, matches); if (matches) return { match: { ...item, headSha: detail.head.sha }, exhausted: false }; } @@ -2517,6 +2553,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For invalidate(input: { cwd: string }): void { pullRequestIdentityCache.clear(); + canonicalRepositoryPaths.clear(); const prefix = `${input.cwd}:`; for (const key of currentPrHeadShaCache.keys()) { if (key.startsWith(prefix)) {