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
@@ -1220,7 +1220,8 @@ export class CheckoutSession {
try {
// The payload schema keeps checkRunId and workflowRunId optional (a Gitea
// Actions run has no check-run id; GitLab routes by changeRequestNumber),
// Actions run has no check-run id; forge adapters may also route by
// changeRequestNumber),
// but a request that addresses no check at all is not actionable — reject
// it here with a clear message instead of failing deep in an adapter. The
// schema itself cannot enforce this: it is a discriminated-union member, so
@@ -1144,7 +1144,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-self-heal.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
pullRequestLookupTarget: {
headRef: "feature",
headSha: "1111111111111111111111111111111111111111",
},
}),
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
@@ -1163,12 +1166,13 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await flushPromises();
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledWith({
cwd: REPO_CWD,
headRef: "feature",
headSha: "1111111111111111111111111111111111111111",
reason: "self-heal-forge-pr-status",
});
expect(listener).toHaveBeenCalledWith(
@@ -1189,6 +1193,108 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
}
});
test("generic forge self-heal uses the fast poll window while checks are pending", async () => {
const forge = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: undefined,
getCurrentPullRequestStatus: vi.fn(async () =>
createCurrentPullRequestStatus({ checksStatus: "pending" }),
),
};
const unregister = defaultForgeRegistry.register("forge-pending-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-pending.test",
});
const pendingResult = createPullRequestStatusResult();
if (pendingResult.status) {
pendingResult.status.checksStatus = "pending";
pendingResult.status.checks = [{ name: "ci", status: "pending" }];
}
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-pending.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
}),
),
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-pending.test/acme/repo.git",
}),
),
getPullRequestStatus: vi.fn(async () => pendingResult),
});
try {
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
await vi.advanceTimersByTimeAsync(20_000);
await flushPromises();
expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
} finally {
service.dispose();
unregister();
}
});
test("generic forge poll refreshes immediately when checkout HEAD changes", async () => {
let nowMs = 0;
let headSha = "1111111111111111111111111111111111111111";
const forge = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: undefined,
getCurrentPullRequestStatus: vi.fn(async () => createCurrentPullRequestStatus()),
};
const unregister = defaultForgeRegistry.register("forge-head-change-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-head-change.test",
});
const service = createService({
now: () => new Date(nowMs),
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-head-change.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature", headSha },
}),
),
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-head-change.test/acme/repo.git",
}),
),
getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult("Visible PR")),
});
try {
await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR");
headSha = "2222222222222222222222222222222222222222";
nowMs = 3_000;
await service.refresh(REPO_CWD);
expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest).toBeNull();
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(0);
await flushPromises();
expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1);
expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed");
subscription.unsubscribe();
} finally {
service.dispose();
unregister();
}
});
test("subscription cancels generic forge PR status self-heal polling after unsubscribe", async () => {
const forge = {
...createGitHubServiceStub(),
@@ -1221,7 +1327,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises();
subscription.unsubscribe();
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled();
@@ -47,7 +47,9 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
const FORGE_PR_STATUS_POLL_INTERVAL_MS = 60_000;
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000;
@@ -336,6 +338,7 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
interface WorkspaceForgePrStatusPollTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}
@@ -1206,9 +1209,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
remoteUrl,
target: pollTarget,
});
const previousPollKey = target.forgePrStatusPollKey;
if (target.forgePrStatusPollKey === pollKey && target.forgePrStatusPollSubscription) {
return;
}
const pollImmediately = previousPollKey !== null && previousPollKey !== pollKey;
this.stopForgePrStatusPollForTarget(target);
target.forgePrStatusPollKey = pollKey;
@@ -1216,6 +1221,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.forgePrStatusPollSubscription = resolution.service.retainCurrentPullRequestStatusPoll({
cwd: target.cwd,
headRef: pollTarget.headRef,
...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}),
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
@@ -1253,6 +1259,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
forge: resolution.forge,
service: resolution.service,
pollTarget,
pollImmediately,
});
}
@@ -1261,23 +1268,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
forge,
service,
pollTarget,
pollImmediately,
}: {
target: WorkspaceGitTarget;
forge: string;
service: ForgeService;
pollTarget: WorkspaceForgePrStatusPollTarget;
pollImmediately: boolean;
}): { unsubscribe: () => void } {
let closed = false;
let timer: NodeJS.Timeout | null = null;
let latestStatus: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] =
target.latestForge?.pullRequest ?? null;
let consecutiveErrors = 0;
const schedule = () => {
const schedule = (delayMs: number) => {
if (closed) {
return;
}
timer = setTimeout(() => {
timer = null;
void poll();
}, FORGE_PR_STATUS_POLL_INTERVAL_MS);
}, delayMs);
};
const poll = async () => {
@@ -1288,17 +1300,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const status = await service.getCurrentPullRequestStatus({
cwd: target.cwd,
headRef: pollTarget.headRef,
...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}),
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
reason: "self-heal-forge-pr-status",
});
if (!closed && this.isActiveObservedWorkspaceTarget(target)) {
latestStatus = status;
consecutiveErrors = 0;
this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), {
notify: true,
});
}
} catch (error) {
consecutiveErrors += 1;
this.logger.warn(
{
err: error,
@@ -1311,11 +1327,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
"Failed to run forge PR status self-heal refresh",
);
} finally {
schedule();
schedule(computeGenericForgeNextInterval(latestStatus, consecutiveErrors));
}
};
schedule();
// A git-only refresh clears forge state when the commit-aware poll identity
// changes. Revalidate that new identity immediately instead of leaving the
// PR panel empty for the full stable polling interval.
schedule(
pollImmediately ? 0 : computeGenericForgeNextInterval(latestStatus, consecutiveErrors),
);
return {
unsubscribe: () => {
closed = true;
@@ -2171,7 +2192,32 @@ function buildWorkspaceForgePrStatusPollKey({
remoteUrl: string;
target: WorkspaceForgePrStatusPollTarget;
}): string {
return JSON.stringify([forge, remoteUrl, target.headRef, target.headRepositoryOwner ?? null]);
return JSON.stringify([
forge,
remoteUrl,
target.headRef,
target.headSha ?? null,
target.headRepositoryOwner ?? null,
]);
}
function computeGenericForgeNextInterval(
status: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"],
consecutiveErrors: number,
): number {
const isPending =
status?.checksStatus === "pending" ||
status?.checks?.some((check) => check.status === "pending") === true;
const baseInterval = isPending
? FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS
: FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS;
if (consecutiveErrors <= 1) {
return baseInterval;
}
return Math.min(
baseInterval * 2 ** (consecutiveErrors - 1),
FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS,
);
}
async function runGitFetch(cwd: string): Promise<void> {
@@ -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;
}
+87 -9
View File
@@ -48,7 +48,11 @@ import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command
import { createForgeResolver } from "../services/forge-resolver.js";
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
import { TeaAuthenticationError, TeaCliMissingError } from "../services/gitea-service.js";
import {
TeaAuthenticationError,
TeaCliMissingError,
TeaCommandError,
} from "../services/gitea-service.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
@@ -161,6 +165,7 @@ function createPullRequestStatus(overrides?: Partial<CurrentPullRequestStatus>)
interface RequestedPullRequestTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}
@@ -176,6 +181,7 @@ function createGitHubServiceRecordingPullRequestTargets(
github.getCurrentPullRequestStatus = async (request) => {
options.requestedTargets.push({
headRef: request.headRef,
...(request.headSha ? { headSha: request.headSha } : {}),
...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}),
});
return createPullRequestStatus({
@@ -2341,7 +2347,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget).toMatchObject({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("keeps the local branch lookup when origin tracking uses the same head name", async () => {
@@ -2356,7 +2363,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "feature" });
expect(lookupTarget).toMatchObject({ headRef: "feature" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("does not attach an owner when the tracked remote is the same GitHub repository", async () => {
@@ -2378,7 +2386,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget).toMatchObject({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("keeps the fork owner when same-repo comparison is indeterminate", async () => {
@@ -2396,7 +2405,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(lookupTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("uses the configured push remote for fork PR lookup when upstream is absent", async () => {
@@ -2425,8 +2435,11 @@ const x = 1;
);
expect(getBranchUpstream(repoDir)).toBeNull();
expect(factsTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]);
expect(factsTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(factsTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
expect(requestedTargets).toEqual([
expect.objectContaining({ headRef: "main", headRepositoryOwner: "chethanuk" }),
]);
});
it("keeps the local branch lookup when same-repo tracking points at the base branch", async () => {
@@ -2441,7 +2454,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "tender-parrot" });
expect(lookupTarget).toMatchObject({ headRef: "tender-parrot" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("derives the same origin tracked head for on-demand PR status reads", async () => {
@@ -2495,7 +2509,9 @@ const x = 1;
const status = await getPullRequestStatus(repoDir, github);
expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]);
expect(requestedTargets).toEqual([
expect.objectContaining({ headRef: "main", headRepositoryOwner: "chethanuk" }),
]);
expect(status.status?.number).toBe(345);
expect(status.status?.headRefName).toBe("main");
});
@@ -2544,6 +2560,33 @@ const x = 1;
expect(callCount).toBe(1);
});
it("does not reuse a PR status cache entry after HEAD changes on the same branch", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
cwd: repoDir,
});
const requestedShas: string[] = [];
const github = createGitHubServiceForStatus(null);
github.getCurrentPullRequestStatus = async (options) => {
if (options.headSha) requestedShas.push(options.headSha);
return createPullRequestStatus({
url: `https://github.com/getpaseo/paseo/pull/${requestedShas.length}`,
});
};
const first = await getPullRequestStatus(repoDir, github);
writeFileSync(join(repoDir, "next.txt"), "next\n");
execFileSync("git", ["add", "next.txt"], { cwd: repoDir });
execFileSync("git", ["commit", "-m", "next commit"], { cwd: repoDir });
const second = await getPullRequestStatus(repoDir, github);
expect(first.status?.url).toContain("/pull/1");
expect(second.status?.url).toContain("/pull/2");
expect(requestedShas).toHaveLength(2);
expect(requestedShas[0]).not.toBe(requestedShas[1]);
});
it("passes forced PR status reads through to the GitHub service", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
@@ -2637,6 +2680,41 @@ const x = 1;
}
});
it("keeps stale PR status when a Gitea-family refresh hits a transient command error", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
cwd: repoDir,
});
__setPullRequestStatusCacheTtlForTests(50);
try {
let callCount = 0;
const service = createGitHubServiceForStatus(null);
service.getCurrentPullRequestStatus = async () => {
callCount += 1;
if (callCount === 1) {
return createPullRequestStatus({ url: "https://gitea.example.com/acme/repo/pulls/7" });
}
throw new TeaCommandError({
args: ["pr", "list"],
cwd: repoDir,
exitCode: 1,
stderr: "request timed out",
});
};
const fresh = await getPullRequestStatus(repoDir, service);
await sleep(80);
const stale = await getPullRequestStatus(repoDir, service);
expect(stale).toEqual(fresh);
expect(stale.status?.url).toContain("/pulls/7");
expect(callCount).toBe(2);
} finally {
__resetPullRequestStatusCacheForTests();
}
});
it("does not use stale PR status fallback for forced GitHub errors", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
+56 -8
View File
@@ -7,7 +7,7 @@ import type { Logger } from "pino";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
import { GitHubCommandError, createGitHubService } from "../services/github-service.js";
import { createGitHubService } from "../services/github-service.js";
import type {
CurrentPullRequestStatus,
ForgeAuthState,
@@ -15,7 +15,11 @@ import type {
ForgeSpecificStatusFacts,
PullRequestMergeable,
} from "../services/forge-service.js";
import { ForgeAuthenticationError, ForgeCliMissingError } from "../services/forge-cli-command.js";
import {
ForgeAuthenticationError,
ForgeCliMissingError,
ForgeCommandError,
} from "../services/forge-cli-command.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
@@ -67,6 +71,7 @@ interface CheckoutReadCacheOptions {
interface PullRequestStatusLookupTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}
@@ -116,8 +121,8 @@ function createShortstatCache(ttlMs: number) {
});
}
function getPullRequestStatusCacheKey(cwd: string): string {
return resolve(cwd);
function getPullRequestStatusCacheKey(cwd: string, headSha: string | null): string {
return `${resolve(cwd)}\u0000${headSha ?? ""}`;
}
function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusResult): void {
@@ -829,6 +834,38 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
}
}
async function getCurrentHeadSha(cwd: string, context?: CheckoutContext): Promise<string | null> {
const knownSha = context?.facts?.isGit
? context.facts.pullRequestLookupTarget?.headSha
: undefined;
if (knownSha) {
return knownSha;
}
try {
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const sha = stdout.trim();
return sha.length > 0 ? sha : null;
} catch {
return null;
}
}
async function addHeadShaToPullRequestLookupTarget(
cwd: string,
target: PullRequestStatusLookupTarget | null,
context?: CheckoutContext,
): Promise<PullRequestStatusLookupTarget | null> {
if (!target) {
return null;
}
const headSha = await getCurrentHeadSha(cwd, context);
return headSha ? { ...target, headSha } : target;
}
async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
const paths = ["rebase-merge/head-name", "rebase-apply/head-name"];
const results = await Promise.all(
@@ -1761,6 +1798,11 @@ export async function getCheckoutSnapshotFacts(
context,
)) ?? pullRequestLookupTarget;
}
pullRequestLookupTarget = await addHeadShaToPullRequestLookupTarget(
cwd,
pullRequestLookupTarget,
context,
);
return {
isGit: true,
@@ -3402,7 +3444,8 @@ export async function getPullRequestStatus(
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
const cacheKey = getPullRequestStatusCacheKey(cwd);
const headSha = await getCurrentHeadSha(cwd, context);
const cacheKey = getPullRequestStatusCacheKey(cwd, headSha);
if (!options?.force) {
const cached = pullRequestStatusCache.get(cacheKey);
if (cached) {
@@ -3415,14 +3458,14 @@ export async function getPullRequestStatus(
}
}
const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context)
const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context, headSha)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status);
return status;
})
.catch((error) => {
if (!options?.force && error instanceof GitHubCommandError) {
if (!options?.force && error instanceof ForgeCommandError) {
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
if (stale) {
return stale;
@@ -3443,6 +3486,7 @@ async function getPullRequestStatusUncached(
forgeService: ForgeService,
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
headSha?: string | null,
): Promise<PullRequestStatusResult> {
if (context?.facts?.isGit === false) {
return buildPullRequestStatusResult(null, "no_remote");
@@ -3455,7 +3499,11 @@ async function getPullRequestStatusUncached(
return buildPullRequestStatusResult(null, "no_remote");
}
try {
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
const resolvedLookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
const lookupTarget =
headSha && !resolvedLookupTarget.headSha
? { ...resolvedLookupTarget, headSha }
: resolvedLookupTarget;
let status: CurrentPullRequestStatus | null;
if (options?.force) {
const reason = options.reason;