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:
committed by
gsxdsm
parent
1f8d995b82
commit
9d5d310fdc
@@ -738,6 +738,8 @@ Do NOT spend time on nits when no real issues exist.`,
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type PrConflictState = "clean" | "conflicting" | "behind" | "blocked" | "unknown";
|
||||||
|
|
||||||
export interface PrInfo {
|
export interface PrInfo {
|
||||||
url: string;
|
url: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -753,6 +755,7 @@ export interface PrInfo {
|
|||||||
lastMergeError?: string;
|
lastMergeError?: string;
|
||||||
lastMergeErrorAt?: string;
|
lastMergeErrorAt?: string;
|
||||||
checkRollup?: "success" | "failure" | "pending" | "none";
|
checkRollup?: "success" | "failure" | "pending" | "none";
|
||||||
|
mergeable?: PrConflictState;
|
||||||
lastCommentAt?: string;
|
lastCommentAt?: string;
|
||||||
lastCheckedAt?: string;
|
lastCheckedAt?: string;
|
||||||
lastReviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
lastReviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
||||||
|
|||||||
@@ -1105,6 +1105,8 @@ describe("GitHubClient", () => {
|
|||||||
title: "Ready PR",
|
title: "Ready PR",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
reviewDecision: "APPROVED",
|
reviewDecision: "APPROVED",
|
||||||
|
mergeable: "MERGEABLE",
|
||||||
|
mergeStateStatus: "CLEAN",
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "fusion/fn-093",
|
headRefName: "fusion/fn-093",
|
||||||
})
|
})
|
||||||
@@ -1116,6 +1118,8 @@ describe("GitHubClient", () => {
|
|||||||
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
||||||
|
|
||||||
expect(result.mergeReady).toBe(true);
|
expect(result.mergeReady).toBe(true);
|
||||||
|
expect(result.mergeable).toBe("clean");
|
||||||
|
expect(result.prInfo.mergeable).toBe("clean");
|
||||||
expect(result.blockingReasons).toEqual([]);
|
expect(result.blockingReasons).toEqual([]);
|
||||||
expect(result.checks).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 () => {
|
it("falls back to GraphQL API when gh CLI merge-status lookup fails and token is available", 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");
|
||||||
@@ -1144,6 +1186,8 @@ describe("GitHubClient", () => {
|
|||||||
title: "Fallback PR",
|
title: "Fallback PR",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
reviewDecision: null,
|
reviewDecision: null,
|
||||||
|
mergeable: "CONFLICTING",
|
||||||
|
mergeStateStatus: "DIRTY",
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: "fusion/fn-093",
|
headRefName: "fusion/fn-093",
|
||||||
comments: { totalCount: 0 },
|
comments: { totalCount: 0 },
|
||||||
@@ -1196,6 +1240,8 @@ 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(true);
|
||||||
|
expect(result.mergeable).toBe("conflicting");
|
||||||
|
expect(result.prInfo.mergeable).toBe("conflicting");
|
||||||
expect(result.checks).toEqual([
|
expect(result.checks).toEqual([
|
||||||
{
|
{
|
||||||
name: "ci",
|
name: "ci",
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
isGhAvailable,
|
isGhAvailable,
|
||||||
isGhAuthenticated,
|
isGhAuthenticated,
|
||||||
@@ -156,6 +156,7 @@ export interface PrMergeStatus {
|
|||||||
prInfo: PrInfo;
|
prInfo: PrInfo;
|
||||||
reviewDecision: ReviewDecision;
|
reviewDecision: ReviewDecision;
|
||||||
checks: PrCheckStatus[];
|
checks: PrCheckStatus[];
|
||||||
|
mergeable: PrConflictState;
|
||||||
mergeReady: boolean;
|
mergeReady: boolean;
|
||||||
blockingReasons: string[];
|
blockingReasons: string[];
|
||||||
}
|
}
|
||||||
@@ -197,6 +198,9 @@ interface GhReviewJson {
|
|||||||
url?: string | null;
|
url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GhPrMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
||||||
|
type GhPrMergeStateStatus = "CLEAN" | "DIRTY" | "BLOCKED" | "BEHIND" | "UNSTABLE" | "UNKNOWN" | "HAS_HOOKS";
|
||||||
|
|
||||||
interface GhPrViewJson {
|
interface GhPrViewJson {
|
||||||
id?: string;
|
id?: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -205,6 +209,8 @@ interface GhPrViewJson {
|
|||||||
state: "OPEN" | "CLOSED" | "MERGED";
|
state: "OPEN" | "CLOSED" | "MERGED";
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
reviewDecision?: ReviewDecision;
|
reviewDecision?: ReviewDecision;
|
||||||
|
mergeable?: GhPrMergeable;
|
||||||
|
mergeStateStatus?: GhPrMergeStateStatus;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
comments: Array<{
|
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: {
|
function toPrInfo(input: {
|
||||||
url: string;
|
url: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -346,6 +371,7 @@ function toPrInfo(input: {
|
|||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
commentCount?: number;
|
commentCount?: number;
|
||||||
|
mergeable?: PrConflictState;
|
||||||
lastCommentAt?: string;
|
lastCommentAt?: string;
|
||||||
lastCheckedAt?: string;
|
lastCheckedAt?: string;
|
||||||
}): PrInfo {
|
}): PrInfo {
|
||||||
@@ -359,6 +385,7 @@ function toPrInfo(input: {
|
|||||||
commentCount: input.commentCount ?? 0,
|
commentCount: input.commentCount ?? 0,
|
||||||
isDraft: input.isDraft,
|
isDraft: input.isDraft,
|
||||||
draft: input.isDraft,
|
draft: input.isDraft,
|
||||||
|
mergeable: input.mergeable,
|
||||||
lastCommentAt: input.lastCommentAt,
|
lastCommentAt: input.lastCommentAt,
|
||||||
lastCheckedAt: input.lastCheckedAt,
|
lastCheckedAt: input.lastCheckedAt,
|
||||||
};
|
};
|
||||||
@@ -1017,8 +1044,9 @@ export class GitHubClient {
|
|||||||
const pr = await runGhJsonAsync<GhPrViewJson>([
|
const pr = await runGhJsonAsync<GhPrViewJson>([
|
||||||
"pr", "view", String(number),
|
"pr", "view", String(number),
|
||||||
"--repo", `${resolved.owner}/${resolved.repo}`,
|
"--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[]>([
|
const checks = await runGhJsonAsync<GhPrCheckJson[]>([
|
||||||
"pr", "checks", String(number),
|
"pr", "checks", String(number),
|
||||||
"--repo", `${resolved.owner}/${resolved.repo}`,
|
"--repo", `${resolved.owner}/${resolved.repo}`,
|
||||||
@@ -1035,6 +1063,7 @@ export class GitHubClient {
|
|||||||
baseBranch: pr.baseRefName,
|
baseBranch: pr.baseRefName,
|
||||||
isDraft: pr.isDraft,
|
isDraft: pr.isDraft,
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
|
mergeable,
|
||||||
});
|
});
|
||||||
const normalizedChecks = checks.map((check) => ({
|
const normalizedChecks = checks.map((check) => ({
|
||||||
name: check.name,
|
name: check.name,
|
||||||
@@ -1054,6 +1083,7 @@ export class GitHubClient {
|
|||||||
prInfo,
|
prInfo,
|
||||||
reviewDecision: pr.reviewDecision ?? null,
|
reviewDecision: pr.reviewDecision ?? null,
|
||||||
checks: normalizedChecks,
|
checks: normalizedChecks,
|
||||||
|
mergeable,
|
||||||
mergeReady: readiness.ready,
|
mergeReady: readiness.ready,
|
||||||
blockingReasons: readiness.blockingReasons,
|
blockingReasons: readiness.blockingReasons,
|
||||||
};
|
};
|
||||||
@@ -1073,6 +1103,8 @@ export class GitHubClient {
|
|||||||
title
|
title
|
||||||
state
|
state
|
||||||
reviewDecision
|
reviewDecision
|
||||||
|
mergeable
|
||||||
|
mergeStateStatus
|
||||||
isDraft
|
isDraft
|
||||||
baseRefName
|
baseRefName
|
||||||
headRefName
|
headRefName
|
||||||
@@ -1121,6 +1153,8 @@ export class GitHubClient {
|
|||||||
title: string;
|
title: string;
|
||||||
state: "OPEN" | "CLOSED" | "MERGED";
|
state: "OPEN" | "CLOSED" | "MERGED";
|
||||||
reviewDecision: ReviewDecision;
|
reviewDecision: ReviewDecision;
|
||||||
|
mergeable?: GhPrMergeable;
|
||||||
|
mergeStateStatus?: GhPrMergeStateStatus;
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
@@ -1186,6 +1220,7 @@ export class GitHubClient {
|
|||||||
} satisfies PrCheckStatus];
|
} satisfies PrCheckStatus];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
|
||||||
const prInfo = toPrInfo({
|
const prInfo = toPrInfo({
|
||||||
url: pr.url,
|
url: pr.url,
|
||||||
number: pr.number,
|
number: pr.number,
|
||||||
@@ -1195,6 +1230,7 @@ export class GitHubClient {
|
|||||||
baseBranch: pr.baseRefName,
|
baseBranch: pr.baseRefName,
|
||||||
isDraft: pr.isDraft,
|
isDraft: pr.isDraft,
|
||||||
commentCount: pr.comments.totalCount,
|
commentCount: pr.comments.totalCount,
|
||||||
|
mergeable,
|
||||||
});
|
});
|
||||||
const readiness = isPrMergeReady({
|
const readiness = isPrMergeReady({
|
||||||
status: prInfo.status,
|
status: prInfo.status,
|
||||||
@@ -1206,6 +1242,7 @@ export class GitHubClient {
|
|||||||
prInfo,
|
prInfo,
|
||||||
reviewDecision: pr.reviewDecision,
|
reviewDecision: pr.reviewDecision,
|
||||||
checks,
|
checks,
|
||||||
|
mergeable,
|
||||||
mergeReady: readiness.ready,
|
mergeReady: readiness.ready,
|
||||||
blockingReasons: readiness.blockingReasons,
|
blockingReasons: readiness.blockingReasons,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user