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
+14
View File
@@ -84,6 +84,20 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts` - `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported - `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Cloud hosts in the manifest are a bounded public-host list, not a self-host Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
host that is either a known cloud host or one the CLI is already authenticated host that is either a known cloud host or one the CLI is already authenticated
@@ -411,6 +411,7 @@ export function PullRequestPane({
repoName: data.repoName, repoName: data.repoName,
checkRunId: ref.checkRunId, checkRunId: ref.checkRunId,
workflowRunId: ref.workflowRunId, workflowRunId: ref.workflowRunId,
changeRequestNumber: data.number,
}; };
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*. // all supported clients use checkout.forge.get_check_details.*.
+3 -3
View File
@@ -1762,9 +1762,9 @@ const CheckoutCheckDetailsRequestPayloadSchema = z.object({
checkRunId: z.number().int().positive().optional(), checkRunId: z.number().int().positive().optional(),
workflowRunId: z.number().int().positive().optional(), workflowRunId: z.number().int().positive().optional(),
// Permanent forge-routing field, optional because only some forges need it: // Permanent forge-routing field, optional because only some forges need it:
// GitLab routes the check-details fetch to the change request's head pipeline // GitLab routes check details to the MR's head pipeline; Gitea-family adapters
// by iid, so a fork/detached MR pipeline (which lives in the source project, // resolve the PR head SHA by number, including after merge/close. GitHub
// not the checkout's target project) resolves correctly. GitHub ignores it. // ignores it.
changeRequestNumber: z.number().int().positive().optional(), changeRequestNumber: z.number().int().positive().optional(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -1220,7 +1220,8 @@ export class CheckoutSession {
try { try {
// The payload schema keeps checkRunId and workflowRunId optional (a Gitea // 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 // 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 // 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 // schema itself cannot enforce this: it is a discriminated-union member, so
@@ -1144,7 +1144,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
createCheckoutFacts(cwd, { createCheckoutFacts(cwd, {
currentBranch: "feature", currentBranch: "feature",
remoteUrl: "https://forge-self-heal.test/acme/repo.git", remoteUrl: "https://forge-self-heal.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" }, pullRequestLookupTarget: {
headRef: "feature",
headSha: "1111111111111111111111111111111111111111",
},
}), }),
); );
const getCheckoutStatus = vi.fn(async (cwd: string) => const getCheckoutStatus = vi.fn(async (cwd: string) =>
@@ -1163,12 +1166,13 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await flushPromises(); await flushPromises();
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(120_000);
await flushPromises(); await flushPromises();
expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledWith({ expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledWith({
cwd: REPO_CWD, cwd: REPO_CWD,
headRef: "feature", headRef: "feature",
headSha: "1111111111111111111111111111111111111111",
reason: "self-heal-forge-pr-status", reason: "self-heal-forge-pr-status",
}); });
expect(listener).toHaveBeenCalledWith( 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 () => { test("subscription cancels generic forge PR status self-heal polling after unsubscribe", async () => {
const forge = { const forge = {
...createGitHubServiceStub(), ...createGitHubServiceStub(),
@@ -1221,7 +1327,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises(); await flushPromises();
subscription.unsubscribe(); subscription.unsubscribe();
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(120_000);
await flushPromises(); await flushPromises();
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled(); 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 WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_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; const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read. // Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000;
@@ -336,6 +338,7 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
interface WorkspaceForgePrStatusPollTarget { interface WorkspaceForgePrStatusPollTarget {
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
} }
@@ -1206,9 +1209,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
remoteUrl, remoteUrl,
target: pollTarget, target: pollTarget,
}); });
const previousPollKey = target.forgePrStatusPollKey;
if (target.forgePrStatusPollKey === pollKey && target.forgePrStatusPollSubscription) { if (target.forgePrStatusPollKey === pollKey && target.forgePrStatusPollSubscription) {
return; return;
} }
const pollImmediately = previousPollKey !== null && previousPollKey !== pollKey;
this.stopForgePrStatusPollForTarget(target); this.stopForgePrStatusPollForTarget(target);
target.forgePrStatusPollKey = pollKey; target.forgePrStatusPollKey = pollKey;
@@ -1216,6 +1221,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.forgePrStatusPollSubscription = resolution.service.retainCurrentPullRequestStatusPoll({ target.forgePrStatusPollSubscription = resolution.service.retainCurrentPullRequestStatusPoll({
cwd: target.cwd, cwd: target.cwd,
headRef: pollTarget.headRef, headRef: pollTarget.headRef,
...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}),
...(pollTarget.headRepositoryOwner ...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner } ? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}), : {}),
@@ -1253,6 +1259,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
forge: resolution.forge, forge: resolution.forge,
service: resolution.service, service: resolution.service,
pollTarget, pollTarget,
pollImmediately,
}); });
} }
@@ -1261,23 +1268,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
forge, forge,
service, service,
pollTarget, pollTarget,
pollImmediately,
}: { }: {
target: WorkspaceGitTarget; target: WorkspaceGitTarget;
forge: string; forge: string;
service: ForgeService; service: ForgeService;
pollTarget: WorkspaceForgePrStatusPollTarget; pollTarget: WorkspaceForgePrStatusPollTarget;
pollImmediately: boolean;
}): { unsubscribe: () => void } { }): { unsubscribe: () => void } {
let closed = false; let closed = false;
let timer: NodeJS.Timeout | null = null; 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) { if (closed) {
return; return;
} }
timer = setTimeout(() => { timer = setTimeout(() => {
timer = null; timer = null;
void poll(); void poll();
}, FORGE_PR_STATUS_POLL_INTERVAL_MS); }, delayMs);
}; };
const poll = async () => { const poll = async () => {
@@ -1288,17 +1300,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const status = await service.getCurrentPullRequestStatus({ const status = await service.getCurrentPullRequestStatus({
cwd: target.cwd, cwd: target.cwd,
headRef: pollTarget.headRef, headRef: pollTarget.headRef,
...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}),
...(pollTarget.headRepositoryOwner ...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner } ? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}), : {}),
reason: "self-heal-forge-pr-status", reason: "self-heal-forge-pr-status",
}); });
if (!closed && this.isActiveObservedWorkspaceTarget(target)) { if (!closed && this.isActiveObservedWorkspaceTarget(target)) {
latestStatus = status;
consecutiveErrors = 0;
this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), { this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), {
notify: true, notify: true,
}); });
} }
} catch (error) { } catch (error) {
consecutiveErrors += 1;
this.logger.warn( this.logger.warn(
{ {
err: error, err: error,
@@ -1311,11 +1327,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
"Failed to run forge PR status self-heal refresh", "Failed to run forge PR status self-heal refresh",
); );
} finally { } 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 { return {
unsubscribe: () => { unsubscribe: () => {
closed = true; closed = true;
@@ -2171,7 +2192,32 @@ function buildWorkspaceForgePrStatusPollKey({
remoteUrl: string; remoteUrl: string;
target: WorkspaceForgePrStatusPollTarget; target: WorkspaceForgePrStatusPollTarget;
}): string { }): 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> { async function runGitFetch(cwd: string): Promise<void> {
@@ -10,10 +10,12 @@ describe("forge registry", () => {
const gitlab = createForgeService("gitlab"); const gitlab = createForgeService("gitlab");
const gitea = createForgeService("gitea"); const gitea = createForgeService("gitea");
const forgejo = createForgeService("forgejo"); const forgejo = createForgeService("forgejo");
const codeberg = createForgeService("codeberg");
expect(github?.getCurrentPullRequestStatus).toBeTypeOf("function"); expect(github?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitlab?.getCurrentPullRequestStatus).toBeTypeOf("function"); expect(gitlab?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(gitea?.getCurrentPullRequestStatus).toBeTypeOf("function"); expect(gitea?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(forgejo?.getCurrentPullRequestStatus).toBeTypeOf("function"); expect(forgejo?.getCurrentPullRequestStatus).toBeTypeOf("function");
expect(codeberg?.getCurrentPullRequestStatus).toBeTypeOf("function");
}); });
it("returns null for an unregistered forge", () => { it("returns null for an unregistered forge", () => {
@@ -29,9 +31,10 @@ describe("forge registry", () => {
expect(defaultForgeRegistry.has("gitlab")).toBe(true); expect(defaultForgeRegistry.has("gitlab")).toBe(true);
expect(defaultForgeRegistry.has("gitea")).toBe(true); expect(defaultForgeRegistry.has("gitea")).toBe(true);
expect(defaultForgeRegistry.has("forgejo")).toBe(true); expect(defaultForgeRegistry.has("forgejo")).toBe(true);
expect(defaultForgeRegistry.has("codeberg")).toBe(true);
expect(defaultForgeRegistry.has("bitbucket")).toBe(false); expect(defaultForgeRegistry.has("bitbucket")).toBe(false);
expect(defaultForgeRegistry.ids()).toEqual( 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; checkRunId?: number;
workflowRunId?: number; workflowRunId?: number;
/** /**
* GitLab-only: the change request iid. GitLab routes the fetch to the change * Change request number used when check details must resolve against a
* request's head pipeline (`glab ci get --merge-request`), so a fork/detached * specific request rather than the current branch. GitLab routes the fetch to
* MR pipeline living in the source project resolves instead of 404ing against * the MR's head pipeline; Gitea-family adapters resolve the PR head SHA by
* the checkout's target project. GitHub ignores it. * number, including for terminal PRs. GitHub ignores it.
*/ */
changeRequestNumber?: number; changeRequestNumber?: number;
} & ForgeReadOptions; } & ForgeReadOptions;
@@ -456,6 +456,7 @@ export interface ForgeService {
options: { options: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
} & ForgeReadOptions, } & ForgeReadOptions,
): Promise<CurrentPullRequestStatus | null>; ): Promise<CurrentPullRequestStatus | null>;
@@ -496,6 +497,7 @@ export interface ForgeService {
retainCurrentPullRequestStatusPoll?(options: { retainCurrentPullRequestStatusPoll?(options: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
onStatus?: (status: CurrentPullRequestStatus | null) => void; onStatus?: (status: CurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void; onError?: (error: unknown) => void;
@@ -74,6 +74,35 @@ const CONFLICTING_PR = {
ci: "failure", 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 = { const STATUS_PR_VIEW = {
id: 161482, id: 161482,
index: 5, 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 () => { it("resolves Gitea Actions check details addressed only by workflowRunId", async () => {
const { service } = makeService( const { service } = makeService(
(args) => { (args) => {
@@ -1172,6 +1238,9 @@ describe("createGiteaService", () => {
const { service, calls } = makeService( const { service, calls } = makeService(
(args) => { (args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([])); 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(" ")}`); throw new Error(`unexpected call: ${args.join(" ")}`);
}, },
{ {
@@ -1187,13 +1256,9 @@ describe("createGiteaService", () => {
checkRunId: 2, checkRunId: 2,
}), }),
).rejects.toThrow("Gitea pull request for branch feat/nonexistent was not found"); ).rejects.toThrow("Gitea pull request for branch feat/nonexistent was not found");
expect( expect(calls[1]).toEqual([
calls "api",
.filter((args) => args[0] === "pr" && args[1] === "list") "repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
.map((args) => [argValue(args, "--state"), argValue(args, "--page")]),
).toEqual([
["open", "1"],
["all", undefined],
]); ]);
}); });
@@ -1337,7 +1402,7 @@ describe("createGiteaService", () => {
expect(status?.checksStatus).toBe("failure"); 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) => ({ const firstPage = Array.from({ length: 50 }, (_, index) => ({
...OPEN_PR, ...OPEN_PR,
index: String(100 + index), index: String(100 + index),
@@ -1367,6 +1432,7 @@ describe("createGiteaService", () => {
const status = await service.getCurrentPullRequestStatus({ const status = await service.getCurrentPullRequestStatus({
cwd: "/repo", cwd: "/repo",
headRef: "feat/sample-change", headRef: "feat/sample-change",
headSha: "9999999999999999999999999999999999999999",
}); });
expect(status?.number).toBe(5); 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 () => { it("falls back to the recent all-state PR window for a recently closed current branch PR", async () => {
const closedPr = { const headSha = "8888888888888888888888888888888888888888";
...OPEN_PR, const closedPr = currentPullRequestApi({
index: "8", number: 8,
state: "closed", state: "closed",
url: "https://gitea.com/example-user/sample-repo/pulls/8", headRef: "feat/recently-closed",
head: "feat/recently-closed", headSha,
}; });
const { service, calls } = makeService((args) => { const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "open") { if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "open") {
return ok(JSON.stringify([])); 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])); return ok(JSON.stringify([closedPr]));
} }
throw new Error(`unexpected call: ${args.join(" ")}`); throw new Error(`unexpected call: ${args.join(" ")}`);
@@ -1401,22 +1467,79 @@ describe("createGiteaService", () => {
const status = await service.getCurrentPullRequestStatus({ const status = await service.getCurrentPullRequestStatus({
cwd: "/repo", cwd: "/repo",
headRef: "feat/recently-closed", headRef: "feat/recently-closed",
headSha,
}); });
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" }); expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
expect( expect(calls[1]).toEqual([
calls "api",
.filter((args) => args[0] === "pr" && args[1] === "list") "repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
.map((args) => [argValue(args, "--state"), argValue(args, "--page")]),
).toEqual([
["open", "1"],
["all", undefined],
]); ]);
}); });
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 () => { it("returns null when no PR matches the current branch", async () => {
const { service, calls } = makeService((args) => { const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([])); 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(" ")}`); throw new Error(`unexpected call: ${args.join(" ")}`);
}); });
@@ -1426,13 +1549,9 @@ describe("createGiteaService", () => {
}); });
expect(status).toBeNull(); expect(status).toBeNull();
expect( expect(calls[1]).toEqual([
calls "api",
.filter((args) => args[0] === "pr" && args[1] === "list") "repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
.map((args) => [argValue(args, "--state"), argValue(args, "--page")]),
).toEqual([
["open", "1"],
["all", undefined],
]); ]);
}); });
+95 -12
View File
@@ -234,6 +234,36 @@ const GiteaPullRequestApiSchema = z
}) })
.passthrough(); .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 const GiteaCommitStatusSchema = z
.object({ .object({
id: z.number(), id: z.number(),
@@ -324,6 +354,7 @@ const GiteaReviewCommentSchema = z
type GiteaPrListItem = z.infer<typeof GiteaPrListItemSchema>; type GiteaPrListItem = z.infer<typeof GiteaPrListItemSchema>;
type GiteaIssueListItem = z.infer<typeof GiteaIssueListItemSchema>; type GiteaIssueListItem = z.infer<typeof GiteaIssueListItemSchema>;
type GiteaPullRequestView = z.infer<typeof GiteaPullRequestViewSchema>; type GiteaPullRequestView = z.infer<typeof GiteaPullRequestViewSchema>;
type GiteaCurrentPullRequestApi = z.infer<typeof GiteaCurrentPullRequestApiSchema>;
type GiteaCommitStatus = z.infer<typeof GiteaCommitStatusSchema>; type GiteaCommitStatus = z.infer<typeof GiteaCommitStatusSchema>;
type GiteaCombinedCommitStatus = z.infer<typeof GiteaCombinedCommitStatusSchema>; type GiteaCombinedCommitStatus = z.infer<typeof GiteaCombinedCommitStatusSchema>;
type GiteaActionsRun = z.infer<typeof GiteaActionsRunSchema>; type GiteaActionsRun = z.infer<typeof GiteaActionsRunSchema>;
@@ -801,6 +832,25 @@ function parseGiteaRepoFromUrl(url: string): { owner?: string; name?: string } {
return {}; 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 { function toPullRequestSummary(item: GiteaPrListItem): PullRequestSummary {
const { owner, name } = parseGiteaRepoFromUrl(item.url); const { owner, name } = parseGiteaRepoFromUrl(item.url);
return { return {
@@ -1347,6 +1397,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
async function loadCurrentPullRequestStatus(input: { async function loadCurrentPullRequestStatus(input: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
}): Promise<CurrentPullRequestStatus | null> { }): Promise<CurrentPullRequestStatus | null> {
const match = await findCurrentPullRequestForHeadRef(input); const match = await findCurrentPullRequestForHeadRef(input);
if (!match) { if (!match) {
@@ -1356,7 +1407,9 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
return loadCurrentPullRequestChecks(input.cwd, match, status); 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); const remoteUrl = await resolveRemoteUrl(cwd);
if (!remoteUrl) { if (!remoteUrl) {
return null; return null;
@@ -1365,34 +1418,60 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
if (!location) { if (!location) {
return null; 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: { async function findCurrentPullRequestForHeadRef(input: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
}): Promise<GiteaPrListItem | null> { }): 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); const openMatch = await findOpenPullRequestForHeadRef(input, expectedHeadOwner);
if (openMatch) { if (openMatch) {
return openMatch; return openMatch;
} }
const recentItems = await listPullRequestItems({ // tea discovers repository context from any configured git remote, while
cwd: input.cwd, // the default remote resolver reads origin only. Preserve that broader
state: "all", // discovery for repositories whose primary remote has another name.
limit: CURRENT_PR_LOOKUP_PAGE_SIZE, const repoPath = repoIdentity
}); ? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}`
return ( : "repos/{owner}/{repo}";
recentItems.find((item) => matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner)) ?? const recentItems = await runJsonArray(
null [
"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( async function findOpenPullRequestForHeadRef(
input: { input: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
}, },
expectedHeadOwner: string | null, expectedHeadOwner: string | null,
): Promise<GiteaPrListItem | null> { ): Promise<GiteaPrListItem | null> {
@@ -1668,7 +1747,11 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
}, },
getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> { 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> { 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_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000; const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS = 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 CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
interface RunnerCall { interface RunnerCall {
@@ -207,6 +207,7 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
isDraft: false, isDraft: false,
baseRefName: "main", baseRefName: "main",
headRefName: "feature/fork", headRefName: "feature/fork",
headRefOid: "1111111111111111111111111111111111111111",
mergedAt: null, mergedAt: null,
statusCheckRollup: [], statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED", reviewDecision: "REVIEW_REQUIRED",
@@ -2246,6 +2247,26 @@ describe("ForgeService", () => {
expect(status?.mergeable).toBe("UNKNOWN"); 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 () => { it("loads GitHub merge, auto-merge, permission, policy, and queue facts for PR 993 shape", async () => {
const runner = createScriptedRunner([ const runner = createScriptedRunner([
currentPullRequestJson({ 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 () => { it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([ const runner = createScriptedRunner([
currentPullRequestJson({ currentPullRequestJson({
@@ -2541,6 +2656,18 @@ describe("ForgeService", () => {
expect(calls.map((call) => call.args)).toEqual([ expect(calls.map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"], ["repo", "view", "--json", "owner,name,parent"],
[
"pr",
"list",
"--state",
"all",
"--head",
"main",
"--limit",
"10",
"--json",
CURRENT_PR_STATUS_FIELDS,
],
]); ]);
}); });
+41 -5
View File
@@ -325,6 +325,7 @@ const CurrentPullRequestStatusSchema = z.object({
isDraft: z.boolean().optional().catch(false), isDraft: z.boolean().optional().catch(false),
baseRefName: z.string().catch(""), baseRefName: z.string().catch(""),
headRefName: z.string().catch(""), headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
mergedAt: z.string().nullable().optional(), mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(), statusCheckRollup: z.unknown().optional(),
reviewDecision: 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 = 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 CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = ` const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -716,6 +717,7 @@ interface InFlightCacheEntry {
interface GitHubPollTarget { interface GitHubPollTarget {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
retainCount: number; retainCount: number;
timer: NodeJS.Timeout | null; timer: NodeJS.Timeout | null;
@@ -727,6 +729,7 @@ interface GitHubPollTarget {
interface ResolvedPullRequestCandidate { interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus; status: CurrentPullRequestStatus;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
} }
@@ -868,6 +871,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
function getPollTargetKey(target: { function getPollTargetKey(target: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
}): string { }): string {
return buildCacheKey({ return buildCacheKey({
@@ -875,6 +879,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
method: "getCurrentPullRequestStatus", method: "getCurrentPullRequestStatus",
args: { args: {
headRef: target.headRef, headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner, headRepositoryOwner: target.headRepositoryOwner,
}, },
}); });
@@ -883,6 +888,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
function updatePollTargetAfterSuccess(update: { function updatePollTargetAfterSuccess(update: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
status: CurrentPullRequestStatus | null; status: CurrentPullRequestStatus | null;
notify: boolean; notify: boolean;
@@ -932,6 +938,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
await api.getCurrentPullRequestStatus({ await api.getCurrentPullRequestStatus({
cwd: target.cwd, cwd: target.cwd,
headRef: target.headRef, headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner, headRepositoryOwner: target.headRepositoryOwner,
reason: "self-heal-github", reason: "self-heal-github",
}); });
@@ -1095,6 +1102,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
method: "getCurrentPullRequestStatus", method: "getCurrentPullRequestStatus",
args: { args: {
headRef: input.headRef, headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner, headRepositoryOwner: input.headRepositoryOwner,
}, },
readOptions: input, readOptions: input,
@@ -1102,6 +1110,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const status = await resolveCurrentPullRequestView({ const status = await resolveCurrentPullRequestView({
cwd: input.cwd, cwd: input.cwd,
headRef: input.headRef, headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner, headRepositoryOwner: input.headRepositoryOwner,
run, run,
}); });
@@ -1111,6 +1120,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
updatePollTargetAfterSuccess({ updatePollTargetAfterSuccess({
cwd: input.cwd, cwd: input.cwd,
headRef: input.headRef, headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner, headRepositoryOwner: input.headRepositoryOwner,
status, status,
notify: input.reason === "self-heal-github", notify: input.reason === "self-heal-github",
@@ -1502,6 +1512,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
target = { target = {
cwd: input.cwd, cwd: input.cwd,
headRef: input.headRef, headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner, headRepositoryOwner: input.headRepositoryOwner,
retainCount: 0, retainCount: 0,
timer: null, timer: null,
@@ -1869,6 +1880,7 @@ function isStatusCheckRollupPermissionError(error: unknown): boolean {
async function resolveCurrentPullRequestView(options: { async function resolveCurrentPullRequestView(options: {
cwd: string; cwd: string;
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>; run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> { }): Promise<CurrentPullRequestStatus | null> {
@@ -1877,6 +1889,7 @@ async function resolveCurrentPullRequestView(options: {
? pickPullRequestCandidate({ ? pickPullRequestCandidate({
candidates: [viewCandidate], candidates: [viewCandidate],
headRef: options.headRef, headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.headRepositoryOwner, headRepositoryOwner: options.headRepositoryOwner,
}) })
: null; : null;
@@ -1893,12 +1906,13 @@ async function resolveCurrentPullRequestView(options: {
const forkOwner = repo?.owner?.login; const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login; const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name; const parentName = repo?.parent?.name;
if (!forkOwner || !parentOwner || !parentName) { if (!forkOwner) {
return null; return null;
} }
if (parentOwner && parentName) {
listHeadRef = `${forkOwner}:${options.headRef}`; listHeadRef = `${forkOwner}:${options.headRef}`;
listRepo = `${parentOwner}/${parentName}`; listRepo = `${parentOwner}/${parentName}`;
}
headRepositoryOwner = forkOwner; headRepositoryOwner = forkOwner;
} }
@@ -1911,6 +1925,7 @@ async function resolveCurrentPullRequestView(options: {
const match = pickPullRequestCandidate({ const match = pickPullRequestCandidate({
candidates, candidates,
headRef: options.headRef, headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner, headRepositoryOwner,
}); });
return match?.status ?? null; return match?.status ?? null;
@@ -2173,8 +2188,10 @@ function toCurrentPullRequestCandidate(
return null; return null;
} }
const headRepositoryOwner = item.headRepositoryOwner?.login; const headRepositoryOwner = item.headRepositoryOwner?.login;
const headSha = item.headRefOid;
return { return {
status, status,
...(headSha ? { headSha } : {}),
...(headRepositoryOwner ? { headRepositoryOwner } : {}), ...(headRepositoryOwner ? { headRepositoryOwner } : {}),
}; };
} }
@@ -2190,26 +2207,45 @@ function hasResolvedRepoIdentity(status: CurrentPullRequestStatus): boolean {
function pickPullRequestCandidate(options: { function pickPullRequestCandidate(options: {
candidates: ResolvedPullRequestCandidate[]; candidates: ResolvedPullRequestCandidate[];
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
}): ResolvedPullRequestCandidate | null { }): ResolvedPullRequestCandidate | null {
const matching = options.candidates.filter((candidate) => { const matching = options.candidates.filter((candidate) => {
if (!isCandidateForHeadRef(candidate, options.headRef)) { if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false; return false;
} }
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
return false;
}
if (!options.headRepositoryOwner) { if (!options.headRepositoryOwner) {
return true; return true;
} }
return candidate.headRepositoryOwner === options.headRepositoryOwner; return candidate.headRepositoryOwner === options.headRepositoryOwner;
}); });
matching.sort(comparePullRequestCandidatePreference); matching.sort((left, right) =>
comparePullRequestCandidatePreference(left, right, options.headSha),
);
return matching[0] ?? null; return matching[0] ?? null;
} }
function comparePullRequestCandidatePreference( function comparePullRequestCandidatePreference(
left: ResolvedPullRequestCandidate, left: ResolvedPullRequestCandidate,
right: ResolvedPullRequestCandidate, right: ResolvedPullRequestCandidate,
headSha?: string,
): number { ): 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 { function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
@@ -33,6 +33,24 @@ function makeService(responder: Responder, overrides: Partial<CreateGitLabServic
return { service, calls }; 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( function gitlabAutoMergeStatus(
overrides: Partial<GitLabStatusFacts> = {}, overrides: Partial<GitLabStatusFacts> = {},
): PullRequestCommandStatus { ): PullRequestCommandStatus {
@@ -60,6 +78,7 @@ const OPEN_MR = {
state: "opened", state: "opened",
source_branch: "release/v0.4.0", source_branch: "release/v0.4.0",
target_branch: "main", target_branch: "main",
sha: "1111111111111111111111111111111111111111",
source_project_id: 101, source_project_id: 101,
target_project_id: 101, target_project_id: 101,
draft: false, draft: false,
@@ -231,7 +250,9 @@ const DISCUSSIONS = [
describe("createGitLabService", () => { describe("createGitLabService", () => {
it("maps a glab merge request view to the neutral current PR status", async () => { 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({ const status = await service.getCurrentPullRequestStatus({
cwd: "/repo", cwd: "/repo",
@@ -261,23 +282,26 @@ describe("createGitLabService", () => {
pipelineStatus: "success", pipelineStatus: "success",
mergeWhenPipelineSucceeds: false, 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 () => { it("reports a conflicting merge request as CONFLICTING", async () => {
const { service } = makeService(() => const conflicting = {
ok( ...OPEN_MR,
JSON.stringify({ ...OPEN_MR, has_conflicts: true, detailed_merge_status: "broken_status" }), 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" }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "x" });
expect(status?.mergeable).toBe("CONFLICTING"); expect(status?.mergeable).toBe("CONFLICTING");
}); });
it("returns null when no merge request exists for the branch", async () => { it("returns null when no merge request exists for the branch", async () => {
const { service } = makeService(() => { const { service } = makeService(() => ok("[]"));
throw { code: 1, stderr: "no open merge request available for 'feature/x'" };
});
const status = await service.getCurrentPullRequestStatus({ const status = await service.getCurrentPullRequestStatus({
cwd: "/repo", cwd: "/repo",
headRef: "feature/x", headRef: "feature/x",
@@ -285,6 +309,61 @@ describe("createGitLabService", () => {
expect(status).toBeNull(); 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 () => { it("looks up a numeric current branch through the source-branch list filter", async () => {
const numericBranchMr = { const numericBranchMr = {
...OPEN_MR, ...OPEN_MR,
@@ -313,6 +392,7 @@ describe("createGitLabService", () => {
const status = await service.getCurrentPullRequestStatus({ const status = await service.getCurrentPullRequestStatus({
cwd: "/repo", cwd: "/repo",
headRef: "1234", headRef: "1234",
headSha: "2222222222222222222222222222222222222222",
}); });
expect(status).toMatchObject({ expect(status).toMatchObject({
@@ -320,7 +400,7 @@ describe("createGitLabService", () => {
title: "Fix numeric branch", title: "Fix numeric branch",
headRefName: "1234", 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[1]).toEqual(["mr", "view", "21", "-F", "json"]);
expect(calls).not.toContainEqual(["mr", "view", "1234", "-F", "json"]); expect(calls).not.toContainEqual(["mr", "view", "1234", "-F", "json"]);
}); });
@@ -342,7 +422,7 @@ describe("createGitLabService", () => {
}); });
expect(status).toBeNull(); 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 () => { 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 () => { it("surfaces the head pipeline id and url on the gitlab status facts", async () => {
const { service } = makeService(() => const pipelineMr = {
ok(
JSON.stringify({
...OPEN_MR, ...OPEN_MR,
head_pipeline: { head_pipeline: {
id: 306, id: 306,
status: "running", status: "running",
web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", 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({ const status = await service.getCurrentPullRequestStatus({
@@ -682,6 +761,7 @@ describe("createGitLabService", () => {
it("populates approval counts from the approvals endpoint", async () => { it("populates approval counts from the approvals endpoint", async () => {
const { service, calls } = makeService((args) => { 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] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR));
if (args[0] === "api" && args[1].endsWith("/approvals")) return ok(JSON.stringify(APPROVALS)); if (args[0] === "api" && args[1].endsWith("/approvals")) return ok(JSON.stringify(APPROVALS));
throw new Error(`unexpected call: ${args.join(" ")}`); throw new Error(`unexpected call: ${args.join(" ")}`);
@@ -697,7 +777,7 @@ describe("createGitLabService", () => {
approvalsRequired: 2, approvalsRequired: 2,
approvalsGiven: 1, approvalsGiven: 1,
}); });
expect(calls[1]).toEqual([ expect(calls[2]).toEqual([
"api", "api",
"projects/example-group%2Fexample-project/merge_requests/14/approvals", "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 () => { it("falls back to zero approvals when the approvals endpoint returns an error", async () => {
const { service } = makeService((args) => { 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)); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR));
throw { code: 1, stderr: "500 Internal Server Error" }; throw { code: 1, stderr: "500 Internal Server Error" };
}); });
+27 -15
View File
@@ -149,6 +149,7 @@ const GitLabMergeRequestSchema = z
state: z.string(), state: z.string(),
source_branch: z.string(), source_branch: z.string(),
target_branch: z.string(), target_branch: z.string(),
sha: z.string().optional(),
source_project_id: z.number().nullable().optional(), source_project_id: z.number().nullable().optional(),
target_project_id: z.number().nullable().optional(), target_project_id: z.number().nullable().optional(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
@@ -861,16 +862,26 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema); return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema);
} }
function isNumericBranchRef(ref: string): boolean { async function listMergeRequestsBySourceBranch(
return /^[0-9]+$/.test(ref);
}
async function listOpenMergeRequestsBySourceBranch(
cwd: string, cwd: string,
sourceBranch: string, sourceBranch: string,
): Promise<GitLabMergeRequest[]> { ): Promise<GitLabMergeRequest[]> {
return runJson( 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 }, { cwd },
z.array(GitLabMergeRequestSchema), z.array(GitLabMergeRequestSchema),
); );
@@ -879,16 +890,17 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
async function resolveCurrentMergeRequest( async function resolveCurrentMergeRequest(
cwd: string, cwd: string,
headRef: string, headRef: string,
headSha?: string,
): Promise<GitLabMergeRequest | null> { ): Promise<GitLabMergeRequest | null> {
if (!isNumericBranchRef(headRef)) { const mergeRequests = await listMergeRequestsBySourceBranch(cwd, headRef);
return viewMergeRequest(cwd, headRef); const candidates = mergeRequests.filter((mr) => mr.source_branch === headRef);
}
const mergeRequests = await listOpenMergeRequestsBySourceBranch(cwd, headRef);
const match = const match =
mergeRequests.find( candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ??
(mr) => mr.source_branch === headRef && mapMergeRequestState(mr.state) === "open", candidates.find(
) ?? null; (mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
null;
return match ? viewMergeRequest(cwd, String(match.iid)) : 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> { async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
try { try {
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef); const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
if (!mr) { if (!mr) {
return null; 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 { createForgeResolver } from "../services/forge-resolver.js";
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js"; import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-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 { import {
createWorktree as createWorktreePrimitive, createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions, type CreateWorktreeOptions,
@@ -161,6 +165,7 @@ function createPullRequestStatus(overrides?: Partial<CurrentPullRequestStatus>)
interface RequestedPullRequestTarget { interface RequestedPullRequestTarget {
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
} }
@@ -176,6 +181,7 @@ function createGitHubServiceRecordingPullRequestTargets(
github.getCurrentPullRequestStatus = async (request) => { github.getCurrentPullRequestStatus = async (request) => {
options.requestedTargets.push({ options.requestedTargets.push({
headRef: request.headRef, headRef: request.headRef,
...(request.headSha ? { headSha: request.headSha } : {}),
...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}), ...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}),
}); });
return createPullRequestStatus({ return createPullRequestStatus({
@@ -2341,7 +2347,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); 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 () => { 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); 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 () => { 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); 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 () => { 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); 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 () => { 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(getBranchUpstream(repoDir)).toBeNull();
expect(factsTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" }); expect(factsTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(requestedTargets).toEqual([{ 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 () => { 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); 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 () => { 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); 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?.number).toBe(345);
expect(status.status?.headRefName).toBe("main"); expect(status.status?.headRefName).toBe("main");
}); });
@@ -2544,6 +2560,33 @@ const x = 1;
expect(callCount).toBe(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 () => { it("passes forced PR status reads through to the GitHub service", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { 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 () => { it("does not use stale PR status fallback for forced GitHub errors", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { 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 type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.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 { import type {
CurrentPullRequestStatus, CurrentPullRequestStatus,
ForgeAuthState, ForgeAuthState,
@@ -15,7 +15,11 @@ import type {
ForgeSpecificStatusFacts, ForgeSpecificStatusFacts,
PullRequestMergeable, PullRequestMergeable,
} from "../services/forge-service.js"; } 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 { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js"; import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
@@ -67,6 +71,7 @@ interface CheckoutReadCacheOptions {
interface PullRequestStatusLookupTarget { interface PullRequestStatusLookupTarget {
headRef: string; headRef: string;
headSha?: string;
headRepositoryOwner?: string; headRepositoryOwner?: string;
} }
@@ -116,8 +121,8 @@ function createShortstatCache(ttlMs: number) {
}); });
} }
function getPullRequestStatusCacheKey(cwd: string): string { function getPullRequestStatusCacheKey(cwd: string, headSha: string | null): string {
return resolve(cwd); return `${resolve(cwd)}\u0000${headSha ?? ""}`;
} }
function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusResult): void { 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> { async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
const paths = ["rebase-merge/head-name", "rebase-apply/head-name"]; const paths = ["rebase-merge/head-name", "rebase-apply/head-name"];
const results = await Promise.all( const results = await Promise.all(
@@ -1761,6 +1798,11 @@ export async function getCheckoutSnapshotFacts(
context, context,
)) ?? pullRequestLookupTarget; )) ?? pullRequestLookupTarget;
} }
pullRequestLookupTarget = await addHeadShaToPullRequestLookupTarget(
cwd,
pullRequestLookupTarget,
context,
);
return { return {
isGit: true, isGit: true,
@@ -3402,7 +3444,8 @@ export async function getPullRequestStatus(
options?: CheckoutReadCacheOptions, options?: CheckoutReadCacheOptions,
context?: CheckoutContext, context?: CheckoutContext,
): Promise<PullRequestStatusResult> { ): Promise<PullRequestStatusResult> {
const cacheKey = getPullRequestStatusCacheKey(cwd); const headSha = await getCurrentHeadSha(cwd, context);
const cacheKey = getPullRequestStatusCacheKey(cwd, headSha);
if (!options?.force) { if (!options?.force) {
const cached = pullRequestStatusCache.get(cacheKey); const cached = pullRequestStatusCache.get(cacheKey);
if (cached) { 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) => { .then((status) => {
pullRequestStatusCache.set(cacheKey, status); pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status); rememberPullRequestStatus(cacheKey, status);
return status; return status;
}) })
.catch((error) => { .catch((error) => {
if (!options?.force && error instanceof GitHubCommandError) { if (!options?.force && error instanceof ForgeCommandError) {
const stale = lastSuccessfulPullRequestStatus.get(cacheKey); const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
if (stale) { if (stale) {
return stale; return stale;
@@ -3443,6 +3486,7 @@ async function getPullRequestStatusUncached(
forgeService: ForgeService, forgeService: ForgeService,
options?: CheckoutReadCacheOptions, options?: CheckoutReadCacheOptions,
context?: CheckoutContext, context?: CheckoutContext,
headSha?: string | null,
): Promise<PullRequestStatusResult> { ): Promise<PullRequestStatusResult> {
if (context?.facts?.isGit === false) { if (context?.facts?.isGit === false) {
return buildPullRequestStatusResult(null, "no_remote"); return buildPullRequestStatusResult(null, "no_remote");
@@ -3455,7 +3499,11 @@ async function getPullRequestStatusUncached(
return buildPullRequestStatusResult(null, "no_remote"); return buildPullRequestStatusResult(null, "no_remote");
} }
try { 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; let status: CurrentPullRequestStatus | null;
if (options?.force) { if (options?.force) {
const reason = options.reason; const reason = options.reason;