feat(FN-4763): complete Step 1 — capture PR mergeable state

Fusion-Task-Id: FN-4763
Fusion-Task-Lineage: b3628e63-682b-4a39-9a7d-2c96278c5366
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 12:29:53 -07:00
committed by gsxdsm
parent 1f8d995b82
commit 9d5d310fdc
3 changed files with 88 additions and 2 deletions

View File

@@ -1105,6 +1105,8 @@ describe("GitHubClient", () => {
title: "Ready PR",
state: "OPEN",
reviewDecision: "APPROVED",
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
baseRefName: "main",
headRefName: "fusion/fn-093",
})
@@ -1116,6 +1118,8 @@ describe("GitHubClient", () => {
const result = await client.getPrMergeStatus("owner", "repo", 42);
expect(result.mergeReady).toBe(true);
expect(result.mergeable).toBe("clean");
expect(result.prInfo.mergeable).toBe("clean");
expect(result.blockingReasons).toEqual([]);
expect(result.checks).toEqual([
{
@@ -1130,6 +1134,44 @@ describe("GitHubClient", () => {
]);
});
it("maps DIRTY merge-state to conflicting even when mergeable is unknown", async () => {
mockRunGhJsonAsync
.mockResolvedValueOnce({
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Dirty PR",
state: "OPEN",
reviewDecision: "APPROVED",
mergeable: "UNKNOWN",
mergeStateStatus: "DIRTY",
baseRefName: "main",
headRefName: "fusion/fn-093",
})
.mockResolvedValueOnce([{ name: "ci", state: "SUCCESS" }]);
const result = await client.getPrMergeStatus("owner", "repo", 42);
expect(result.mergeable).toBe("conflicting");
expect(result.prInfo.mergeable).toBe("conflicting");
});
it("maps missing mergeability fields to unknown", async () => {
mockRunGhJsonAsync
.mockResolvedValueOnce({
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Unknown PR",
state: "OPEN",
reviewDecision: "APPROVED",
baseRefName: "main",
headRefName: "fusion/fn-093",
})
.mockResolvedValueOnce([{ name: "ci", state: "SUCCESS" }]);
const result = await client.getPrMergeStatus("owner", "repo", 42);
expect(result.mergeable).toBe("unknown");
expect(result.prInfo.mergeable).toBe("unknown");
});
it("falls back to GraphQL API when gh CLI merge-status lookup fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
@@ -1144,6 +1186,8 @@ describe("GitHubClient", () => {
title: "Fallback PR",
state: "OPEN",
reviewDecision: null,
mergeable: "CONFLICTING",
mergeStateStatus: "DIRTY",
baseRefName: "main",
headRefName: "fusion/fn-093",
comments: { totalCount: 0 },
@@ -1196,6 +1240,8 @@ describe("GitHubClient", () => {
const result = await clientWithToken.getPrMergeStatus("owner", "repo", 42);
expect(result.mergeReady).toBe(true);
expect(result.mergeable).toBe("conflicting");
expect(result.prInfo.mergeable).toBe("conflicting");
expect(result.checks).toEqual([
{
name: "ci",

View File

@@ -1,4 +1,4 @@
import type { IssueInfo, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
import type { IssueInfo, PrConflictState, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core";
import {
isGhAvailable,
isGhAuthenticated,
@@ -156,6 +156,7 @@ export interface PrMergeStatus {
prInfo: PrInfo;
reviewDecision: ReviewDecision;
checks: PrCheckStatus[];
mergeable: PrConflictState;
mergeReady: boolean;
blockingReasons: string[];
}
@@ -197,6 +198,9 @@ interface GhReviewJson {
url?: string | null;
}
type GhPrMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
type GhPrMergeStateStatus = "CLEAN" | "DIRTY" | "BLOCKED" | "BEHIND" | "UNSTABLE" | "UNKNOWN" | "HAS_HOOKS";
interface GhPrViewJson {
id?: string;
number: number;
@@ -205,6 +209,8 @@ interface GhPrViewJson {
state: "OPEN" | "CLOSED" | "MERGED";
isDraft?: boolean;
reviewDecision?: ReviewDecision;
mergeable?: GhPrMergeable;
mergeStateStatus?: GhPrMergeStateStatus;
baseRefName: string;
headRefName: string;
comments: Array<{
@@ -337,6 +343,25 @@ function normalizeCheckState(state: string | null | undefined): PrCheckState {
}
}
function mapPrConflictState(
mergeable?: GhPrMergeable,
mergeStateStatus?: GhPrMergeStateStatus,
): PrConflictState {
if (mergeStateStatus === "DIRTY" || mergeable === "CONFLICTING") {
return "conflicting";
}
if (mergeStateStatus === "BEHIND") {
return "behind";
}
if (mergeStateStatus === "BLOCKED") {
return "blocked";
}
if (mergeStateStatus === "CLEAN" || mergeable === "MERGEABLE") {
return "clean";
}
return "unknown";
}
function toPrInfo(input: {
url: string;
number: number;
@@ -346,6 +371,7 @@ function toPrInfo(input: {
baseBranch: string;
isDraft?: boolean;
commentCount?: number;
mergeable?: PrConflictState;
lastCommentAt?: string;
lastCheckedAt?: string;
}): PrInfo {
@@ -359,6 +385,7 @@ function toPrInfo(input: {
commentCount: input.commentCount ?? 0,
isDraft: input.isDraft,
draft: input.isDraft,
mergeable: input.mergeable,
lastCommentAt: input.lastCommentAt,
lastCheckedAt: input.lastCheckedAt,
};
@@ -1017,8 +1044,9 @@ export class GitHubClient {
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr", "view", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
"--json", "number,url,title,state,isDraft,baseRefName,headRefName,reviewDecision",
"--json", "number,url,title,state,isDraft,baseRefName,headRefName,reviewDecision,mergeable,mergeStateStatus",
]);
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
const checks = await runGhJsonAsync<GhPrCheckJson[]>([
"pr", "checks", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
@@ -1035,6 +1063,7 @@ export class GitHubClient {
baseBranch: pr.baseRefName,
isDraft: pr.isDraft,
commentCount: 0,
mergeable,
});
const normalizedChecks = checks.map((check) => ({
name: check.name,
@@ -1054,6 +1083,7 @@ export class GitHubClient {
prInfo,
reviewDecision: pr.reviewDecision ?? null,
checks: normalizedChecks,
mergeable,
mergeReady: readiness.ready,
blockingReasons: readiness.blockingReasons,
};
@@ -1073,6 +1103,8 @@ export class GitHubClient {
title
state
reviewDecision
mergeable
mergeStateStatus
isDraft
baseRefName
headRefName
@@ -1121,6 +1153,8 @@ export class GitHubClient {
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
reviewDecision: ReviewDecision;
mergeable?: GhPrMergeable;
mergeStateStatus?: GhPrMergeStateStatus;
isDraft?: boolean;
baseRefName: string;
headRefName: string;
@@ -1186,6 +1220,7 @@ export class GitHubClient {
} satisfies PrCheckStatus];
});
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
const prInfo = toPrInfo({
url: pr.url,
number: pr.number,
@@ -1195,6 +1230,7 @@ export class GitHubClient {
baseBranch: pr.baseRefName,
isDraft: pr.isDraft,
commentCount: pr.comments.totalCount,
mergeable,
});
const readiness = isPrMergeReady({
status: prInfo.status,
@@ -1206,6 +1242,7 @@ export class GitHubClient {
prInfo,
reviewDecision: pr.reviewDecision,
checks,
mergeable,
mergeReady: readiness.ready,
blockingReasons: readiness.blockingReasons,
};