Fix commit-aware PR resolution across forges

This commit is contained in:
nllptrx
2026-07-18 14:21:10 +02:00
committed by GitHub
parent ffe76a7e57
commit c97f823236
16 changed files with 879 additions and 122 deletions
@@ -10,10 +10,12 @@ describe("forge registry", () => {
const gitlab = createForgeService("gitlab");
const gitea = createForgeService("gitea");
const forgejo = createForgeService("forgejo");
const codeberg = createForgeService("codeberg");
expect(github?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitlab?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitea?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(forgejo?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(codeberg?.getCurrentPullRequestStatus).toBeTypeOf("function");
});
it("returns null for an unregistered forge", () => {
@@ -29,9 +31,10 @@ describe("forge registry", () => {
expect(defaultForgeRegistry.has("gitlab")).toBe(true);
expect(defaultForgeRegistry.has("gitea")).toBe(true);
expect(defaultForgeRegistry.has("forgejo")).toBe(true);
expect(defaultForgeRegistry.has("codeberg")).toBe(true);
expect(defaultForgeRegistry.has("bitbucket")).toBe(false);
expect(defaultForgeRegistry.ids()).toEqual(
expect.arrayContaining(["github", "gitlab", "gitea", "forgejo"]),
expect.arrayContaining(["github", "gitlab", "gitea", "forgejo", "codeberg"]),
);
});
@@ -271,10 +271,10 @@ export type GetCheckDetailsOptions = {
checkRunId?: number;
workflowRunId?: number;
/**
* GitLab-only: the change request iid. GitLab routes the fetch to the change
* request's head pipeline (`glab ci get --merge-request`), so a fork/detached
* MR pipeline living in the source project resolves instead of 404ing against
* the checkout's target project. GitHub ignores it.
* Change request number used when check details must resolve against a
* specific request rather than the current branch. GitLab routes the fetch to
* the MR's head pipeline; Gitea-family adapters resolve the PR head SHA by
* number, including for terminal PRs. GitHub ignores it.
*/
changeRequestNumber?: number;
} & ForgeReadOptions;
@@ -456,6 +456,7 @@ export interface ForgeService {
options: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
} & ForgeReadOptions,
): Promise<CurrentPullRequestStatus | null>;
@@ -496,6 +497,7 @@ export interface ForgeService {
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
onStatus?: (status: CurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
@@ -74,6 +74,35 @@ const CONFLICTING_PR = {
ci: "failure",
};
function currentPullRequestApi(input: {
number: number;
state: "open" | "closed";
headRef: string;
headSha: string;
merged?: boolean;
}) {
return {
number: input.number,
html_url: `https://gitea.com/example-user/sample-repo/pulls/${input.number}`,
title: "Historical pull request",
body: "",
state: input.state,
merged: input.merged ?? false,
mergeable: true,
updated_at: "2026-06-26T10:00:00Z",
labels: [],
head: {
ref: input.headRef,
sha: input.headSha,
repo: { id: 1, owner: { login: "example-user" } },
},
base: {
ref: "main",
repo: { id: 1, owner: { login: "example-user" } },
},
};
}
const STATUS_PR_VIEW = {
id: 161482,
index: 5,
@@ -1048,6 +1077,43 @@ describe("createGiteaService", () => {
]);
});
it("resolves terminal PR check details by explicit change request number", async () => {
const headSha = "8888888888888888888888888888888888888888";
const terminalView = {
...STATUS_PR_VIEW,
index: 8,
state: "closed",
head: "feat/recently-closed",
headSha,
hasMerged: true,
mergedAt: "2026-06-28T17:00:00Z",
};
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(terminalView));
if (args[0] === "api" && args[1].endsWith(`/commits/${headSha}/status`)) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveCurrentBranch: async () => "feat/recently-closed" },
);
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
checkRunId: 2,
changeRequestNumber: 8,
});
expect(details).toMatchObject({ checkRunId: 2, name: "ci/lint", status: "pending" });
expect(calls).toEqual([
["pr", "8", "-o", "json"],
["api", `repos/example-user/sample-repo/commits/${headSha}/status`],
]);
});
it("resolves Gitea Actions check details addressed only by workflowRunId", async () => {
const { service } = makeService(
(args) => {
@@ -1172,6 +1238,9 @@ describe("createGiteaService", () => {
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([]));
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{
@@ -1187,13 +1256,9 @@ describe("createGiteaService", () => {
checkRunId: 2,
}),
).rejects.toThrow("Gitea pull request for branch feat/nonexistent was not found");
expect(
calls
.filter((args) => args[0] === "pr" && args[1] === "list")
.map((args) => [argValue(args, "--state"), argValue(args, "--page")]),
).toEqual([
["open", "1"],
["all", undefined],
expect(calls[1]).toEqual([
"api",
"repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
@@ -1337,7 +1402,7 @@ describe("createGiteaService", () => {
expect(status?.checksStatus).toBe("failure");
});
it("finds the current branch PR on the second open pull-request page", async () => {
it("finds the open current-branch PR even when its remote head SHA differs", async () => {
const firstPage = Array.from({ length: 50 }, (_, index) => ({
...OPEN_PR,
index: String(100 + index),
@@ -1367,6 +1432,7 @@ describe("createGiteaService", () => {
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
headSha: "9999999999999999999999999999999999999999",
});
expect(status?.number).toBe(5);
@@ -1381,18 +1447,18 @@ describe("createGiteaService", () => {
});
it("falls back to the recent all-state PR window for a recently closed current branch PR", async () => {
const closedPr = {
...OPEN_PR,
index: "8",
const headSha = "8888888888888888888888888888888888888888";
const closedPr = currentPullRequestApi({
number: 8,
state: "closed",
url: "https://gitea.com/example-user/sample-repo/pulls/8",
head: "feat/recently-closed",
};
headRef: "feat/recently-closed",
headSha,
});
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "open") {
return ok(JSON.stringify([]));
}
if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "all") {
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([closedPr]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
@@ -1401,22 +1467,79 @@ describe("createGiteaService", () => {
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/recently-closed",
headSha,
});
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
expect(
calls
.filter((args) => args[0] === "pr" && args[1] === "list")
.map((args) => [argValue(args, "--state"), argValue(args, "--page")]),
).toEqual([
["open", "1"],
["all", undefined],
expect(calls[1]).toEqual([
"api",
"repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
it("uses tea repository context when origin identity is unavailable", async () => {
const headSha = "8888888888888888888888888888888888888888";
const closedPr = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "feat/recently-closed",
headSha,
});
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].startsWith("repos/{owner}/{repo}/pulls?")) {
return ok(JSON.stringify([closedPr]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => null },
);
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/recently-closed",
headSha,
});
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
expect(calls[1]).toEqual([
"api",
"repos/{owner}/{repo}/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "dev",
headSha: "1111111111111111111111111111111111111111",
merged: true,
});
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([stale]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
});
it("returns null when no PR matches the current branch", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([]));
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
@@ -1426,13 +1549,9 @@ describe("createGiteaService", () => {
});
expect(status).toBeNull();
expect(
calls
.filter((args) => args[0] === "pr" && args[1] === "list")
.map((args) => [argValue(args, "--state"), argValue(args, "--page")]),
).toEqual([
["open", "1"],
["all", undefined],
expect(calls[1]).toEqual([
"api",
"repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
+95 -12
View File
@@ -234,6 +234,36 @@ const GiteaPullRequestApiSchema = z
})
.passthrough();
const GiteaCurrentPullRequestApiSchema = z
.object({
number: z.number(),
html_url: z.string(),
title: z.string(),
body: z.string().nullable().optional(),
state: z.string(),
merged: z.boolean().optional(),
mergeable: z.boolean().nullable().optional(),
updated_at: z.string().optional(),
labels: z
.array(z.object({ name: z.string() }).passthrough())
.optional()
.default([]),
head: z
.object({
ref: z.string(),
sha: z.string(),
repo: GiteaPullRequestRepoSchema.nullable().optional(),
})
.passthrough(),
base: z
.object({
ref: z.string(),
repo: GiteaPullRequestRepoSchema.nullable().optional(),
})
.passthrough(),
})
.passthrough();
const GiteaCommitStatusSchema = z
.object({
id: z.number(),
@@ -324,6 +354,7 @@ const GiteaReviewCommentSchema = z
type GiteaPrListItem = z.infer<typeof GiteaPrListItemSchema>;
type GiteaIssueListItem = z.infer<typeof GiteaIssueListItemSchema>;
type GiteaPullRequestView = z.infer<typeof GiteaPullRequestViewSchema>;
type GiteaCurrentPullRequestApi = z.infer<typeof GiteaCurrentPullRequestApiSchema>;
type GiteaCommitStatus = z.infer<typeof GiteaCommitStatusSchema>;
type GiteaCombinedCommitStatus = z.infer<typeof GiteaCombinedCommitStatusSchema>;
type GiteaActionsRun = z.infer<typeof GiteaActionsRunSchema>;
@@ -801,6 +832,25 @@ function parseGiteaRepoFromUrl(url: string): { owner?: string; name?: string } {
return {};
}
function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): GiteaPrListItem {
const headOwner = item.head.repo?.owner?.login;
const baseOwner = item.base.repo?.owner?.login;
const head =
headOwner && headOwner !== baseOwner ? `${headOwner}:${item.head.ref}` : item.head.ref;
return {
index: String(item.number),
state: item.merged ? "merged" : item.state,
url: item.html_url,
title: item.title,
body: item.body ?? "",
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
base: item.base.ref,
head,
updated: item.updated_at,
labels: item.labels.map((label) => label.name).join(","),
};
}
function toPullRequestSummary(item: GiteaPrListItem): PullRequestSummary {
const { owner, name } = parseGiteaRepoFromUrl(item.url);
return {
@@ -1347,6 +1397,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
async function loadCurrentPullRequestStatus(input: {
cwd: string;
headRef: string;
headSha?: string;
}): Promise<CurrentPullRequestStatus | null> {
const match = await findCurrentPullRequestForHeadRef(input);
if (!match) {
@@ -1356,7 +1407,9 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
return loadCurrentPullRequestChecks(input.cwd, match, status);
}
async function resolveExpectedHeadOwner(cwd: string): Promise<string | null> {
async function resolveCurrentRepoIdentity(
cwd: string,
): Promise<{ owner: string; name: string } | null> {
const remoteUrl = await resolveRemoteUrl(cwd);
if (!remoteUrl) {
return null;
@@ -1365,34 +1418,60 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
if (!location) {
return null;
}
return parseGitHubRemoteIdentity(location.path)?.owner ?? null;
const identity = parseGitHubRemoteIdentity(location.path);
return identity ? { owner: identity.owner, name: identity.name } : null;
}
async function findCurrentPullRequestForHeadRef(input: {
cwd: string;
headRef: string;
headSha?: string;
}): Promise<GiteaPrListItem | null> {
const expectedHeadOwner = await resolveExpectedHeadOwner(input.cwd);
const repoIdentity = await resolveCurrentRepoIdentity(input.cwd);
const expectedHeadOwner = repoIdentity?.owner ?? null;
const openMatch = await findOpenPullRequestForHeadRef(input, expectedHeadOwner);
if (openMatch) {
return openMatch;
}
const recentItems = await listPullRequestItems({
cwd: input.cwd,
state: "all",
limit: CURRENT_PR_LOOKUP_PAGE_SIZE,
});
return (
recentItems.find((item) => matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner)) ??
null
// tea discovers repository context from any configured git remote, while
// the default remote resolver reads origin only. Preserve that broader
// discovery for repositories whose primary remote has another name.
const repoPath = repoIdentity
? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}`
: "repos/{owner}/{repo}";
const recentItems = await runJsonArray(
[
"api",
`${repoPath}/pulls?state=all&sort=recentupdate&page=1&limit=${CURRENT_PR_LOOKUP_PAGE_SIZE}`,
],
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const candidates = recentItems.filter((item) =>
matchesCurrentHeadRef(
currentPullRequestApiToListItem(item),
input.headRef,
expectedHeadOwner,
),
);
const match =
candidates.find((item) => mapGiteaState(item.state) === "open") ??
candidates.find(
(item) =>
mapGiteaState(item.state) !== "open" &&
input.headSha !== undefined &&
item.head.sha === input.headSha,
) ??
null;
return match ? currentPullRequestApiToListItem(match) : null;
}
async function findOpenPullRequestForHeadRef(
input: {
cwd: string;
headRef: string;
headSha?: string;
},
expectedHeadOwner: string | null,
): Promise<GiteaPrListItem | null> {
@@ -1668,7 +1747,11 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
},
getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
return loadCurrentPullRequestStatus({ cwd: input.cwd, headRef: input.headRef });
return loadCurrentPullRequestStatus({
cwd: input.cwd,
headRef: input.headRef,
headSha: input.headSha,
});
},
async getPullRequest(input: GetPullRequestOptions): Promise<PullRequestSummary> {
@@ -21,7 +21,7 @@ const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
interface RunnerCall {
@@ -207,6 +207,7 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
isDraft: false,
baseRefName: "main",
headRefName: "feature/fork",
headRefOid: "1111111111111111111111111111111111111111",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
@@ -2246,6 +2247,26 @@ describe("ForgeService", () => {
expect(status?.mergeable).toBe("UNKNOWN");
});
it("keeps an open PR when its remote head SHA differs from the checkout HEAD", async () => {
const runner = createRunner([
currentPullRequestJson({
headRefOid: "1111111111111111111111111111111111111111",
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
});
expect(status).toMatchObject({ number: 42, state: "open" });
});
it("loads GitHub merge, auto-merge, permission, policy, and queue facts for PR 993 shape", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
@@ -2313,6 +2334,100 @@ describe("ForgeService", () => {
});
});
it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const runner = createRunner([
currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: headSha,
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha,
});
expect(status?.number).toBe(42);
expect(status?.state).toBe("merged");
});
it("does not attach a stale merged PR after a same-name branch advances", async () => {
const stale = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
});
const runner = createScriptedRunner([
stale,
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([JSON.parse(stale)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
const staleView = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-15T12:00:00Z",
headRefOid: "0000000000000000000000000000000000000000",
headRepositoryOwner: owner,
});
const open = JSON.parse(
currentPullRequestJson({
number: 43,
state: "OPEN",
headRefOid: "3333333333333333333333333333333333333333",
headRepositoryOwner: owner,
}),
);
const exactMerged = JSON.parse(
currentPullRequestJson({
number: 42,
state: "MERGED",
mergedAt: "2026-07-16T12:00:00Z",
headRefOid: checkoutSha,
headRepositoryOwner: owner,
}),
);
const runner = createScriptedRunner([staleView, JSON.stringify([exactMerged, open])]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: checkoutSha,
headRepositoryOwner: "forkOwner",
});
expect(status).toMatchObject({ number: 43, state: "open" });
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
@@ -2541,6 +2656,18 @@ describe("ForgeService", () => {
expect(calls.map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"],
[
"pr",
"list",
"--state",
"all",
"--head",
"main",
"--limit",
"10",
"--json",
CURRENT_PR_STATUS_FIELDS,
],
]);
});
+43 -7
View File
@@ -325,6 +325,7 @@ const CurrentPullRequestStatusSchema = z.object({
isDraft: z.boolean().optional().catch(false),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
@@ -493,7 +494,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -716,6 +717,7 @@ interface InFlightCacheEntry {
interface GitHubPollTarget {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
retainCount: number;
timer: NodeJS.Timeout | null;
@@ -727,6 +729,7 @@ interface GitHubPollTarget {
interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus;
headSha?: string;
headRepositoryOwner?: string;
}
@@ -868,6 +871,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
function getPollTargetKey(target: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}): string {
return buildCacheKey({
@@ -875,6 +879,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
method: "getCurrentPullRequestStatus",
args: {
headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner,
},
});
@@ -883,6 +888,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
function updatePollTargetAfterSuccess(update: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
status: CurrentPullRequestStatus | null;
notify: boolean;
@@ -932,6 +938,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
await api.getCurrentPullRequestStatus({
cwd: target.cwd,
headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner,
reason: "self-heal-github",
});
@@ -1095,6 +1102,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
method: "getCurrentPullRequestStatus",
args: {
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
},
readOptions: input,
@@ -1102,6 +1110,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const status = await resolveCurrentPullRequestView({
cwd: input.cwd,
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
run,
});
@@ -1111,6 +1120,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
updatePollTargetAfterSuccess({
cwd: input.cwd,
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
status,
notify: input.reason === "self-heal-github",
@@ -1502,6 +1512,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
target = {
cwd: input.cwd,
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
retainCount: 0,
timer: null,
@@ -1869,6 +1880,7 @@ function isStatusCheckRollupPermissionError(error: unknown): boolean {
async function resolveCurrentPullRequestView(options: {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
@@ -1877,6 +1889,7 @@ async function resolveCurrentPullRequestView(options: {
? pickPullRequestCandidate({
candidates: [viewCandidate],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
@@ -1893,12 +1906,13 @@ async function resolveCurrentPullRequestView(options: {
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner || !parentOwner || !parentName) {
if (!forkOwner) {
return null;
}
listHeadRef = `${forkOwner}:${options.headRef}`;
listRepo = `${parentOwner}/${parentName}`;
if (parentOwner && parentName) {
listHeadRef = `${forkOwner}:${options.headRef}`;
listRepo = `${parentOwner}/${parentName}`;
}
headRepositoryOwner = forkOwner;
}
@@ -1911,6 +1925,7 @@ async function resolveCurrentPullRequestView(options: {
const match = pickPullRequestCandidate({
candidates,
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner,
});
return match?.status ?? null;
@@ -2173,8 +2188,10 @@ function toCurrentPullRequestCandidate(
return null;
}
const headRepositoryOwner = item.headRepositoryOwner?.login;
const headSha = item.headRefOid;
return {
status,
...(headSha ? { headSha } : {}),
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
}
@@ -2190,26 +2207,45 @@ function hasResolvedRepoIdentity(status: CurrentPullRequestStatus): boolean {
function pickPullRequestCandidate(options: {
candidates: ResolvedPullRequestCandidate[];
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}): ResolvedPullRequestCandidate | null {
const matching = options.candidates.filter((candidate) => {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
return false;
}
if (!options.headRepositoryOwner) {
return true;
}
return candidate.headRepositoryOwner === options.headRepositoryOwner;
});
matching.sort(comparePullRequestCandidatePreference);
matching.sort((left, right) =>
comparePullRequestCandidatePreference(left, right, options.headSha),
);
return matching[0] ?? null;
}
function comparePullRequestCandidatePreference(
left: ResolvedPullRequestCandidate,
right: ResolvedPullRequestCandidate,
headSha?: string,
): number {
return getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
const stateRank = getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
if (stateRank !== 0) {
return stateRank;
}
const leftExact = headSha !== undefined && left.headSha === headSha;
const rightExact = headSha !== undefined && right.headSha === headSha;
if (leftExact !== rightExact) {
return leftExact ? -1 : 1;
}
return 0;
}
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
@@ -33,6 +33,24 @@ function makeService(responder: Responder, overrides: Partial<CreateGitLabServic
return { service, calls };
}
function currentMrListArgs(headRef: string): string[] {
return [
"mr",
"list",
"--all",
"--source-branch",
headRef,
"--order",
"updated_at",
"--sort",
"desc",
"--per-page",
"100",
"-F",
"json",
];
}
function gitlabAutoMergeStatus(
overrides: Partial<GitLabStatusFacts> = {},
): PullRequestCommandStatus {
@@ -60,6 +78,7 @@ const OPEN_MR = {
state: "opened",
source_branch: "release/v0.4.0",
target_branch: "main",
sha: "1111111111111111111111111111111111111111",
source_project_id: 101,
target_project_id: 101,
draft: false,
@@ -231,7 +250,9 @@ const DISCUSSIONS = [
describe("createGitLabService", () => {
it("maps a glab merge request view to the neutral current PR status", async () => {
const { service, calls } = makeService(() => ok(JSON.stringify(OPEN_MR)));
const { service, calls } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [OPEN_MR] : OPEN_MR)),
);
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
@@ -261,23 +282,26 @@ describe("createGitLabService", () => {
pipelineStatus: "success",
mergeWhenPipelineSucceeds: false,
});
expect(calls[0]).toEqual(["mr", "view", "release/v0.4.0", "-F", "json"]);
expect(calls[0]).toEqual(currentMrListArgs("release/v0.4.0"));
expect(calls[1]).toEqual(["mr", "view", "14", "-F", "json"]);
});
it("reports a conflicting merge request as CONFLICTING", async () => {
const { service } = makeService(() =>
ok(
JSON.stringify({ ...OPEN_MR, has_conflicts: true, detailed_merge_status: "broken_status" }),
),
const conflicting = {
...OPEN_MR,
source_branch: "x",
has_conflicts: true,
detailed_merge_status: "broken_status",
};
const { service } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [conflicting] : conflicting)),
);
const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "x" });
expect(status?.mergeable).toBe("CONFLICTING");
});
it("returns null when no merge request exists for the branch", async () => {
const { service } = makeService(() => {
throw { code: 1, stderr: "no open merge request available for 'feature/x'" };
});
const { service } = makeService(() => ok("[]"));
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/x",
@@ -285,6 +309,61 @@ describe("createGitLabService", () => {
expect(status).toBeNull();
});
it("selects a terminal merge request only when its head SHA matches the checkout", async () => {
const branch = "dev";
const checkoutSha = "2222222222222222222222222222222222222222";
const newestStale = {
...OPEN_MR,
iid: 271,
state: "merged",
source_branch: branch,
sha: "1111111111111111111111111111111111111111",
updated_at: "2026-07-17T12:00:00.000Z",
};
const exactOlder = {
...OPEN_MR,
iid: 270,
state: "merged",
source_branch: branch,
sha: checkoutSha,
updated_at: "2026-07-16T12:00:00.000Z",
};
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([newestStale, exactOlder]));
if (args[1] === "view" && args[2] === "270") return ok(JSON.stringify(exactOlder));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: branch,
headSha: checkoutSha,
});
expect(status?.number).toBe(270);
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
});
it("does not attach the latest historical merge request after a reused branch advances", async () => {
const stale = {
...OPEN_MR,
state: "merged",
source_branch: "dev",
sha: "1111111111111111111111111111111111111111",
};
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
expect(calls).toEqual([currentMrListArgs("dev")]);
});
it("looks up a numeric current branch through the source-branch list filter", async () => {
const numericBranchMr = {
...OPEN_MR,
@@ -313,6 +392,7 @@ describe("createGitLabService", () => {
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "1234",
headSha: "2222222222222222222222222222222222222222",
});
expect(status).toMatchObject({
@@ -320,7 +400,7 @@ describe("createGitLabService", () => {
title: "Fix numeric branch",
headRefName: "1234",
});
expect(calls[0]).toEqual(["mr", "list", "--source-branch", "1234", "-F", "json"]);
expect(calls[0]).toEqual(currentMrListArgs("1234"));
expect(calls[1]).toEqual(["mr", "view", "21", "-F", "json"]);
expect(calls).not.toContainEqual(["mr", "view", "1234", "-F", "json"]);
});
@@ -342,7 +422,7 @@ describe("createGitLabService", () => {
});
expect(status).toBeNull();
expect(calls).toEqual([["mr", "list", "--source-branch", "1234", "-F", "json"]]);
expect(calls).toEqual([currentMrListArgs("1234")]);
});
it("lists merge requests as neutral PR summaries", async () => {
@@ -549,17 +629,16 @@ describe("createGitLabService", () => {
});
it("surfaces the head pipeline id and url on the gitlab status facts", async () => {
const { service } = makeService(() =>
ok(
JSON.stringify({
...OPEN_MR,
head_pipeline: {
id: 306,
status: "running",
web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306",
},
}),
),
const pipelineMr = {
...OPEN_MR,
head_pipeline: {
id: 306,
status: "running",
web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306",
},
};
const { service } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [pipelineMr] : pipelineMr)),
);
const status = await service.getCurrentPullRequestStatus({
@@ -682,6 +761,7 @@ describe("createGitLabService", () => {
it("populates approval counts from the approvals endpoint", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([OPEN_MR]));
if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR));
if (args[0] === "api" && args[1].endsWith("/approvals")) return ok(JSON.stringify(APPROVALS));
throw new Error(`unexpected call: ${args.join(" ")}`);
@@ -697,7 +777,7 @@ describe("createGitLabService", () => {
approvalsRequired: 2,
approvalsGiven: 1,
});
expect(calls[1]).toEqual([
expect(calls[2]).toEqual([
"api",
"projects/example-group%2Fexample-project/merge_requests/14/approvals",
]);
@@ -705,6 +785,7 @@ describe("createGitLabService", () => {
it("falls back to zero approvals when the approvals endpoint returns an error", async () => {
const { service } = makeService((args) => {
if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([OPEN_MR]));
if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR));
throw { code: 1, stderr: "500 Internal Server Error" };
});
+27 -15
View File
@@ -149,6 +149,7 @@ const GitLabMergeRequestSchema = z
state: z.string(),
source_branch: z.string(),
target_branch: z.string(),
sha: z.string().optional(),
source_project_id: z.number().nullable().optional(),
target_project_id: z.number().nullable().optional(),
draft: z.boolean().optional(),
@@ -861,16 +862,26 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema);
}
function isNumericBranchRef(ref: string): boolean {
return /^[0-9]+$/.test(ref);
}
async function listOpenMergeRequestsBySourceBranch(
async function listMergeRequestsBySourceBranch(
cwd: string,
sourceBranch: string,
): Promise<GitLabMergeRequest[]> {
return runJson(
["mr", "list", "--source-branch", sourceBranch, "-F", "json"],
[
"mr",
"list",
"--all",
"--source-branch",
sourceBranch,
"--order",
"updated_at",
"--sort",
"desc",
"--per-page",
"100",
"-F",
"json",
],
{ cwd },
z.array(GitLabMergeRequestSchema),
);
@@ -879,16 +890,17 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
async function resolveCurrentMergeRequest(
cwd: string,
headRef: string,
headSha?: string,
): Promise<GitLabMergeRequest | null> {
if (!isNumericBranchRef(headRef)) {
return viewMergeRequest(cwd, headRef);
}
const mergeRequests = await listOpenMergeRequestsBySourceBranch(cwd, headRef);
const mergeRequests = await listMergeRequestsBySourceBranch(cwd, headRef);
const candidates = mergeRequests.filter((mr) => mr.source_branch === headRef);
const match =
mergeRequests.find(
(mr) => mr.source_branch === headRef && mapMergeRequestState(mr.state) === "open",
) ?? null;
candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ??
candidates.find(
(mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
null;
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
@@ -1004,7 +1016,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
try {
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef);
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
if (!mr) {
return null;
}