fix(forges): retain pull requests after repository renames

Compare the head repository with the forge-resolved identity when its configured path has moved.
This commit is contained in:
Mohamed Boudra
2026-11-02 06:55:23 +01:00
parent a7d5f99a1c
commit 24ef4c48cc
2 changed files with 172 additions and 12 deletions
@@ -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])) {
+49 -12
View File
@@ -1404,6 +1404,10 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
const now = options.now ?? Date.now;
const currentPrHeadShaCache = new Map<string, { sha: string; expiresAt: number }>();
const pullRequestIdentityCache = new LRUCache<string, boolean>({ max: 512, ttl: 120_000 });
const canonicalRepositoryPaths = new LRUCache<string, { path: string | null }>({
max: 512,
ttl: 120_000,
});
async function run(args: string[], runOptions: TeaCommandRunnerOptions): Promise<string> {
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<string | null> | undefined;
const loadCanonicalPath = async (): Promise<string | null> => {
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<boolean> => {
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<boolean>;
exhausted: boolean;
}): Promise<GiteaPrListItem | null> {
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<boolean>;
previousPullRequest?: { number?: number; url: string; state: string } | null;
recent: GiteaPrListItem | null;
}): Promise<GiteaPrListItem | null> {
@@ -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<boolean>,
): Promise<GiteaPrListItem | null> {
// 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<boolean>,
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)) {