FN-8836: classify GitHub branch policy merge blocks
Classify ambiguous GitHub merge failures using refreshed pull request state. - Distinguish branch-protection blocks from true merge conflicts. - Surface review and required-check blockers in CLI and dashboard merge flows. - Add regression coverage and a patch changeset. Files changed: .changeset/fn-8836-gh-merge-policy-errors.md | 7 ++ .../src/commands/__tests__/task-lifecycle.test.ts | 55 +++++++++ packages/cli/src/commands/task-lifecycle.ts | 14 ++- packages/core/src/__tests__/gh-cli.test.ts | 39 +++++- packages/core/src/cli/gh-cli.ts | 60 +++++++++- packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + .../dashboard/src/__tests__/routes-github.test.ts | 131 +++++++++++++++++++++ .../dashboard/src/routes/register-git-github.ts | 58 +++++++-- 9 files changed, 351 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-8836 Fusion-Task-Lineage: 7102f5ba-aca9-48cd-a27b-76deb952c4a5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8836-gh-merge-policy-errors.md
Normal file
7
.changeset/fn-8836-gh-merge-policy-errors.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Report GitHub branch-protection merge blocks instead of false merge conflicts.
|
||||
category: fix
|
||||
dev: Classifies refreshed BLOCKED PR state as merge-blocked-by-policy.
|
||||
@@ -1325,6 +1325,61 @@ describe("processPullRequestMergeTask", () => {
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a refreshed blocked review as policy rather than a conflict", async () => {
|
||||
const task: MockTask = {
|
||||
id: "FN-9105-policy",
|
||||
title: "test",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
prInfo: { number: 125, url: "https://github.com/x/y/pull/125", status: "open", headBranch: "fusion/fn-9105", baseBranch: "main" },
|
||||
};
|
||||
const store = makeStore(task);
|
||||
const openPr = { ...task.prInfo };
|
||||
const github = {
|
||||
findPrForBranch: vi.fn(),
|
||||
createPr: vi.fn(),
|
||||
getPrMergeStatus: vi.fn()
|
||||
.mockResolvedValueOnce({ prInfo: openPr, reviewDecision: "APPROVED", checks: [], mergeReady: true, blockingReasons: [] })
|
||||
.mockResolvedValueOnce({ prInfo: { ...openPr, mergeable: "blocked" }, reviewDecision: "REVIEW_REQUIRED", checks: [], mergeReady: false, blockingReasons: [] }),
|
||||
mergePr: vi.fn(async () => { throw new Error("Pull request is not mergeable"); }),
|
||||
};
|
||||
|
||||
await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined))
|
||||
.rejects.toThrow("blocked by branch protection: review approval is required");
|
||||
expect(store.updatePrInfo).toHaveBeenLastCalledWith(task.id, expect.objectContaining({ status: "open" }));
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports refreshed required checks as a policy block", async () => {
|
||||
const task: MockTask = {
|
||||
id: "FN-9105-checks",
|
||||
title: "test",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
prInfo: { number: 126, url: "https://github.com/x/y/pull/126", status: "open", headBranch: "fusion/fn-9105", baseBranch: "main" },
|
||||
};
|
||||
const store = makeStore(task);
|
||||
const openPr = { ...task.prInfo };
|
||||
const github = {
|
||||
findPrForBranch: vi.fn(),
|
||||
createPr: vi.fn(),
|
||||
getPrMergeStatus: vi.fn()
|
||||
.mockResolvedValueOnce({ prInfo: openPr, reviewDecision: "APPROVED", checks: [], mergeReady: true, blockingReasons: [] })
|
||||
.mockResolvedValueOnce({
|
||||
prInfo: { ...openPr, mergeable: "blocked" },
|
||||
reviewDecision: null,
|
||||
checks: [],
|
||||
mergeReady: false,
|
||||
blockingReasons: ["required checks not successful: ci (pending)"],
|
||||
}),
|
||||
mergePr: vi.fn(async () => { throw new Error("Pull request is not mergeable"); }),
|
||||
};
|
||||
|
||||
await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined))
|
||||
.rejects.toThrow("blocked by branch protection: required checks not successful: ci (pending)");
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rethrows the original merge error when the post-failure refresh also fails", async () => {
|
||||
const task: MockTask = {
|
||||
id: "FN-9106",
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
resolveEffectiveSettings,
|
||||
isWorkspaceTask,
|
||||
assertNotWorkspaceTaskMerge,
|
||||
classifyGhError,
|
||||
WorkspaceTaskMergeError,
|
||||
} from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
||||
@@ -1146,7 +1147,18 @@ export async function processPullRequestMergeTask(
|
||||
return "merged";
|
||||
}
|
||||
|
||||
throw err;
|
||||
/*
|
||||
FNXC:GitHubPrMerge 2026-08-09-01:02:
|
||||
Preserve merge → one refresh → persist → merged reconciliation ordering. A
|
||||
refreshed BLOCKED state explains ambiguous gh "not mergeable" output as
|
||||
branch policy, while DIRTY/CONFLICTING remain the only state-based conflict.
|
||||
*/
|
||||
const diagnosis = classifyGhError(err, {
|
||||
mergeable: refreshedStatus.prInfo.mergeable,
|
||||
reviewDecision: refreshedStatus.reviewDecision,
|
||||
blockingReasons: refreshedStatus.blockingReasons,
|
||||
});
|
||||
throw Object.assign(new Error(diagnosis.message), { code: diagnosis.code, cause: diagnosis.cause });
|
||||
}
|
||||
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
|
||||
await finalizePullRequestMerge(store, cwd, task, mergedPr, "Pull request merged", pool);
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("classifyGhError", () => {
|
||||
{ label: "not found", error: new Error("404 not found"), expectedCode: "not-found", retryable: false },
|
||||
{ label: "network", error: new Error("getaddrinfo ENOTFOUND api.github.com"), expectedCode: "network", retryable: true, actionKind: "retry" },
|
||||
{ label: "permission", error: new Error("403 permission denied"), expectedCode: "permission", retryable: false },
|
||||
{ label: "merge conflict", error: new Error("pull request is not mergeable due to merge conflict"), expectedCode: "merge-conflict", retryable: false },
|
||||
{ label: "explicit merge conflict", error: new Error("pull request is not mergeable due to merge conflict"), expectedCode: "merge-conflict", retryable: false },
|
||||
{ label: "validation", error: new Error("422 validation failed"), expectedCode: "validation", retryable: false },
|
||||
{ label: "timeout", error: new Error("gh command timed out after 30000ms"), expectedCode: "timeout", retryable: true, actionKind: "retry" },
|
||||
{ label: "unknown", error: new Error("something novel happened"), expectedCode: "unknown", retryable: true, actionKind: "retry" },
|
||||
@@ -56,6 +56,43 @@ describe("classifyGhError", () => {
|
||||
expect(result.code).toBe("rate-limited");
|
||||
expect(result.retryAfterMs).toBe(3000);
|
||||
});
|
||||
|
||||
it("uses refreshed BLOCKED state to name a required review", () => {
|
||||
const result = classifyGhError(new Error("pull request is not mergeable"), {
|
||||
mergeable: "blocked",
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
});
|
||||
expect(result).toMatchObject({ code: "merge-blocked-by-policy", retryable: false });
|
||||
expect(result.message).toContain("review approval is required");
|
||||
expect(result.message).not.toMatch(/conflict/i);
|
||||
});
|
||||
|
||||
it("uses deterministic unique check blockers when policy blocks a merge", () => {
|
||||
const result = classifyGhError(new Error("pull request is not mergeable"), {
|
||||
mergeable: "BLOCKED",
|
||||
blockingReasons: ["required checks not successful: ci (pending)", "required checks not successful: ci (pending)"],
|
||||
});
|
||||
expect(result).toMatchObject({ code: "merge-blocked-by-policy", retryable: false });
|
||||
expect(result.message).toBe("Pull request is blocked by branch protection: required checks not successful: ci (pending).");
|
||||
});
|
||||
|
||||
it("keeps all normalized policy blockers in deterministic order", () => {
|
||||
const result = classifyGhError(new Error("pull request is not mergeable"), {
|
||||
mergeable: "BLOCKED",
|
||||
reviewDecision: "review_required",
|
||||
blockingReasons: ["zebra check (failed)", " alpha check (pending) ", "ZEBRA check (failed)"],
|
||||
});
|
||||
expect(result.message).toBe(
|
||||
"Pull request is blocked by branch protection: review approval is required; alpha check (pending); zebra check (failed).",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses refreshed conflict state but preserves context-free not mergeable errors", () => {
|
||||
expect(classifyGhError(new Error("pull request is not mergeable"), { mergeable: "DIRTY" }).code).toBe("merge-conflict");
|
||||
const unknown = classifyGhError(new Error("pull request is not mergeable"));
|
||||
expect(unknown.code).toBe("unknown");
|
||||
expect(unknown.message).toBe("pull request is not mergeable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGhErrorMessage", () => {
|
||||
|
||||
@@ -15,10 +15,20 @@ export type GhErrorCode =
|
||||
| "network"
|
||||
| "permission"
|
||||
| "merge-conflict"
|
||||
| "merge-blocked-by-policy"
|
||||
| "validation"
|
||||
| "timeout"
|
||||
| "unknown";
|
||||
|
||||
export interface GhErrorClassificationContext {
|
||||
/** Merge state supplied by a post-failure GitHub refresh; dashboard types stay out of core. */
|
||||
mergeable?: string;
|
||||
/** GitHub's review decision when it explains a protected-branch block. */
|
||||
reviewDecision?: string | null;
|
||||
/** Safe, operator-facing reasons returned by GitHub for a blocked merge. */
|
||||
blockingReasons?: readonly string[];
|
||||
}
|
||||
|
||||
export interface StructuredGhError {
|
||||
code: GhErrorCode;
|
||||
message: string;
|
||||
@@ -338,7 +348,10 @@ function normalizeGhErrorParts(error: unknown): { message: string; stderr: strin
|
||||
return { message: String(error), stderr: "", stdout: "", exitCode: null };
|
||||
}
|
||||
|
||||
export function classifyGhError(error: unknown): StructuredGhError {
|
||||
export function classifyGhError(
|
||||
error: unknown,
|
||||
context: GhErrorClassificationContext = {},
|
||||
): StructuredGhError {
|
||||
const parts = normalizeGhErrorParts(error);
|
||||
const haystack = `${parts.message}\n${parts.stderr}\n${parts.stdout}`.toLowerCase();
|
||||
const baseCause = {
|
||||
@@ -397,7 +410,16 @@ export function classifyGhError(error: unknown): StructuredGhError {
|
||||
});
|
||||
}
|
||||
|
||||
if (haystack.includes("merge conflict") || haystack.includes("not mergeable")) {
|
||||
const mergeable = context.mergeable?.toUpperCase();
|
||||
const hasExplicitConflict = haystack.includes("merge conflict") || haystack.includes("cannot be cleanly created");
|
||||
|
||||
/*
|
||||
FNXC:GitHubPrMerge 2026-08-09-01:02:
|
||||
GitHub CLI's "not mergeable" text is ambiguous: branch protection emits it too.
|
||||
Only explicit conflict text or refreshed DIRTY/CONFLICTING state may diagnose a
|
||||
conflict; refreshed BLOCKED state must preserve the operator's policy blocker.
|
||||
*/
|
||||
if (hasExplicitConflict || mergeable === "DIRTY" || mergeable === "CONFLICTING") {
|
||||
return withCause({
|
||||
code: "merge-conflict",
|
||||
message: "Pull request cannot be merged due to conflicts.",
|
||||
@@ -405,6 +427,36 @@ export function classifyGhError(error: unknown): StructuredGhError {
|
||||
});
|
||||
}
|
||||
|
||||
if (mergeable === "BLOCKED") {
|
||||
const uniqueReasons = new Map<string, string>();
|
||||
for (const reason of context.blockingReasons ?? []) {
|
||||
const normalized = reason.trim().replace(/\s+/g, " ");
|
||||
if (normalized && !uniqueReasons.has(normalized.toLowerCase())) {
|
||||
uniqueReasons.set(normalized.toLowerCase(), normalized);
|
||||
}
|
||||
}
|
||||
const reasons = [...uniqueReasons.values()].sort((left, right) => {
|
||||
const normalizedLeft = left.toLowerCase();
|
||||
const normalizedRight = right.toLowerCase();
|
||||
return normalizedLeft < normalizedRight ? -1 : normalizedLeft > normalizedRight ? 1 : 0;
|
||||
});
|
||||
const reviewRequired = context.reviewDecision?.toUpperCase() === "REVIEW_REQUIRED";
|
||||
|
||||
// FNXC:GitHubPrMerge 2026-08-09-01:35: Keep every sanitized blocker because GitHub can report review and check policy blocks together.
|
||||
const blockers = [
|
||||
...(reviewRequired ? ["review approval is required"] : []),
|
||||
...reasons,
|
||||
];
|
||||
const message = blockers.length > 0
|
||||
? `Pull request is blocked by branch protection: ${blockers.join("; ")}.`
|
||||
: "Pull request is blocked by branch protection.";
|
||||
return withCause({
|
||||
code: "merge-blocked-by-policy",
|
||||
message,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (haystack.includes("validation failed") || /\b422\b/.test(haystack)) {
|
||||
return withCause({
|
||||
code: "validation",
|
||||
@@ -441,8 +493,8 @@ export function classifyGhError(error: unknown): StructuredGhError {
|
||||
* Get a human-readable error message from a gh CLI error.
|
||||
* Extracts the most relevant error information.
|
||||
*/
|
||||
export function getGhErrorMessage(error: unknown): string {
|
||||
return classifyGhError(error).message;
|
||||
export function getGhErrorMessage(error: unknown, context?: GhErrorClassificationContext): string {
|
||||
return classifyGhError(error, context).message;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1114,6 +1114,7 @@ export {
|
||||
getCurrentRepo,
|
||||
getPushRepo,
|
||||
type GhError,
|
||||
type GhErrorClassificationContext,
|
||||
type GhErrorCode,
|
||||
type StructuredGhError,
|
||||
} from "./cli/gh-cli.js";
|
||||
|
||||
@@ -1257,6 +1257,7 @@ export {
|
||||
getCurrentRepo,
|
||||
getPushRepo,
|
||||
type GhError,
|
||||
type GhErrorClassificationContext,
|
||||
type GhErrorCode,
|
||||
type StructuredGhError,
|
||||
} from "./cli/gh-cli.js";
|
||||
|
||||
@@ -4023,6 +4023,137 @@ describe("PR conflict refresh + reclaim routes", () => {
|
||||
expect(res.body.mergeReady).toBe(res.body.primary.mergeReady);
|
||||
});
|
||||
|
||||
it("returns a refreshed branch-protection diagnosis for an ambiguous merge failure", async () => {
|
||||
const prInfo = { url: "https://github.com/owner/repo/pull/940", number: 940, status: "open" as const, title: "PR940", headBranch: "fusion/fn-940", baseBranch: "main", commentCount: 0 };
|
||||
const task = { ...FAKE_TASK_DETAIL, id: "FN-940", prInfo, prInfos: [prInfo] };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
vi.spyOn(GitHubClient.prototype, "mergePr").mockRejectedValue(new Error("Pull request is not mergeable"));
|
||||
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
|
||||
prInfo: { ...prInfo, mergeable: "blocked" },
|
||||
mergeable: "blocked",
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
checks: [],
|
||||
mergeReady: false,
|
||||
blockingReasons: [],
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/merge`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details.githubError.code).toBe("merge-blocked-by-policy");
|
||||
expect(res.body.error).toContain("review approval is required");
|
||||
expect(res.body.error).not.toMatch(/conflict/i);
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({
|
||||
mergeable: "blocked",
|
||||
lastMergeError: expect.stringContaining("review approval is required"),
|
||||
}));
|
||||
expect(store.applyPrMergedTransition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns required-check blockers from the refreshed merge status", async () => {
|
||||
const prInfo = { url: "https://github.com/owner/repo/pull/941", number: 941, status: "open" as const, title: "PR941", headBranch: "fusion/fn-941", baseBranch: "main", commentCount: 0 };
|
||||
const task = { ...FAKE_TASK_DETAIL, id: "FN-941", prInfo, prInfos: [prInfo] };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
vi.spyOn(GitHubClient.prototype, "mergePr").mockRejectedValue(new Error("Pull request is not mergeable"));
|
||||
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
|
||||
prInfo: { ...prInfo, mergeable: "blocked" },
|
||||
mergeable: "blocked",
|
||||
reviewDecision: null,
|
||||
checks: [],
|
||||
mergeReady: false,
|
||||
blockingReasons: ["required checks not successful: ci (pending)"],
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/merge`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details.githubError).toMatchObject({ code: "merge-blocked-by-policy" });
|
||||
expect(res.body.error).toContain("required checks not successful: ci (pending)");
|
||||
expect(res.body.error).not.toMatch(/conflict/i);
|
||||
});
|
||||
|
||||
it("retains the conflict diagnosis for a refreshed conflicting PR", async () => {
|
||||
const prInfo = { url: "https://github.com/owner/repo/pull/942", number: 942, status: "open" as const, title: "PR942", headBranch: "fusion/fn-942", baseBranch: "main", commentCount: 0 };
|
||||
const task = { ...FAKE_TASK_DETAIL, id: "FN-942", prInfo, prInfos: [prInfo] };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
vi.spyOn(GitHubClient.prototype, "mergePr").mockRejectedValue(new Error("Pull request is not mergeable"));
|
||||
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
|
||||
prInfo: { ...prInfo, mergeable: "conflicting" },
|
||||
mergeable: "conflicting",
|
||||
reviewDecision: null,
|
||||
checks: [],
|
||||
mergeReady: false,
|
||||
blockingReasons: ["PR mergeability is conflicting"],
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/merge`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details.githubError.code).toBe("merge-conflict");
|
||||
expect(res.body.error).toMatch(/conflicts/i);
|
||||
});
|
||||
|
||||
it("keeps an ambiguous merge failure generic when its refresh has no merge state", async () => {
|
||||
const prInfo = { url: "https://github.com/owner/repo/pull/942", number: 942, status: "open" as const, title: "PR942", headBranch: "fusion/fn-942", baseBranch: "main", commentCount: 0 };
|
||||
const task = { ...FAKE_TASK_DETAIL, id: "FN-942", prInfo, prInfos: [prInfo] };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
vi.spyOn(GitHubClient.prototype, "mergePr").mockRejectedValue(new Error("Pull request is not mergeable"));
|
||||
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
|
||||
prInfo,
|
||||
mergeable: undefined,
|
||||
reviewDecision: null,
|
||||
checks: [],
|
||||
mergeReady: false,
|
||||
blockingReasons: [],
|
||||
} as any);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/merge`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.details.githubError.code).toBe("unknown");
|
||||
expect(res.body.error).toBe("Pull request is not mergeable");
|
||||
expect(res.body.error).not.toMatch(/conflict/i);
|
||||
});
|
||||
|
||||
it("reconciles a PR merged after the merge command failure exactly once", async () => {
|
||||
const prInfo = { url: "https://github.com/owner/repo/pull/943", number: 943, status: "open" as const, title: "PR943", headBranch: "fusion/fn-943", baseBranch: "main", commentCount: 0 };
|
||||
const task = { ...FAKE_TASK_DETAIL, id: "FN-943", prInfo, prInfos: [prInfo] };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
vi.spyOn(GitHubClient.prototype, "mergePr").mockRejectedValue(new Error("merge command timed out"));
|
||||
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
|
||||
prInfo: { ...prInfo, status: "merged" },
|
||||
mergeable: "clean",
|
||||
reviewDecision: "APPROVED",
|
||||
checks: [],
|
||||
mergeReady: true,
|
||||
blockingReasons: [],
|
||||
} as any);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/merge`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.prInfo.status).toBe("merged");
|
||||
expect(store.applyPrMergedTransition).toHaveBeenCalledTimes(1);
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({ status: "merged", lastMergeError: undefined }));
|
||||
});
|
||||
|
||||
it("preserves the original merge failure when status refresh fails", async () => {
|
||||
const prInfo = { url: "https://github.com/owner/repo/pull/944", number: 944, status: "open" as const, title: "PR944", headBranch: "fusion/fn-944", baseBranch: "main", commentCount: 0 };
|
||||
const task = { ...FAKE_TASK_DETAIL, id: "FN-944", prInfo, prInfos: [prInfo] };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
vi.spyOn(GitHubClient.prototype, "mergePr").mockRejectedValue(new Error("merge command failed"));
|
||||
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockRejectedValue(new Error("GitHub unavailable"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/merge`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.error).toBe("merge command failed");
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({
|
||||
lastMergeError: "merge command failed",
|
||||
}));
|
||||
expect(store.applyPrMergedTransition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unlink removes targeted PR without closing github PR", async () => {
|
||||
const task = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
|
||||
@@ -99,6 +99,7 @@ function mapStructuredGhErrorToStatus(code: StructuredGhError["code"]): number {
|
||||
return 404;
|
||||
case "validation":
|
||||
case "merge-conflict":
|
||||
case "merge-blocked-by-policy":
|
||||
return 422;
|
||||
default:
|
||||
return 502;
|
||||
@@ -2394,18 +2395,57 @@ async function mergeTaskPr(
|
||||
});
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const message = getCommandErrorMessage(error) || "Failed to merge pull request";
|
||||
await scopedStore.updatePrInfo(task.id, {
|
||||
let mergeStatus: Awaited<ReturnType<GitHubClient["getPrMergeStatus"]>> | undefined;
|
||||
try {
|
||||
mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number);
|
||||
} catch {
|
||||
// A refresh failure cannot invent GitHub state; retain the original command diagnosis.
|
||||
}
|
||||
|
||||
const refreshed = mergeStatus && {
|
||||
...task.prInfo,
|
||||
lastMergeError: message,
|
||||
...mergeStatus.prInfo,
|
||||
autoMergeOnGreen: task.prInfo.autoMergeOnGreen,
|
||||
autoMergeStrategy: task.prInfo.autoMergeStrategy,
|
||||
manual: task.prInfo.manual,
|
||||
draft: mergeStatus.prInfo.draft ?? mergeStatus.prInfo.isDraft,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
} satisfies PrInfo;
|
||||
|
||||
if (refreshed?.status === "merged") {
|
||||
await scopedStore.updatePrInfo(task.id, {
|
||||
...refreshed,
|
||||
lastMergeError: undefined,
|
||||
lastMergeErrorAt: undefined,
|
||||
});
|
||||
await scopedStore.applyPrMergedTransition(task.id, {
|
||||
agentId: "dashboard",
|
||||
runId: `${runIdPrefix}-${task.id}-${Date.now()}`,
|
||||
});
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:GitHubPrMerge 2026-08-09-01:02:
|
||||
The direct route never reaches CLI lifecycle recovery, so it performs its
|
||||
own single post-failure refresh before classifying ambiguous gh output.
|
||||
Persist that state and structured policy diagnosis; only a confirmed merged
|
||||
refresh may finalize the task, and a refresh failure retains the original error.
|
||||
*/
|
||||
const diagnosis = classifyGhError(error, mergeStatus && {
|
||||
mergeable: mergeStatus.mergeable,
|
||||
reviewDecision: mergeStatus.reviewDecision,
|
||||
blockingReasons: mergeStatus.blockingReasons,
|
||||
});
|
||||
const updated = {
|
||||
...(refreshed ?? task.prInfo),
|
||||
lastMergeError: diagnosis.message,
|
||||
lastMergeErrorAt: new Date().toISOString(),
|
||||
} satisfies PrInfo;
|
||||
await scopedStore.updatePrInfo(task.id, updated);
|
||||
throw new ApiError(mapStructuredGhErrorToStatus(diagnosis.code), diagnosis.message, {
|
||||
githubError: diagnosis,
|
||||
});
|
||||
const err = new ApiError(502, "Failed to merge pull request", {
|
||||
code: "pr_merge_failed",
|
||||
retryable: true,
|
||||
error: message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user