mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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:
@@ -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])) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user