From c0e2ba2ade4984e269cf177e6f7edb23c43043bc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 8 Aug 2026 18:56:43 -0700 Subject: [PATCH] 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) --- .changeset/fn-8836-gh-merge-policy-errors.md | 7 + .../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 + .../src/__tests__/routes-github.test.ts | 131 ++++++++++++++++++ .../src/routes/register-git-github.ts | 58 ++++++-- 9 files changed, 351 insertions(+), 15 deletions(-) create mode 100644 .changeset/fn-8836-gh-merge-policy-errors.md diff --git a/.changeset/fn-8836-gh-merge-policy-errors.md b/.changeset/fn-8836-gh-merge-policy-errors.md new file mode 100644 index 0000000000..3e5a804f6c --- /dev/null +++ b/.changeset/fn-8836-gh-merge-policy-errors.md @@ -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. diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index aef8266ed8..4f042373e9 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -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", diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index e7970fe227..5b343cbee1 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -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); diff --git a/packages/core/src/__tests__/gh-cli.test.ts b/packages/core/src/__tests__/gh-cli.test.ts index 07a087861a..a4d44fca38 100644 --- a/packages/core/src/__tests__/gh-cli.test.ts +++ b/packages/core/src/__tests__/gh-cli.test.ts @@ -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", () => { diff --git a/packages/core/src/cli/gh-cli.ts b/packages/core/src/cli/gh-cli.ts index b0b71dcc49..dd634fc286 100644 --- a/packages/core/src/cli/gh-cli.ts +++ b/packages/core/src/cli/gh-cli.ts @@ -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(); + 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; } /** diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 85d938a610..a27b2e3d96 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1114,6 +1114,7 @@ export { getCurrentRepo, getPushRepo, type GhError, + type GhErrorClassificationContext, type GhErrorCode, type StructuredGhError, } from "./cli/gh-cli.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12ff43568b..d0ff8c541e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1257,6 +1257,7 @@ export { getCurrentRepo, getPushRepo, type GhError, + type GhErrorClassificationContext, type GhErrorCode, type StructuredGhError, } from "./cli/gh-cli.js"; diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index 0dbd4fe832..fd84b5e6c7 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -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).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).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).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).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).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).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, diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 7a1360cdc9..3f14428469 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -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> | 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; } }