Fix commit-aware PR resolution across forges
This commit is contained in:
@@ -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.*.
|
||||||
|
|||||||
@@ -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],
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"], {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user