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:
gsxdsm
2026-08-08 18:56:43 -07:00
parent a61ab834f8
commit c0e2ba2ade
9 changed files with 351 additions and 15 deletions

View 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.

View File

@@ -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",

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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;
}
/**

View File

@@ -1114,6 +1114,7 @@ export {
getCurrentRepo,
getPushRepo,
type GhError,
type GhErrorClassificationContext,
type GhErrorCode,
type StructuredGhError,
} from "./cli/gh-cli.js";

View File

@@ -1257,6 +1257,7 @@ export {
getCurrentRepo,
getPushRepo,
type GhError,
type GhErrorClassificationContext,
type GhErrorCode,
type StructuredGhError,
} from "./cli/gh-cli.js";

View File

@@ -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,

View File

@@ -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;
}
}