FN-8835: block unmergeable pull requests
Fail closed before auto-merging pull requests that GitHub reports as unmergeable. - Require normalized clean mergeability in PR readiness checks. - Cover protected, behind, conflicting, and unknown PR states across CLI and dashboard tests. - Add a patch changeset for the corrected auto-merge behavior. Files changed: .changeset/fn-8835-pr-merge-readiness.md | 7 +++ .../src/commands/__tests__/task-lifecycle.test.ts | 21 ++++--- packages/dashboard/src/__tests__/github.test.ts | 64 ++++++++++++++++++---- packages/dashboard/src/github.ts | 11 ++++ 4 files changed, 84 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-8835 Fusion-Task-Lineage: 698dacf9-5f24-42b7-b927-ae5b5579ee3f Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8835-pr-merge-readiness.md
Normal file
7
.changeset/fn-8835-pr-merge-readiness.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Prevent auto-merge attempts for branch-protected, behind, conflicting, or unknown PR states.
|
||||||
|
category: fix
|
||||||
|
dev: The legacy PR merge gate now requires normalized mergeability to be `clean` while preserving optional approval and check policy.
|
||||||
@@ -517,7 +517,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({ head: getTaskBranchName(task.id) }));
|
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({ head: getTaskBranchName(task.id) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not create duplicate group PR when branch-group PR already exists", async () => {
|
it("holds a branch-protected shared-group PR without calling mergePr", async () => {
|
||||||
const task: MockTask = {
|
const task: MockTask = {
|
||||||
id: "FN-9012",
|
id: "FN-9012",
|
||||||
title: "group member",
|
title: "group member",
|
||||||
@@ -553,11 +553,11 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
findPrForBranch: vi.fn(async () => null),
|
findPrForBranch: vi.fn(async () => null),
|
||||||
createPr: vi.fn(),
|
createPr: vi.fn(),
|
||||||
getPrMergeStatus: vi.fn(async () => ({
|
getPrMergeStatus: vi.fn(async () => ({
|
||||||
prInfo: { number: 22, status: "open" as const, url: "https://github.com/x/y/pull/22" },
|
prInfo: { number: 22, status: "open" as const, url: "https://github.com/x/y/pull/22", mergeable: "blocked" as const },
|
||||||
reviewDecision: null,
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
checks: [],
|
checks: [],
|
||||||
mergeReady: false,
|
mergeReady: false,
|
||||||
blockingReasons: [],
|
blockingReasons: ["PR mergeability is blocked"],
|
||||||
})),
|
})),
|
||||||
mergePr: vi.fn(),
|
mergePr: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -566,6 +566,8 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
|
|
||||||
expect(github.createPr).not.toHaveBeenCalled();
|
expect(github.createPr).not.toHaveBeenCalled();
|
||||||
expect(github.getPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 22);
|
expect(github.getPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 22);
|
||||||
|
expect(github.mergePr).not.toHaveBeenCalled();
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: "awaiting-pr-checks" });
|
||||||
expect(store.updateBranchGroup).toHaveBeenCalledWith("BG-2", expect.objectContaining({
|
expect(store.updateBranchGroup).toHaveBeenCalledWith("BG-2", expect.objectContaining({
|
||||||
prNumber: 22,
|
prNumber: 22,
|
||||||
prUrl: "https://github.com/x/y/pull/22",
|
prUrl: "https://github.com/x/y/pull/22",
|
||||||
@@ -845,7 +847,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not bump mergeRetries for a non-conflicting not-ready PR", async () => {
|
it("holds a branch-protected review-required PR without a merge attempt or conflict retry", async () => {
|
||||||
const task: MockTask = {
|
const task: MockTask = {
|
||||||
id: "FN-9021",
|
id: "FN-9021",
|
||||||
title: "test",
|
title: "test",
|
||||||
@@ -862,11 +864,11 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
findPrForBranch: vi.fn(async () => existingPr),
|
findPrForBranch: vi.fn(async () => existingPr),
|
||||||
createPr: vi.fn(),
|
createPr: vi.fn(),
|
||||||
getPrMergeStatus: vi.fn(async () => ({
|
getPrMergeStatus: vi.fn(async () => ({
|
||||||
prInfo: { ...existingPr, mergeable: "unknown" as const },
|
prInfo: { ...existingPr, mergeable: "blocked" as const },
|
||||||
reviewDecision: null,
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
checks: [],
|
checks: [],
|
||||||
mergeReady: false,
|
mergeReady: false,
|
||||||
blockingReasons: [],
|
blockingReasons: ["PR mergeability is blocked"],
|
||||||
})),
|
})),
|
||||||
mergePr: vi.fn(),
|
mergePr: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -875,6 +877,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
|
|
||||||
expect(result).toBe("waiting");
|
expect(result).toBe("waiting");
|
||||||
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: "awaiting-pr-checks" });
|
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: "awaiting-pr-checks" });
|
||||||
|
expect(github.mergePr).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips the push when an existing PR already covers the branch", async () => {
|
it("skips the push when an existing PR already covers the branch", async () => {
|
||||||
@@ -1456,7 +1459,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
// checks and no blocking review state, so isPrMergeReady returns
|
// checks and no blocking review state, so isPrMergeReady returns
|
||||||
// mergeReady: true. Without the gate this would auto-merge.
|
// mergeReady: true. Without the gate this would auto-merge.
|
||||||
return {
|
return {
|
||||||
prInfo,
|
prInfo: { ...prInfo, mergeable: "clean" as const },
|
||||||
reviewDecision,
|
reviewDecision,
|
||||||
checks: [],
|
checks: [],
|
||||||
mergeReady: true,
|
mergeReady: true,
|
||||||
|
|||||||
@@ -1665,6 +1665,8 @@ describe("GitHubClient", () => {
|
|||||||
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
||||||
expect(result.mergeable).toBe("conflicting");
|
expect(result.mergeable).toBe("conflicting");
|
||||||
expect(result.prInfo.mergeable).toBe("conflicting");
|
expect(result.prInfo.mergeable).toBe("conflicting");
|
||||||
|
expect(result.mergeReady).toBe(false);
|
||||||
|
expect(result.blockingReasons).toEqual(["PR mergeability is conflicting"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps BEHIND merge-state to behind", async () => {
|
it("maps BEHIND merge-state to behind", async () => {
|
||||||
@@ -1684,6 +1686,8 @@ describe("GitHubClient", () => {
|
|||||||
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
||||||
expect(result.mergeable).toBe("behind");
|
expect(result.mergeable).toBe("behind");
|
||||||
expect(result.prInfo.mergeable).toBe("behind");
|
expect(result.prInfo.mergeable).toBe("behind");
|
||||||
|
expect(result.mergeReady).toBe(false);
|
||||||
|
expect(result.blockingReasons).toEqual(["PR mergeability is behind"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps BLOCKED merge-state to blocked", async () => {
|
it("maps BLOCKED merge-state to blocked", async () => {
|
||||||
@@ -1693,7 +1697,8 @@ describe("GitHubClient", () => {
|
|||||||
url: "https://github.com/owner/repo/pull/42",
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
title: "Blocked PR",
|
title: "Blocked PR",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
reviewDecision: "APPROVED",
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
mergeStateStatus: "BLOCKED",
|
mergeStateStatus: "BLOCKED",
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "fusion/fn-093",
|
headRefName: "fusion/fn-093",
|
||||||
@@ -1703,6 +1708,8 @@ describe("GitHubClient", () => {
|
|||||||
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
||||||
expect(result.mergeable).toBe("blocked");
|
expect(result.mergeable).toBe("blocked");
|
||||||
expect(result.prInfo.mergeable).toBe("blocked");
|
expect(result.prInfo.mergeable).toBe("blocked");
|
||||||
|
expect(result.mergeReady).toBe(false);
|
||||||
|
expect(result.blockingReasons).toEqual(["PR mergeability is blocked"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps missing mergeability fields to unknown", async () => {
|
it("maps missing mergeability fields to unknown", async () => {
|
||||||
@@ -1721,9 +1728,11 @@ describe("GitHubClient", () => {
|
|||||||
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
||||||
expect(result.mergeable).toBe("unknown");
|
expect(result.mergeable).toBe("unknown");
|
||||||
expect(result.prInfo.mergeable).toBe("unknown");
|
expect(result.prInfo.mergeable).toBe("unknown");
|
||||||
|
expect(result.mergeReady).toBe(false);
|
||||||
|
expect(result.blockingReasons).toEqual(["PR mergeability is unknown"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to GraphQL API when gh CLI merge-status lookup fails and token is available", async () => {
|
it("fails closed through the GraphQL API when branch protection blocks a review-required PR", async () => {
|
||||||
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
|
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
|
||||||
const clientWithToken = new GitHubClient("ghp_token");
|
const clientWithToken = new GitHubClient("ghp_token");
|
||||||
const mockFetch = vi.fn().mockResolvedValue({
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
@@ -1734,11 +1743,11 @@ describe("GitHubClient", () => {
|
|||||||
pullRequest: {
|
pullRequest: {
|
||||||
number: 42,
|
number: 42,
|
||||||
url: "https://github.com/owner/repo/pull/42",
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
title: "Fallback PR",
|
title: "Branch-protected PR",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
reviewDecision: null,
|
reviewDecision: "REVIEW_REQUIRED",
|
||||||
mergeable: "CONFLICTING",
|
mergeable: "MERGEABLE",
|
||||||
mergeStateStatus: "DIRTY",
|
mergeStateStatus: "BLOCKED",
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "fusion/fn-093",
|
headRefName: "fusion/fn-093",
|
||||||
comments: { totalCount: 0 },
|
comments: { totalCount: 0 },
|
||||||
@@ -1790,9 +1799,10 @@ describe("GitHubClient", () => {
|
|||||||
|
|
||||||
const result = await clientWithToken.getPrMergeStatus("owner", "repo", 42);
|
const result = await clientWithToken.getPrMergeStatus("owner", "repo", 42);
|
||||||
|
|
||||||
expect(result.mergeReady).toBe(true);
|
expect(result.mergeReady).toBe(false);
|
||||||
expect(result.mergeable).toBe("conflicting");
|
expect(result.mergeable).toBe("blocked");
|
||||||
expect(result.prInfo.mergeable).toBe("conflicting");
|
expect(result.prInfo.mergeable).toBe("blocked");
|
||||||
|
expect(result.blockingReasons).toEqual(["PR mergeability is blocked"]);
|
||||||
expect(result.checks).toEqual([
|
expect(result.checks).toEqual([
|
||||||
{
|
{
|
||||||
name: "ci",
|
name: "ci",
|
||||||
@@ -2140,7 +2150,7 @@ describe("GitHubClient", () => {
|
|||||||
|
|
||||||
describe("isPrMergeReady", () => {
|
describe("isPrMergeReady", () => {
|
||||||
it("blocks closed PRs", () => {
|
it("blocks closed PRs", () => {
|
||||||
expect(isPrMergeReady({ status: "closed", reviewDecision: null, checks: [] })).toEqual({
|
expect(isPrMergeReady({ status: "closed", reviewDecision: null, checks: [], mergeable: "clean" })).toEqual({
|
||||||
ready: false,
|
ready: false,
|
||||||
blockingReasons: ["PR is closed"],
|
blockingReasons: ["PR is closed"],
|
||||||
});
|
});
|
||||||
@@ -2151,6 +2161,7 @@ describe("GitHubClient", () => {
|
|||||||
status: "open",
|
status: "open",
|
||||||
reviewDecision: "CHANGES_REQUESTED",
|
reviewDecision: "CHANGES_REQUESTED",
|
||||||
checks: [{ name: "ci", required: true, state: "success" }],
|
checks: [{ name: "ci", required: true, state: "success" }],
|
||||||
|
mergeable: "clean",
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
ready: false,
|
ready: false,
|
||||||
blockingReasons: ["changes requested review is active"],
|
blockingReasons: ["changes requested review is active"],
|
||||||
@@ -2162,6 +2173,7 @@ describe("GitHubClient", () => {
|
|||||||
status: "open",
|
status: "open",
|
||||||
reviewDecision: null,
|
reviewDecision: null,
|
||||||
checks: [{ name: "ci", required: true, state: "pending" }],
|
checks: [{ name: "ci", required: true, state: "pending" }],
|
||||||
|
mergeable: "clean",
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
ready: false,
|
ready: false,
|
||||||
blockingReasons: ["required checks not successful: ci (pending)"],
|
blockingReasons: ["required checks not successful: ci (pending)"],
|
||||||
@@ -2176,8 +2188,40 @@ describe("GitHubClient", () => {
|
|||||||
{ name: "required-ci", required: true, state: "success" },
|
{ name: "required-ci", required: true, state: "success" },
|
||||||
{ name: "optional-preview", required: false, state: "failure" },
|
{ name: "optional-preview", required: false, state: "failure" },
|
||||||
],
|
],
|
||||||
|
mergeable: "clean",
|
||||||
})).toEqual({ ready: true, blockingReasons: [] });
|
})).toEqual({ ready: true, blockingReasons: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["blocked", "behind", "conflicting", "unknown"] as const)(
|
||||||
|
"fails closed when mergeability is %s",
|
||||||
|
(mergeable) => {
|
||||||
|
expect(isPrMergeReady({
|
||||||
|
status: "open",
|
||||||
|
reviewDecision: "APPROVED",
|
||||||
|
checks: [{ name: "ci", required: true, state: "success" }],
|
||||||
|
mergeable,
|
||||||
|
})).toEqual({
|
||||||
|
ready: false,
|
||||||
|
blockingReasons: [`PR mergeability is ${mergeable}`],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("aggregates branch protection with existing review and required-check blockers", () => {
|
||||||
|
expect(isPrMergeReady({
|
||||||
|
status: "open",
|
||||||
|
reviewDecision: "CHANGES_REQUESTED",
|
||||||
|
checks: [{ name: "ci", required: true, state: "failure" }],
|
||||||
|
mergeable: "blocked",
|
||||||
|
})).toEqual({
|
||||||
|
ready: false,
|
||||||
|
blockingReasons: [
|
||||||
|
"changes requested review is active",
|
||||||
|
"PR mergeability is blocked",
|
||||||
|
"required checks not successful: ci (failure)",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error handling when gh CLI not available", () => {
|
describe("error handling when gh CLI not available", () => {
|
||||||
|
|||||||
@@ -735,10 +735,15 @@ function toPrInfo(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PrMergeReadiness 2026-08-09-00:48:
|
||||||
|
FN-8835 requires the live pull-request merge gate to fail closed on GitHub's normalized mergeability state. Branch-protection BLOCKED, stale BEHIND, conflicts, and unknown state must wait before any merge request; legacy approval and optional-check policy remain unchanged.
|
||||||
|
*/
|
||||||
export function isPrMergeReady(input: {
|
export function isPrMergeReady(input: {
|
||||||
status: PrInfo["status"];
|
status: PrInfo["status"];
|
||||||
reviewDecision: ReviewDecision;
|
reviewDecision: ReviewDecision;
|
||||||
checks: PrCheckStatus[];
|
checks: PrCheckStatus[];
|
||||||
|
mergeable: PrConflictState;
|
||||||
}): { ready: boolean; blockingReasons: string[] } {
|
}): { ready: boolean; blockingReasons: string[] } {
|
||||||
const blockingReasons: string[] = [];
|
const blockingReasons: string[] = [];
|
||||||
|
|
||||||
@@ -750,6 +755,10 @@ export function isPrMergeReady(input: {
|
|||||||
blockingReasons.push("changes requested review is active");
|
blockingReasons.push("changes requested review is active");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.mergeable !== "clean") {
|
||||||
|
blockingReasons.push(`PR mergeability is ${input.mergeable}`);
|
||||||
|
}
|
||||||
|
|
||||||
const blockingChecks = input.checks.filter(
|
const blockingChecks = input.checks.filter(
|
||||||
(check) => check.required && check.state !== "success",
|
(check) => check.required && check.state !== "success",
|
||||||
);
|
);
|
||||||
@@ -1816,6 +1825,7 @@ export class GitHubClient {
|
|||||||
status: prInfo.status,
|
status: prInfo.status,
|
||||||
reviewDecision: pr.reviewDecision ?? null,
|
reviewDecision: pr.reviewDecision ?? null,
|
||||||
checks: normalizedChecks,
|
checks: normalizedChecks,
|
||||||
|
mergeable,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1975,6 +1985,7 @@ export class GitHubClient {
|
|||||||
status: prInfo.status,
|
status: prInfo.status,
|
||||||
reviewDecision: pr.reviewDecision,
|
reviewDecision: pr.reviewDecision,
|
||||||
checks,
|
checks,
|
||||||
|
mergeable,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user