FN-8855: enforce configured PR checks before merging
Add configurable Fusion-side PR check gating across merge surfaces. - Add project required-check settings, validation, UI controls, CLI support, and operator documentation. - Evaluate configured checks before dashboard and CLI PR merges, accepting successful, skipped, and neutral checks. - Bind merges to the evaluated PR head SHA to prevent push-and-merge races. - Cover required-check policy and head-SHA merge behavior with tests. Files changed: .changeset/fn-8855-required-checks.md | 7 + docs/dashboard-guide.md | 5 + docs/settings-reference.md | 1 + .../src/commands/__tests__/task-lifecycle.test.ts | 30 ++++- packages/cli/src/commands/task-lifecycle.ts | 17 ++- .../core/src/__tests__/required-checks.test.ts | 16 +++ packages/core/src/config/index.ts | 1 + packages/core/src/config/required-checks.ts | 16 +++ packages/core/src/config/settings-schema.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/types.ts | 6 + packages/core/src/types/settings/settings-scope.ts | 6 + packages/core/src/types/task/task-tracking.ts | 5 + .../settings/__tests__/section-keys.test.ts | 1 + .../app/components/settings/section-keys.ts | 1 + .../settings/sections/MergeSection.search.ts | 3 + .../components/settings/sections/MergeSection.tsx | 30 ++++- .../__tests__/MergeSection.requiredChecks.test.tsx | 50 +++++++ .../settings-default-descriptions.test.tsx | 1 + packages/dashboard/src/__tests__/github.test.ts | 123 +++++++++++++++++ packages/dashboard/src/github.ts | 149 +++++++++++++-------- .../dashboard/src/routes/register-git-github.ts | 38 ++++-- .../src/routes/register-task-workflow-routes.ts | 8 +- packages/i18n/locales/en/app.json | 4 +- 24 files changed, 441 insertions(+), 79 deletions(-) Fusion-Task-Id: FN-8855 Fusion-Task-Lineage: 708e9c27-72be-4925-bf09-5db421bcaa67 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8855-required-checks.md
Normal file
7
.changeset/fn-8855-required-checks.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Require named GitHub checks before Fusion merges pull requests.
|
||||||
|
category: feature
|
||||||
|
dev: Adds `requiredChecks`, `resolveRequiredCheckNames` in @fusion/core, and getPrMergeStatus options.
|
||||||
@@ -2415,3 +2415,8 @@ The Review tab shows a custom workflow result only when its selected workflow de
|
|||||||
### Workflow agent routing
|
### Workflow agent routing
|
||||||
|
|
||||||
Agent creation and detail settings support a primary role plus additional role tags. Workflow review prompts expose a node-local reviewer override and retain a missing configured ID visibly rather than clearing it. Task workflow-stage identity is distinct from assigned ownership: a stage badge identifies the currently fenced principal while active, then clears when its work item terminates.
|
Agent creation and detail settings support a primary role plus additional role tags. Workflow review prompts expose a node-local reviewer override and retain a missing configured ID visibly rather than clearing it. Task workflow-stage identity is distinct from assigned ownership: a stage badge identifies the currently fenced principal while active, then clears when its work item terminates.
|
||||||
|
|
||||||
|
|
||||||
|
### Pull-request required checks
|
||||||
|
|
||||||
|
In **Settings → Merge**, pull-request mode offers **Required pull-request checks**. Enter comma-separated GitHub check names to make Fusion wait for those names independently of repository rulesets. The PR review surfaces show the same missing, pending, failed, or truncated-check-list reasons used by the merge gate. **Reset this menu** clears the setting.
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
|
|||||||
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
|
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
|
||||||
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
|
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
|
||||||
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
|
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
|
||||||
|
| `requiredChecks` | `string[]` | unset | Pull-request-mode only, opt-in Fusion-side required check names. Names match GitHub check names exactly and case-sensitively, independently of GitHub required-status-check configuration. `success`, `skipped`, and `neutral` satisfy a name; every other state, an absent name, and a named check outside GraphQL's first 100 contexts block the merge (the latter reports a truncated-list reason). Empty/unset preserves GitHub-delegated behavior. |
|
||||||
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
|
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
|
||||||
| `mergeIntegrationWorktree` | `"reuse-task-worktree" \| "cwd-integration-branch" \| "cwd-main"` | `"reuse-task-worktree"` | Auto-merge integration-root mode for direct merges only (`mergeStrategy="direct"`). `reuse-task-worktree` (default) runs the rebase/conflict/audit/finalize cascade inside the task worktree after FN-5279 reuse-handoff gates, leaving project-root `HEAD`/dirty state untouched. `cwd-integration-branch` is an explicit operator opt-in escape hatch that runs the cascade from the resolved integration branch in the project-root worktree and surfaces an operator-visible startup warning per FN-5348. `cwd-main` is a deprecated legacy alias: `normalizeMergeIntegrationWorktreeMode(...)` normalizes it to `cwd-integration-branch` at read time and emits a one-shot `[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch` warning; new configs must not use it. When `worktrunk.enabled=true`, worktrunk-managed merge/worktree handling takes precedence and this setting is advisory until the native path runs. Reuse-handoff refusal must never silently fall back to `cwd-integration-branch`: any future fallback path must emit `merge:cwd-integration-fallback-removed`, and current behavior leaves the task in `in-review` instead. |
|
| `mergeIntegrationWorktree` | `"reuse-task-worktree" \| "cwd-integration-branch" \| "cwd-main"` | `"reuse-task-worktree"` | Auto-merge integration-root mode for direct merges only (`mergeStrategy="direct"`). `reuse-task-worktree` (default) runs the rebase/conflict/audit/finalize cascade inside the task worktree after FN-5279 reuse-handoff gates, leaving project-root `HEAD`/dirty state untouched. `cwd-integration-branch` is an explicit operator opt-in escape hatch that runs the cascade from the resolved integration branch in the project-root worktree and surfaces an operator-visible startup warning per FN-5348. `cwd-main` is a deprecated legacy alias: `normalizeMergeIntegrationWorktreeMode(...)` normalizes it to `cwd-integration-branch` at read time and emits a one-shot `[merger] settings.mergeIntegrationWorktree=cwd-main is legacy; normalized to cwd-integration-branch` warning; new configs must not use it. When `worktrunk.enabled=true`, worktrunk-managed merge/worktree handling takes precedence and this setting is advisory until the native path runs. Reuse-handoff refusal must never silently fall back to `cwd-integration-branch`: any future fallback path must emit `merge:cwd-integration-fallback-removed`, and current behavior leaves the task in `in-review` instead. |
|
||||||
| `mergeAdvanceAutoSync` | `"off" \| "ff-only" \| "stash-and-ff"` | `"stash-and-ff"` | After the merger advances the integration-branch ref, what to do in **other** worktrees still on that branch (typically your project-root checkout). `off` leaves them alone; users must `git pull` or click the Merge Advance Notice banner's Pull button to bring their checkout forward — this is the surprise behavior that made `git status` look like the merge had been reverted. `ff-only` auto-fast-forwards only when the other worktree's index and working tree are clean; dirty worktrees stay untouched and the banner still surfaces for manual pull. `stash-and-ff` (default) runs the Smart Pull pipeline (stash → fast-forward → pop) so local edits survive across the auto-sync. Pop conflicts emit `merge:auto-sync` audit events with `outcome: "stash-pop-conflict"` and surface through the dashboard's existing stash-conflict modal. Only applies to direct merges. |
|
| `mergeAdvanceAutoSync` | `"off" \| "ff-only" \| "stash-and-ff"` | `"stash-and-ff"` | After the merger advances the integration-branch ref, what to do in **other** worktrees still on that branch (typically your project-root checkout). `off` leaves them alone; users must `git pull` or click the Merge Advance Notice banner's Pull button to bring their checkout forward — this is the surprise behavior that made `git status` look like the merge had been reverted. `ff-only` auto-fast-forwards only when the other worktree's index and working tree are clean; dirty worktrees stay untouched and the banner still surfaces for manual pull. `stash-and-ff` (default) runs the Smart Pull pipeline (stash → fast-forward → pop) so local edits survive across the auto-sync. Pop conflicts emit `merge:auto-sync` audit events with `outcome: "stash-pop-conflict"` and surface through the dashboard's existing stash-conflict modal. Only applies to direct merges. |
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
column: "in-review",
|
column: "in-review",
|
||||||
branchContext: { groupId: "planning:g4", source: "planning", assignmentMode: "shared" },
|
branchContext: { groupId: "planning:g4", source: "planning", assignmentMode: "shared" },
|
||||||
};
|
};
|
||||||
const store = makeStore(task, { baseBranch: "main" });
|
const store = makeStore(task, { baseBranch: "main", requiredChecks: [" build ", "build", ""] });
|
||||||
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
|
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
id: "BG-4",
|
id: "BG-4",
|
||||||
sourceType: "planning",
|
sourceType: "planning",
|
||||||
@@ -333,6 +333,9 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
expect(github.mergePr).toHaveBeenCalledWith(
|
expect(github.mergePr).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ owner: "central-owner", repo: "central-repo", number: 88, method: "squash" }),
|
expect.objectContaining({ owner: "central-owner", repo: "central-repo", number: 88, method: "squash" }),
|
||||||
);
|
);
|
||||||
|
expect(github.getPrMergeStatus).toHaveBeenCalledWith(
|
||||||
|
"central-owner", "central-repo", 88, { requiredCheckNames: ["build"] },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1684,6 +1687,31 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
expect(result).toBe("merged");
|
expect(result).toBe("merged");
|
||||||
expect(github.mergePr).toHaveBeenCalled();
|
expect(github.mergePr).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
it("waits for a configured Fusion check, forwards normalized names, and does not merge", async () => {
|
||||||
|
const task: MockTask = {
|
||||||
|
id: "FN-9103", title: "test", description: "desc", column: "in-review",
|
||||||
|
prInfo: { number: 100, url: "https://github.com/x/y/pull/100", status: "open", headBranch: "fusion/fn-9103", baseBranch: "main" },
|
||||||
|
};
|
||||||
|
const store = makeStore(task, { requiredChecks: [" build ", "build", ""] });
|
||||||
|
const github = {
|
||||||
|
findPrForBranch: vi.fn(), createPr: vi.fn(),
|
||||||
|
getPrMergeStatus: vi.fn(async () => ({
|
||||||
|
prInfo: { ...task.prInfo!, mergeable: "clean" as const }, reviewDecision: null, checks: [],
|
||||||
|
mergeReady: false, blockingReasons: ["required check not reported: build"],
|
||||||
|
})),
|
||||||
|
mergePr: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);
|
||||||
|
|
||||||
|
expect(result).toBe("waiting");
|
||||||
|
expect(github.getPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 100, { requiredCheckNames: ["build"] });
|
||||||
|
expect(github.mergePr).not.toHaveBeenCalled();
|
||||||
|
expect((store as { _updates: Array<{ patch: Record<string, unknown> }> })._updates.at(-1)?.patch).toEqual({ status: "awaiting-pr-checks" });
|
||||||
|
expect((store as { _updates: Array<{ patch: Record<string, unknown> }> })._updates.some(
|
||||||
|
(update) => "mergeRetries" in update.patch,
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
WorkspaceTaskMergeError,
|
WorkspaceTaskMergeError,
|
||||||
acquireWorktreePathReservation,
|
acquireWorktreePathReservation,
|
||||||
type WorktreePathReservation,
|
type WorktreePathReservation,
|
||||||
|
resolveRequiredCheckNames,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
||||||
import { resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "@fusion/core";
|
import { resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "@fusion/core";
|
||||||
@@ -87,7 +88,7 @@ import type {
|
|||||||
interface GitHubOperations {
|
interface GitHubOperations {
|
||||||
findPrForBranch(params: { owner?: string; repo?: string; head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
|
findPrForBranch(params: { owner?: string; repo?: string; head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
|
||||||
createPr(params: { owner?: string; repo?: string; title: string; body: string; head: string; base?: string }): Promise<PrInfo>;
|
createPr(params: { owner?: string; repo?: string; title: string; body: string; head: string; base?: string }): Promise<PrInfo>;
|
||||||
getPrMergeStatus(owner?: string, repo?: string, number?: number): Promise<{
|
getPrMergeStatus(owner?: string, repo?: string, number?: number, options?: { requiredCheckNames?: string[] }): Promise<{
|
||||||
prInfo: PrInfo;
|
prInfo: PrInfo;
|
||||||
reviewDecision: string | null;
|
reviewDecision: string | null;
|
||||||
checks: Array<{ name: string; required: boolean; state: string }>;
|
checks: Array<{ name: string; required: boolean; state: string }>;
|
||||||
@@ -1304,6 +1305,10 @@ export async function processPullRequestMergeTask(
|
|||||||
} catch {
|
} catch {
|
||||||
// Defensive: keep the base settings if effective resolution fails entirely.
|
// Defensive: keep the base settings if effective resolution fails entirely.
|
||||||
}
|
}
|
||||||
|
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
||||||
|
const getPrMergeStatus = (number: number) => requiredCheckNames.length > 0
|
||||||
|
? github.getPrMergeStatus(prRepo.owner, prRepo.repo, number, { requiredCheckNames })
|
||||||
|
: github.getPrMergeStatus(prRepo.owner, prRepo.repo, number);
|
||||||
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
||||||
const projectDefaultBranch = resolvedIntegrationBranch;
|
const projectDefaultBranch = resolvedIntegrationBranch;
|
||||||
|
|
||||||
@@ -1393,7 +1398,7 @@ export async function processPullRequestMergeTask(
|
|||||||
prState: toBranchGroupPrState(groupPrInfo),
|
prState: toBranchGroupPrState(groupPrInfo),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergeStatus = await github.getPrMergeStatus(prRepo.owner, prRepo.repo, groupPrInfo.number);
|
const mergeStatus = await getPrMergeStatus(groupPrInfo.number);
|
||||||
const refreshedPrInfo: PrInfo = {
|
const refreshedPrInfo: PrInfo = {
|
||||||
...groupPrInfo,
|
...groupPrInfo,
|
||||||
...mergeStatus.prInfo,
|
...mergeStatus.prInfo,
|
||||||
@@ -1439,7 +1444,7 @@ export async function processPullRequestMergeTask(
|
|||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
const latestMergeStatus = refreshedHead.refreshed
|
const latestMergeStatus = refreshedHead.refreshed
|
||||||
? await github.getPrMergeStatus(prRepo.owner, prRepo.repo, refreshedPrInfo.number) ?? mergeStatus
|
? await getPrMergeStatus(refreshedPrInfo.number) ?? mergeStatus
|
||||||
: mergeStatus;
|
: mergeStatus;
|
||||||
if (refreshedHead.refreshed) {
|
if (refreshedHead.refreshed) {
|
||||||
await store.updateBranchGroup(branchGroup.id, {
|
await store.updateBranchGroup(branchGroup.id, {
|
||||||
@@ -1543,7 +1548,7 @@ export async function processPullRequestMergeTask(
|
|||||||
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
|
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeStatus = await github.getPrMergeStatus(prRepo.owner, prRepo.repo, prInfo.number);
|
const mergeStatus = await getPrMergeStatus(prInfo.number);
|
||||||
const refreshedPrInfo: PrInfo = {
|
const refreshedPrInfo: PrInfo = {
|
||||||
...prInfo,
|
...prInfo,
|
||||||
...mergeStatus.prInfo,
|
...mergeStatus.prInfo,
|
||||||
@@ -1607,7 +1612,7 @@ export async function processPullRequestMergeTask(
|
|||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
const latestMergeStatus = refreshedHead.refreshed
|
const latestMergeStatus = refreshedHead.refreshed
|
||||||
? await github.getPrMergeStatus(prRepo.owner, prRepo.repo, prInfo.number) ?? mergeStatus
|
? await getPrMergeStatus(prInfo.number) ?? mergeStatus
|
||||||
: mergeStatus;
|
: mergeStatus;
|
||||||
/*
|
/*
|
||||||
FNXC:PullRequestFreshness 2026-08-09-03:02:
|
FNXC:PullRequestFreshness 2026-08-09-03:02:
|
||||||
@@ -1641,7 +1646,7 @@ export async function processPullRequestMergeTask(
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
let refreshedStatus: Awaited<ReturnType<GitHubOperations["getPrMergeStatus"]>>;
|
let refreshedStatus: Awaited<ReturnType<GitHubOperations["getPrMergeStatus"]>>;
|
||||||
try {
|
try {
|
||||||
refreshedStatus = await github.getPrMergeStatus(prRepo.owner, prRepo.repo, prInfo.number);
|
refreshedStatus = await getPrMergeStatus(prInfo.number);
|
||||||
} catch {
|
} catch {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
16
packages/core/src/__tests__/required-checks.test.ts
Normal file
16
packages/core/src/__tests__/required-checks.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isGlobalSettingsKey, isProjectSettingsKey } from "../config/settings-schema.js";
|
||||||
|
import { resolveRequiredCheckNames } from "../config/required-checks.js";
|
||||||
|
|
||||||
|
describe("resolveRequiredCheckNames", () => {
|
||||||
|
it.each([undefined, null, {}, { requiredChecks: [] }, { requiredChecks: ["", " "] }, { requiredChecks: "build" }, { requiredChecks: [1, null] }])("returns no names for %j", (settings) => {
|
||||||
|
expect(resolveRequiredCheckNames(settings as any)).toEqual([]);
|
||||||
|
});
|
||||||
|
it("trims and deduplicates names in first-seen order", () => {
|
||||||
|
expect(resolveRequiredCheckNames({ requiredChecks: ["build", "build ", " ci", "ci"] })).toEqual(["build", "ci"]);
|
||||||
|
});
|
||||||
|
it("registers the setting at project scope only", () => {
|
||||||
|
expect(isProjectSettingsKey("requiredChecks")).toBe(true);
|
||||||
|
expect(isGlobalSettingsKey("requiredChecks")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ export * from "./global-settings.js";
|
|||||||
export * from "./mcp-config.js";
|
export * from "./mcp-config.js";
|
||||||
export * from "./mcp-discovery.js";
|
export * from "./mcp-discovery.js";
|
||||||
export * from "./moved-settings.js";
|
export * from "./moved-settings.js";
|
||||||
|
export * from "./required-checks.js";
|
||||||
export * from "./settings-export.js";
|
export * from "./settings-export.js";
|
||||||
export * from "./settings-schema.js";
|
export * from "./settings-schema.js";
|
||||||
export * from "./settings-validation.js";
|
export * from "./settings-validation.js";
|
||||||
|
|||||||
16
packages/core/src/config/required-checks.ts
Normal file
16
packages/core/src/config/required-checks.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* FNXC:PrMergeRequiredChecks 2026-08-09-06:39:
|
||||||
|
* Fusion-side PR check names are shared by CLI, dashboard, routes, and settings UI.
|
||||||
|
* Keep normalization in core because the CLI deliberately has no dashboard dependency;
|
||||||
|
* empty input preserves GitHub's legacy required-check policy while absent names block.
|
||||||
|
*/
|
||||||
|
export function resolveRequiredCheckNames(settings?: { requiredChecks?: unknown }): string[] {
|
||||||
|
if (!Array.isArray(settings?.requiredChecks)) return [];
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const value of settings.requiredChecks) {
|
||||||
|
if (typeof value !== "string") continue;
|
||||||
|
const name = value.trim();
|
||||||
|
if (name) names.add(name);
|
||||||
|
}
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
@@ -608,6 +608,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
mergeDiffVolumeMinLines: undefined,
|
mergeDiffVolumeMinLines: undefined,
|
||||||
mergeDiffVolumeThreshold: undefined,
|
mergeDiffVolumeThreshold: undefined,
|
||||||
mergeDiffVolumeAllowlist: undefined,
|
mergeDiffVolumeAllowlist: undefined,
|
||||||
|
requiredChecks: undefined,
|
||||||
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
|
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
|
||||||
postMergeAuditMode: "warn",
|
postMergeAuditMode: "warn",
|
||||||
mergeAuditAutoRecovery: "ai-assisted",
|
mergeAuditAutoRecovery: "ai-assisted",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export {
|
|||||||
mergeSupplementalOpenAiCodexModels,
|
mergeSupplementalOpenAiCodexModels,
|
||||||
} from "./ai/openai-models.js";
|
} from "./ai/openai-models.js";
|
||||||
export type { OpenAiCodexProviderRegistration } from "./ai/openai-models.js";
|
export type { OpenAiCodexProviderRegistration } from "./ai/openai-models.js";
|
||||||
|
export { resolveRequiredCheckNames } from "./config/required-checks.js";
|
||||||
export { detectImageMimeFromBytes } from "./i18n/image-mime.js";
|
export { detectImageMimeFromBytes } from "./i18n/image-mime.js";
|
||||||
export type { DetectedImageMime } from "./i18n/image-mime.js";
|
export type { DetectedImageMime } from "./i18n/image-mime.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export {
|
|||||||
export type { GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput } from "./git/gitlab-config.js";
|
export type { GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput } from "./git/gitlab-config.js";
|
||||||
export { validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed } from "./config/settings-validation.js";
|
export { validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed } from "./config/settings-validation.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PrMergeRequiredChecks 2026-08-09-07:48:
|
||||||
|
The dashboard aliases @fusion/core to this browser-safe barrel. Re-export the pure shared normalizer here so Settings cannot fork name-trimming semantics from the CLI and server merge gates.
|
||||||
|
*/
|
||||||
|
export { resolveRequiredCheckNames } from "./config/required-checks.js";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* FNXC:WorkflowDeprecation 2026-07-15-16:35:
|
* FNXC:WorkflowDeprecation 2026-07-15-16:35:
|
||||||
* Keep deprecated IDs browser-safe because Settings loads the management list
|
* Keep deprecated IDs browser-safe because Settings loads the management list
|
||||||
|
|||||||
@@ -1661,6 +1661,12 @@ export interface ProjectSettings {
|
|||||||
mergeDiffVolumeThreshold?: number;
|
mergeDiffVolumeThreshold?: number;
|
||||||
/** Additional file globs allowlisted by the pre-commit diff-volume gate on top of generated/lockfile patterns. Default applied at read site: []. */
|
/** Additional file globs allowlisted by the pre-commit diff-volume gate on top of generated/lockfile patterns. Default applied at read site: []. */
|
||||||
mergeDiffVolumeAllowlist?: string[];
|
mergeDiffVolumeAllowlist?: string[];
|
||||||
|
/**
|
||||||
|
* FNXC:PrMergeRequiredChecks 2026-08-09-06:39:
|
||||||
|
* Fusion honors these names independently of GitHub's isRequired flag. Empty preserves
|
||||||
|
* legacy GitHub-delegated behavior; an absent named check blocks zero-suite PR merges.
|
||||||
|
*/
|
||||||
|
requiredChecks?: string[];
|
||||||
/** Controls overlap protection when `mergeConflictStrategy="smart-prefer-main"`
|
/** Controls overlap protection when `mergeConflictStrategy="smart-prefer-main"`
|
||||||
* reaches its Attempt 3 fallback. Default: "flip-to-prefer-branch". */
|
* reaches its Attempt 3 fallback. Default: "flip-to-prefer-branch". */
|
||||||
mergeStrategyOverlapBehavior?: MergeStrategyOverlapBehavior;
|
mergeStrategyOverlapBehavior?: MergeStrategyOverlapBehavior;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export interface PrInfo {
|
|||||||
status: PrStatus;
|
status: PrStatus;
|
||||||
title: string;
|
title: string;
|
||||||
headBranch: string;
|
headBranch: string;
|
||||||
|
/**
|
||||||
|
* FNXC:DashboardPrMergeGate 2026-08-09-08:26:
|
||||||
|
* Git object ID captured with merge readiness to prevent a push/merge race.
|
||||||
|
*/
|
||||||
|
headOid?: string;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ describe("settings section-keys registry", () => {
|
|||||||
"mergeConflictStrategy",
|
"mergeConflictStrategy",
|
||||||
"mergeIntegrationWorktree",
|
"mergeIntegrationWorktree",
|
||||||
"mergeStrategy",
|
"mergeStrategy",
|
||||||
|
"requiredChecks",
|
||||||
"mergeStrategyOverlapBehavior",
|
"mergeStrategyOverlapBehavior",
|
||||||
"merger",
|
"merger",
|
||||||
"planApprovalMode",
|
"planApprovalMode",
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
|
|||||||
"mergeConflictStrategy",
|
"mergeConflictStrategy",
|
||||||
"mergeIntegrationWorktree",
|
"mergeIntegrationWorktree",
|
||||||
"mergeStrategy",
|
"mergeStrategy",
|
||||||
|
"requiredChecks",
|
||||||
"mergeStrategyOverlapBehavior",
|
"mergeStrategyOverlapBehavior",
|
||||||
"merger",
|
"merger",
|
||||||
"planApprovalMode",
|
"planApprovalMode",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
import type { SettingsSearchEntry } from "../search/types";
|
import type { SettingsSearchEntry } from "../search/types";
|
||||||
|
|
||||||
export const mergeSearchEntries: SettingsSearchEntry[] = [
|
export const mergeSearchEntries: SettingsSearchEntry[] = [
|
||||||
|
{
|
||||||
|
sectionId: "merge", key: "requiredChecks", labelKey: "settings.merge.requiredChecks", labelFallback: "Required pull-request checks", helpKey: "settings.merge.requiredChecksHelp", helpFallback: "No default — unset. Comma-separated check names match GitHub exactly (case-sensitive). Leaving this empty uses GitHub required-status checks only; a named check that never reports blocks the merge.", keywords: ["CI", "status", "GitHub"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
sectionId: "merge",
|
sectionId: "merge",
|
||||||
key: "maxAutoMergeRetries",
|
key: "maxAutoMergeRetries",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Settings } from "@fusion/core";
|
import { resolveRequiredCheckNames, type Settings } from "@fusion/core";
|
||||||
import { fetchGitRemoteBranches } from "../../../api";
|
import { fetchGitRemoteBranches } from "../../../api";
|
||||||
import { MovedSettingsStub } from "./MovedSettingsStub";
|
import { MovedSettingsStub } from "./MovedSettingsStub";
|
||||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||||
@@ -85,6 +85,19 @@ export interface MergeSectionProps extends SectionBaseProps {
|
|||||||
}
|
}
|
||||||
export function MergeSection({ form, setForm, integrationBranchOptions, integrationBranchCustomMode, setIntegrationBranchCustomMode, onOpenWorkflowSettings, gitRemoteOptions = [], projectId, }: MergeSectionProps) {
|
export function MergeSection({ form, setForm, integrationBranchOptions, integrationBranchCustomMode, setIntegrationBranchCustomMode, onOpenWorkflowSettings, gitRemoteOptions = [], projectId, }: MergeSectionProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const [requiredChecksInput, setRequiredChecksInput] = useState(() => (form.requiredChecks ?? []).join(", "));
|
||||||
|
const emittedRequiredCheckNames = useRef(resolveRequiredCheckNames(form));
|
||||||
|
useEffect(() => {
|
||||||
|
const formNames = resolveRequiredCheckNames(form);
|
||||||
|
if (formNames.join("\u0000") !== emittedRequiredCheckNames.current.join("\u0000")) {
|
||||||
|
setRequiredChecksInput(formNames.join(", "));
|
||||||
|
}
|
||||||
|
emittedRequiredCheckNames.current = formNames;
|
||||||
|
}, [form.requiredChecks]);
|
||||||
|
/*
|
||||||
|
FNXC:PrMergeRequiredChecks 2026-08-09-07:46:
|
||||||
|
Keep the raw comma-delimited value while editing. Rebuilding the controlled input from normalized names on every keypress erases a trailing comma, making a second check impossible to type; synchronize only external form changes.
|
||||||
|
*/
|
||||||
const pushTarget = parsePushRemoteSetting(form.pushRemote);
|
const pushTarget = parsePushRemoteSetting(form.pushRemote);
|
||||||
const [pushBranchOptions, setPushBranchOptions] = useState<string[]>([]);
|
const [pushBranchOptions, setPushBranchOptions] = useState<string[]>([]);
|
||||||
const [pushBranchCustomMode, setPushBranchCustomMode] = useState(false);
|
const [pushBranchCustomMode, setPushBranchCustomMode] = useState(false);
|
||||||
@@ -265,6 +278,19 @@ export function MergeSection({ form, setForm, integrationBranchOptions, integrat
|
|||||||
<option value="pull-request">{t("settings.merge.createMonitorAndMergeAGitHubPullRequest", "Create, monitor, and merge a GitHub pull request")}</option>
|
<option value="pull-request">{t("settings.merge.createMonitorAndMergeAGitHubPullRequest", "Create, monitor, and merge a GitHub pull request")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{form.mergeStrategy === "pull-request" && <div className="form-group">
|
||||||
|
<div className="settings-field-label-row">
|
||||||
|
<label htmlFor="requiredChecks">{t("settings.merge.requiredChecks", "Required pull-request checks")}</label>
|
||||||
|
<SettingsHelpTip settingKey="requiredChecks">{t("settings.merge.requiredChecksHelp", "No default — unset. Comma-separated check names match GitHub exactly (case-sensitive). Leaving this empty uses GitHub required-status checks only; a named check that never reports blocks the merge.")}</SettingsHelpTip>
|
||||||
|
</div>
|
||||||
|
<input id="requiredChecks" className="input" value={requiredChecksInput} onChange={(event) => {
|
||||||
|
const rawValue = event.target.value;
|
||||||
|
setRequiredChecksInput(rawValue);
|
||||||
|
const requiredChecks = resolveRequiredCheckNames({ requiredChecks: rawValue.split(",") });
|
||||||
|
emittedRequiredCheckNames.current = requiredChecks;
|
||||||
|
setForm((current) => ({ ...current, requiredChecks: requiredChecks.length > 0 ? requiredChecks : undefined }));
|
||||||
|
}}/>
|
||||||
|
</div>}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="settings-field-label-row">
|
<div className="settings-field-label-row">
|
||||||
<label htmlFor="integrationBranch">{t("settings.merge.integrationBranch", "Integration branch")}</label>
|
<label htmlFor="integrationBranch">{t("settings.merge.integrationBranch", "Integration branch")}</label>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MergeSection } from "../MergeSection";
|
||||||
|
import { mergeSearchEntries } from "../MergeSection.search";
|
||||||
|
import type { MergeSectionProps } from "../MergeSection";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeProps(overrides: Partial<MergeSectionProps["form"]> = {}): MergeSectionProps {
|
||||||
|
return {
|
||||||
|
scopeBanner: null,
|
||||||
|
form: { autoMerge: true, planApprovalMode: "workflow", merger: { mode: "ai" }, testMode: false, mergeStrategy: "pull-request", ...overrides } as MergeSectionProps["form"],
|
||||||
|
setForm: vi.fn(),
|
||||||
|
integrationBranchOptions: ["main"],
|
||||||
|
integrationBranchCustomMode: false,
|
||||||
|
setIntegrationBranchCustomMode: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequiredChecksHarness(): JSX.Element {
|
||||||
|
const [form, setForm] = useState(makeProps().form);
|
||||||
|
return <>
|
||||||
|
<MergeSection {...makeProps()} form={form} setForm={setForm} />
|
||||||
|
<output data-testid="required-checks-value">{JSON.stringify(form.requiredChecks)}</output>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MergeSection requiredChecks", () => {
|
||||||
|
it("round-trips comma-separated check names and clears to undefined", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<RequiredChecksHarness />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Required pull-request checks");
|
||||||
|
await user.type(input, "build, ci");
|
||||||
|
expect(screen.getByTestId("required-checks-value")).toHaveTextContent('["build","ci"]');
|
||||||
|
|
||||||
|
await user.clear(input);
|
||||||
|
expect(screen.getByTestId("required-checks-value")).toHaveTextContent("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty input for an unset setting and registers search copy", () => {
|
||||||
|
render(<MergeSection {...makeProps({ requiredChecks: undefined })} />);
|
||||||
|
expect(screen.getByLabelText("Required pull-request checks")).toHaveValue("");
|
||||||
|
expect(mergeSearchEntries).toContainEqual(expect.objectContaining({ key: "requiredChecks" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,7 @@ function resolveCanonicalDefault(settingKey: string): unknown {
|
|||||||
* English description states that setting's default value.
|
* English description states that setting's default value.
|
||||||
*/
|
*/
|
||||||
const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||||
|
requiredChecks: "merge.requiredChecksHelp",
|
||||||
// AuthenticationSection — Anthropic dual-credential precedence (default api-key)
|
// AuthenticationSection — Anthropic dual-credential precedence (default api-key)
|
||||||
anthropicAuthPreference: "auth.anthropicPreferenceHint",
|
anthropicAuthPreference: "auth.anthropicPreferenceHint",
|
||||||
// GlobalGeneralSection
|
// GlobalGeneralSection
|
||||||
|
|||||||
@@ -1823,6 +1823,94 @@ describe("GitHubClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("requiredChecks transport enforcement", () => {
|
||||||
|
const ghPr = {
|
||||||
|
number: 42, url: "https://github.com/owner/repo/pull/42", title: "Ready PR", state: "OPEN",
|
||||||
|
reviewDecision: null, mergeable: "MERGEABLE", mergeStateStatus: "CLEAN",
|
||||||
|
baseRefName: "main", headRefName: "fusion/fn-8855",
|
||||||
|
};
|
||||||
|
const apiPayload = (nodes: unknown[], hasNextPage = false) => ({
|
||||||
|
data: { repository: { pullRequest: {
|
||||||
|
...ghPr,
|
||||||
|
comments: { totalCount: 0 },
|
||||||
|
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes, pageInfo: { hasNextPage } } } } }] },
|
||||||
|
} } },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed for an absent configured check through the gh transport", async () => {
|
||||||
|
mockRunGhJsonAsync.mockResolvedValueOnce(ghPr).mockResolvedValueOnce([]);
|
||||||
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
||||||
|
|
||||||
|
const result = await ghClient.getPrMergeStatus("owner", "repo", 42, { requiredCheckNames: ["build"] });
|
||||||
|
|
||||||
|
expect(result.mergeReady).toBe(false);
|
||||||
|
expect(result.blockingReasons).toContain("required check not reported: build");
|
||||||
|
expect(mockRunGhJsonAsync).toHaveBeenLastCalledWith(expect.not.arrayContaining(["--required"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the gh unfiltered check read is swallowed", async () => {
|
||||||
|
mockRunGhJsonAsync.mockResolvedValueOnce(ghPr).mockRejectedValueOnce(new Error("checks pending"));
|
||||||
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
||||||
|
|
||||||
|
const result = await ghClient.getPrMergeStatus("owner", "repo", 42, { requiredCheckNames: ["build"] });
|
||||||
|
|
||||||
|
expect(result.mergeReady).toBe(false);
|
||||||
|
expect(result.blockingReasons).toContain("required check not reported: build");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the required-only gh request when no Fusion names are configured", async () => {
|
||||||
|
mockRunGhJsonAsync.mockResolvedValueOnce(ghPr).mockResolvedValueOnce([]);
|
||||||
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
||||||
|
|
||||||
|
const result = await ghClient.getPrMergeStatus("owner", "repo", 42);
|
||||||
|
|
||||||
|
expect(result.mergeReady).toBe(true);
|
||||||
|
expect(mockRunGhJsonAsync).toHaveBeenLastCalledWith(expect.arrayContaining(["--required"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed for an absent configured check through token while default token filtering remains unchanged", async () => {
|
||||||
|
const fetchMock = vi.spyOn(global, "fetch" as any).mockResolvedValueOnce({
|
||||||
|
ok: true, json: async () => apiPayload([]),
|
||||||
|
} as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => apiPayload([{ __typename: "CheckRun", name: "optional", status: "COMPLETED", conclusion: "CANCELLED", isRequired: false }]),
|
||||||
|
} as any);
|
||||||
|
const tokenClient = new GitHubClient({ token: "ghp_token", forceMode: "token" });
|
||||||
|
|
||||||
|
const blocked = await tokenClient.getPrMergeStatus("owner", "repo", 42, { requiredCheckNames: ["build"] });
|
||||||
|
const legacy = await tokenClient.getPrMergeStatus("owner", "repo", 42);
|
||||||
|
|
||||||
|
expect(blocked.mergeReady).toBe(false);
|
||||||
|
expect(blocked.blockingReasons).toContain("required check not reported: build");
|
||||||
|
expect(legacy.mergeReady).toBe(true);
|
||||||
|
expect(legacy.checks).toEqual([]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed for a truncated token check list and preserves names through gh fallback", async () => {
|
||||||
|
const fetchMock = vi.spyOn(global, "fetch" as any).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => apiPayload([], true),
|
||||||
|
} as any);
|
||||||
|
mockRunGhJsonAsync.mockRejectedValueOnce(new Error("gh unavailable"));
|
||||||
|
const fallbackClient = new GitHubClient({ token: "ghp_token", forceMode: undefined });
|
||||||
|
|
||||||
|
const fallback = await fallbackClient.getPrMergeStatus("owner", "repo", 42, { requiredCheckNames: ["build"] });
|
||||||
|
expect(fallback.mergeReady).toBe(false);
|
||||||
|
expect(fallback.blockingReasons).toContain("required check list truncated; cannot confirm: build");
|
||||||
|
|
||||||
|
mockRunGhJsonAsync.mockReset();
|
||||||
|
const tokenClient = new GitHubClient({ token: "ghp_token", forceMode: "token" });
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => apiPayload([{ __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS", isRequired: false }]),
|
||||||
|
} as any);
|
||||||
|
const ready = await tokenClient.getPrMergeStatus("owner", "repo", 42, { requiredCheckNames: ["build"] });
|
||||||
|
expect(ready.mergeReady).toBe(true);
|
||||||
|
expect(ready.checks).toEqual([expect.objectContaining({ name: "build", required: true, state: "success" })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getAllPrChecks", () => {
|
describe("getAllPrChecks", () => {
|
||||||
it("returns required and non-required checks in gh mode and computes rollup from required checks", async () => {
|
it("returns required and non-required checks in gh mode and computes rollup from required checks", async () => {
|
||||||
mockRunGhJsonAsync.mockResolvedValueOnce([
|
mockRunGhJsonAsync.mockResolvedValueOnce([
|
||||||
@@ -2118,6 +2206,22 @@ describe("GitHubClient", () => {
|
|||||||
expect(result.status).toBe("merged");
|
expect(result.status).toBe("merged");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the checked head SHA to GitHub merge", async () => {
|
||||||
|
mockRunGh.mockReturnValue("Merged pull request");
|
||||||
|
mockRunGhJsonAsync.mockResolvedValue({
|
||||||
|
number: 42,
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
title: "Merged PR",
|
||||||
|
state: "MERGED",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "fusion/fn-093",
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.mergePr({ owner: "owner", repo: "repo", number: 42, expectedHeadOid: "checked-sha" });
|
||||||
|
|
||||||
|
expect(mockRunGh).toHaveBeenCalledWith(expect.arrayContaining(["--match-head-commit", "checked-sha"]));
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to REST API merge when gh CLI fails and token is available", async () => {
|
it("falls back to REST API merge when gh CLI fails and token is available", async () => {
|
||||||
mockRunGh.mockImplementation(() => {
|
mockRunGh.mockImplementation(() => {
|
||||||
throw new Error("gh failed");
|
throw new Error("gh failed");
|
||||||
@@ -2180,6 +2284,25 @@ describe("GitHubClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[[], ["build"], false, "required check not reported: build"],
|
||||||
|
[[{ name: "build", required: false, state: "cancelled" }], ["build"], false, "required check not successful: build (cancelled)"],
|
||||||
|
[[{ name: "build", required: false, state: "pending" }], ["build"], false, "required check not successful: build (pending)"],
|
||||||
|
[[{ name: "build", required: false, state: "success" }], ["build"], true, undefined],
|
||||||
|
[[{ name: "build", required: true, state: "skipped" }], ["build"], true, undefined],
|
||||||
|
[[{ name: "build", required: true, state: "neutral" }], ["build"], true, undefined],
|
||||||
|
[[{ name: "build", required: false, state: "success" }, { name: "build", required: false, state: "pending" }], ["build"], false, "required check not successful: build (pending)"],
|
||||||
|
] as const)("applies Fusion required checks", (checks, requiredCheckNames, ready, reason) => {
|
||||||
|
const result = isPrMergeReady({ status: "open", reviewDecision: null, checks: checks as any, mergeable: "clean", requiredCheckNames: requiredCheckNames as string[] });
|
||||||
|
expect(result.ready).toBe(ready);
|
||||||
|
if (reason) expect(result.blockingReasons).toContain(reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed with a distinct reason for truncated named check lists", () => {
|
||||||
|
expect(isPrMergeReady({ status: "open", reviewDecision: null, checks: [], mergeable: "clean", requiredCheckNames: ["build"], checkListTruncated: true }).blockingReasons)
|
||||||
|
.toContain("required check list truncated; cannot confirm: build");
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores optional checks when determining readiness", () => {
|
it("ignores optional checks when determining readiness", () => {
|
||||||
expect(isPrMergeReady({
|
expect(isPrMergeReady({
|
||||||
status: "open",
|
status: "open",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getGhErrorMessage,
|
getGhErrorMessage,
|
||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
runGh,
|
runGh,
|
||||||
|
resolveRequiredCheckNames,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js";
|
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js";
|
||||||
|
|
||||||
@@ -473,6 +474,7 @@ interface GhPrViewJson {
|
|||||||
mergeStateStatus?: GhPrMergeStateStatus;
|
mergeStateStatus?: GhPrMergeStateStatus;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
|
headRefOid?: string;
|
||||||
comments: Array<{
|
comments: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -712,6 +714,7 @@ function toPrInfo(input: {
|
|||||||
title: string;
|
title: string;
|
||||||
status: PrInfo["status"];
|
status: PrInfo["status"];
|
||||||
headBranch: string;
|
headBranch: string;
|
||||||
|
headOid?: string;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
commentCount?: number;
|
commentCount?: number;
|
||||||
@@ -725,6 +728,7 @@ function toPrInfo(input: {
|
|||||||
status: input.status,
|
status: input.status,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
headBranch: input.headBranch,
|
headBranch: input.headBranch,
|
||||||
|
headOid: input.headOid,
|
||||||
baseBranch: input.baseBranch,
|
baseBranch: input.baseBranch,
|
||||||
commentCount: input.commentCount ?? 0,
|
commentCount: input.commentCount ?? 0,
|
||||||
isDraft: input.isDraft,
|
isDraft: input.isDraft,
|
||||||
@@ -744,38 +748,48 @@ export function isPrMergeReady(input: {
|
|||||||
reviewDecision: ReviewDecision;
|
reviewDecision: ReviewDecision;
|
||||||
checks: PrCheckStatus[];
|
checks: PrCheckStatus[];
|
||||||
mergeable: PrConflictState;
|
mergeable: PrConflictState;
|
||||||
|
requiredCheckNames?: string[];
|
||||||
|
checkListTruncated?: boolean;
|
||||||
}): { ready: boolean; blockingReasons: string[] } {
|
}): { ready: boolean; blockingReasons: string[] } {
|
||||||
const blockingReasons: string[] = [];
|
const blockingReasons: string[] = [];
|
||||||
|
|
||||||
if (input.status !== "open") {
|
if (input.status !== "open") blockingReasons.push(`PR is ${input.status}`);
|
||||||
blockingReasons.push(`PR is ${input.status}`);
|
if (input.reviewDecision === "CHANGES_REQUESTED") blockingReasons.push("changes requested review is active");
|
||||||
}
|
if (input.mergeable !== "clean") blockingReasons.push(`PR mergeability is ${input.mergeable}`);
|
||||||
|
|
||||||
if (input.reviewDecision === "CHANGES_REQUESTED") {
|
const satisfies = (state: PrCheckState) => state === "success" || state === "skipped" || state === "neutral";
|
||||||
blockingReasons.push("changes requested review is active");
|
const blockingChecks = input.checks.filter((check) => check.required && !satisfies(check.state));
|
||||||
}
|
|
||||||
|
|
||||||
if (input.mergeable !== "clean") {
|
|
||||||
blockingReasons.push(`PR mergeability is ${input.mergeable}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockingChecks = input.checks.filter(
|
|
||||||
(check) => check.required && check.state !== "success",
|
|
||||||
);
|
|
||||||
if (blockingChecks.length > 0) {
|
if (blockingChecks.length > 0) {
|
||||||
blockingReasons.push(
|
blockingReasons.push(`required checks not successful: ${blockingChecks.map((check) => `${check.name} (${check.state})`).join(", ")}`);
|
||||||
`required checks not successful: ${blockingChecks
|
|
||||||
.map((check) => `${check.name} (${check.state})`)
|
|
||||||
.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const namedChecks = new Set(input.requiredCheckNames ?? []);
|
||||||
ready: blockingReasons.length === 0,
|
/*
|
||||||
blockingReasons,
|
FNXC:PrMergeRequiredChecks 2026-08-09-06:39:
|
||||||
};
|
GitHub accepts skipped and neutral required contexts. Treating path-filtered checks as
|
||||||
|
failures would deadlock PRs, while every other normalized state fails closed.
|
||||||
|
*/
|
||||||
|
for (const name of namedChecks) {
|
||||||
|
const matches = input.checks.filter((check) => check.name === name);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
blockingReasons.push(input.checkListTruncated
|
||||||
|
? `required check list truncated; cannot confirm: ${name}`
|
||||||
|
: `required check not reported: ${name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unsatisfied = matches.find((check) => !satisfies(check.state));
|
||||||
|
if (unsatisfied) {
|
||||||
|
const githubReason = `required checks not successful: ${name} (${unsatisfied.state})`;
|
||||||
|
if (!blockingReasons.includes(githubReason)) {
|
||||||
|
blockingReasons.push(`required check not successful: ${name} (${unsatisfied.state})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ready: blockingReasons.length === 0, blockingReasons: [...new Set(blockingReasons)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PrCheckGateOptions { requiredCheckNames?: string[]; }
|
||||||
|
|
||||||
export interface GitHubClientOptions {
|
export interface GitHubClientOptions {
|
||||||
token?: string;
|
token?: string;
|
||||||
/**
|
/**
|
||||||
@@ -1328,10 +1342,10 @@ export class GitHubClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number): Promise<PrReviewSnapshot> {
|
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number, options?: PrCheckGateOptions): Promise<PrReviewSnapshot> {
|
||||||
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
||||||
const details = await this.getRawPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
const details = await this.getRawPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
||||||
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
|
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number, options);
|
||||||
const checks = mergeStatus.checks;
|
const checks = mergeStatus.checks;
|
||||||
const commentItems: PrReviewStateItem[] = (details.comments ?? []).map((comment) => ({
|
const commentItems: PrReviewStateItem[] = (details.comments ?? []).map((comment) => ({
|
||||||
id: `gh-comment-${comment.id}`,
|
id: `gh-comment-${comment.id}`,
|
||||||
@@ -1378,10 +1392,10 @@ export class GitHubClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrReviewDetails(owner: string | undefined, repo: string | undefined, number: number): Promise<TaskReviewData> {
|
async getPrReviewDetails(owner: string | undefined, repo: string | undefined, number: number, options?: PrCheckGateOptions): Promise<TaskReviewData> {
|
||||||
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
|
||||||
const details = await this.getRawPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
const details = await this.getRawPrReviewDetails(resolvedOwner, resolvedRepo, number);
|
||||||
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number);
|
const mergeStatus = await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number, options);
|
||||||
const fetchedAt = new Date().toISOString();
|
const fetchedAt = new Date().toISOString();
|
||||||
|
|
||||||
const reviewItems: TaskReviewItem[] = (details.reviews ?? []).map((review) => ({
|
const reviewItems: TaskReviewItem[] = (details.reviews ?? []).map((review) => ({
|
||||||
@@ -1769,38 +1783,41 @@ export class GitHubClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
|
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number, options?: PrCheckGateOptions): Promise<PrMergeStatus> {
|
||||||
|
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
|
||||||
if (this.hasGhAuth()) {
|
if (this.hasGhAuth()) {
|
||||||
try {
|
try {
|
||||||
return await this.getPrMergeStatusWithGh(owner, repo, number);
|
return await this.getPrMergeStatusWithGh(owner, repo, number, requiredCheckNames);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
return this.getPrMergeStatusWithApi(owner, repo, number);
|
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames);
|
||||||
}
|
}
|
||||||
throw new Error(getGhErrorMessage(err));
|
throw new Error(getGhErrorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
return this.getPrMergeStatusWithApi(owner, repo, number);
|
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames);
|
||||||
}
|
}
|
||||||
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
|
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPrMergeStatusWithGh(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
|
private async getPrMergeStatusWithGh(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[]): Promise<PrMergeStatus> {
|
||||||
const resolved = this.resolveRepo(owner, repo);
|
const resolved = this.resolveRepo(owner, repo);
|
||||||
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,mergeable,mergeStateStatus",
|
"--json", "number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,reviewDecision,mergeable,mergeStateStatus",
|
||||||
]);
|
]);
|
||||||
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
|
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
|
||||||
const checks = await runGhJsonAsync<GhPrCheckJson[]>([
|
/* FNXC:PrMergeRequiredChecks 2026-08-09-06:39: named checks need the unfiltered list so an absent check blocks; retain the legacy required-only request when unset. */
|
||||||
"pr", "checks", String(number),
|
const namedSet = new Set(requiredCheckNames);
|
||||||
"--repo", `${resolved.owner}/${resolved.repo}`,
|
const checks = requiredCheckNames.length > 0
|
||||||
"--required",
|
? await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames).then((result) => result.checks).catch(() => [])
|
||||||
"--json", "name,state,link,startedAt,completedAt",
|
: await runGhJsonAsync<GhPrCheckJson[]>([
|
||||||
]).catch(() => []);
|
"pr", "checks", String(number), "--repo", `${resolved.owner}/${resolved.repo}`,
|
||||||
|
"--required", "--json", "name,state,link,startedAt,completedAt",
|
||||||
|
]).catch(() => []);
|
||||||
|
|
||||||
const prInfo = toPrInfo({
|
const prInfo = toPrInfo({
|
||||||
url: pr.url,
|
url: pr.url,
|
||||||
@@ -1808,6 +1825,7 @@ export class GitHubClient {
|
|||||||
status: this.mapGhPrState(pr.state),
|
status: this.mapGhPrState(pr.state),
|
||||||
title: pr.title,
|
title: pr.title,
|
||||||
headBranch: pr.headRefName,
|
headBranch: pr.headRefName,
|
||||||
|
headOid: pr.headRefOid,
|
||||||
baseBranch: pr.baseRefName,
|
baseBranch: pr.baseRefName,
|
||||||
isDraft: pr.isDraft,
|
isDraft: pr.isDraft,
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
@@ -1815,17 +1833,18 @@ export class GitHubClient {
|
|||||||
});
|
});
|
||||||
const normalizedChecks = checks.map((check) => ({
|
const normalizedChecks = checks.map((check) => ({
|
||||||
name: check.name,
|
name: check.name,
|
||||||
required: true,
|
required: requiredCheckNames.length === 0 ? true : (check as PrCheckStatus).required || ((check as GhPrCheckJson).bucket ? (check as GhPrCheckJson).bucket !== "none" : false) || namedSet.has(check.name),
|
||||||
state: normalizeCheckState(check.state),
|
state: normalizeCheckState(check.state),
|
||||||
detailsUrl: check.link,
|
detailsUrl: (check as GhPrCheckJson).link,
|
||||||
startedAt: check.startedAt,
|
startedAt: (check as GhPrCheckJson).startedAt,
|
||||||
completedAt: check.completedAt,
|
completedAt: (check as GhPrCheckJson).completedAt,
|
||||||
} satisfies PrCheckStatus));
|
} satisfies PrCheckStatus));
|
||||||
const readiness = isPrMergeReady({
|
const readiness = isPrMergeReady({
|
||||||
status: prInfo.status,
|
status: prInfo.status,
|
||||||
reviewDecision: pr.reviewDecision ?? null,
|
reviewDecision: pr.reviewDecision ?? null,
|
||||||
checks: normalizedChecks,
|
checks: normalizedChecks,
|
||||||
mergeable,
|
mergeable,
|
||||||
|
requiredCheckNames,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1838,7 +1857,7 @@ export class GitHubClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPrMergeStatusWithApi(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
|
private async getPrMergeStatusWithApi(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[]): Promise<PrMergeStatus> {
|
||||||
const resolved = this.resolveRepo(owner, repo);
|
const resolved = this.resolveRepo(owner, repo);
|
||||||
const response = await fetch(`${this.baseUrl}/graphql`, {
|
const response = await fetch(`${this.baseUrl}/graphql`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1857,12 +1876,14 @@ export class GitHubClient {
|
|||||||
isDraft
|
isDraft
|
||||||
baseRefName
|
baseRefName
|
||||||
headRefName
|
headRefName
|
||||||
|
headRefOid
|
||||||
comments { totalCount }
|
comments { totalCount }
|
||||||
commits(last: 1) {
|
commits(last: 1) {
|
||||||
nodes {
|
nodes {
|
||||||
commit {
|
commit {
|
||||||
statusCheckRollup {
|
statusCheckRollup {
|
||||||
contexts(first: 100) {
|
contexts(first: 100) {
|
||||||
|
pageInfo { hasNextPage }
|
||||||
nodes {
|
nodes {
|
||||||
__typename
|
__typename
|
||||||
... on CheckRun {
|
... on CheckRun {
|
||||||
@@ -1907,12 +1928,14 @@ export class GitHubClient {
|
|||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
|
headRefOid?: string | null;
|
||||||
comments: { totalCount: number };
|
comments: { totalCount: number };
|
||||||
commits: {
|
commits: {
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
commit: {
|
commit: {
|
||||||
statusCheckRollup?: {
|
statusCheckRollup?: {
|
||||||
contexts?: {
|
contexts?: {
|
||||||
|
pageInfo?: { hasNextPage?: boolean };
|
||||||
nodes?: Array<
|
nodes?: Array<
|
||||||
| {
|
| {
|
||||||
__typename: "CheckRun";
|
__typename: "CheckRun";
|
||||||
@@ -1948,13 +1971,16 @@ export class GitHubClient {
|
|||||||
throw new Error(`PR #${number} not found in ${resolved.owner}/${resolved.repo}`);
|
throw new Error(`PR #${number} not found in ${resolved.owner}/${resolved.repo}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts?.nodes ?? [];
|
const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
||||||
|
const nodes = contexts?.nodes ?? [];
|
||||||
|
const namedSet = new Set(requiredCheckNames);
|
||||||
|
/* FNXC:PrMergeRequiredChecks 2026-08-09-06:39: conditional unfiltered GraphQL reads let absent configured checks fail closed without changing default payload behavior. */
|
||||||
const checks = nodes.flatMap((node) => {
|
const checks = nodes.flatMap((node) => {
|
||||||
if (!node || !node.isRequired) return [];
|
if (!node) return [];
|
||||||
if (node.__typename === "CheckRun") {
|
if (node.__typename === "CheckRun") {
|
||||||
return [{
|
return [{
|
||||||
name: node.name,
|
name: node.name,
|
||||||
required: true,
|
required: Boolean(node.isRequired) || namedSet.has(node.name),
|
||||||
state: normalizeCheckState(node.conclusion ?? node.status),
|
state: normalizeCheckState(node.conclusion ?? node.status),
|
||||||
detailsUrl: node.detailsUrl ?? undefined,
|
detailsUrl: node.detailsUrl ?? undefined,
|
||||||
startedAt: node.startedAt ?? undefined,
|
startedAt: node.startedAt ?? undefined,
|
||||||
@@ -1963,12 +1989,13 @@ export class GitHubClient {
|
|||||||
}
|
}
|
||||||
return [{
|
return [{
|
||||||
name: node.context,
|
name: node.context,
|
||||||
required: true,
|
required: Boolean(node.isRequired) || namedSet.has(node.context),
|
||||||
state: normalizeCheckState(node.state),
|
state: normalizeCheckState(node.state),
|
||||||
detailsUrl: node.targetUrl ?? undefined,
|
detailsUrl: node.targetUrl ?? undefined,
|
||||||
} satisfies PrCheckStatus];
|
} satisfies PrCheckStatus];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gateChecks = checks.filter((check) => check.required);
|
||||||
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
|
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
|
||||||
const prInfo = toPrInfo({
|
const prInfo = toPrInfo({
|
||||||
url: pr.url,
|
url: pr.url,
|
||||||
@@ -1976,6 +2003,7 @@ export class GitHubClient {
|
|||||||
status: this.mapGhPrState(pr.state),
|
status: this.mapGhPrState(pr.state),
|
||||||
title: pr.title,
|
title: pr.title,
|
||||||
headBranch: pr.headRefName,
|
headBranch: pr.headRefName,
|
||||||
|
headOid: pr.headRefOid ?? undefined,
|
||||||
baseBranch: pr.baseRefName,
|
baseBranch: pr.baseRefName,
|
||||||
isDraft: pr.isDraft,
|
isDraft: pr.isDraft,
|
||||||
commentCount: pr.comments.totalCount,
|
commentCount: pr.comments.totalCount,
|
||||||
@@ -1984,14 +2012,16 @@ export class GitHubClient {
|
|||||||
const readiness = isPrMergeReady({
|
const readiness = isPrMergeReady({
|
||||||
status: prInfo.status,
|
status: prInfo.status,
|
||||||
reviewDecision: pr.reviewDecision,
|
reviewDecision: pr.reviewDecision,
|
||||||
checks,
|
checks: gateChecks,
|
||||||
mergeable,
|
mergeable,
|
||||||
|
requiredCheckNames,
|
||||||
|
checkListTruncated: Boolean(contexts?.pageInfo?.hasNextPage) && requiredCheckNames.some((name) => !gateChecks.some((check) => check.name === name)),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prInfo,
|
prInfo,
|
||||||
reviewDecision: pr.reviewDecision,
|
reviewDecision: pr.reviewDecision,
|
||||||
checks,
|
checks: gateChecks,
|
||||||
mergeable,
|
mergeable,
|
||||||
mergeReady: readiness.ready,
|
mergeReady: readiness.ready,
|
||||||
blockingReasons: readiness.blockingReasons,
|
blockingReasons: readiness.blockingReasons,
|
||||||
@@ -2002,20 +2032,22 @@ export class GitHubClient {
|
|||||||
owner: string | undefined,
|
owner: string | undefined,
|
||||||
repo: string | undefined,
|
repo: string | undefined,
|
||||||
number: number,
|
number: number,
|
||||||
|
options?: PrCheckGateOptions,
|
||||||
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
||||||
|
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
|
||||||
if (this.hasGhAuth()) {
|
if (this.hasGhAuth()) {
|
||||||
try {
|
try {
|
||||||
return await this.getAllPrChecksWithGh(owner, repo, number);
|
return await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
return this.getAllPrChecksWithApi(owner, repo, number);
|
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames);
|
||||||
}
|
}
|
||||||
throw new Error(getGhErrorMessage(err));
|
throw new Error(getGhErrorMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
return this.getAllPrChecksWithApi(owner, repo, number);
|
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames);
|
||||||
}
|
}
|
||||||
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
|
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
|
||||||
}
|
}
|
||||||
@@ -2038,6 +2070,7 @@ export class GitHubClient {
|
|||||||
owner: string | undefined,
|
owner: string | undefined,
|
||||||
repo: string | undefined,
|
repo: string | undefined,
|
||||||
number: number,
|
number: number,
|
||||||
|
requiredCheckNames: string[] = [],
|
||||||
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
||||||
const resolved = this.resolveRepo(owner, repo);
|
const resolved = this.resolveRepo(owner, repo);
|
||||||
|
|
||||||
@@ -2062,9 +2095,11 @@ export class GitHubClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
checks = checks ?? [];
|
checks = checks ?? [];
|
||||||
|
const namedSet = new Set(requiredCheckNames);
|
||||||
|
/* FNXC:PrMergeRequiredChecks 2026-08-09-06:39: gh bucket is only a heuristic; configured names are required by exact name, never by bucket inference. */
|
||||||
const normalized = checks.map((check) => ({
|
const normalized = checks.map((check) => ({
|
||||||
name: check.name,
|
name: check.name,
|
||||||
required: check.bucket ? check.bucket !== "none" : false,
|
required: (check.bucket ? check.bucket !== "none" : false) || namedSet.has(check.name),
|
||||||
state: normalizeCheckState(check.state),
|
state: normalizeCheckState(check.state),
|
||||||
detailsUrl: check.link,
|
detailsUrl: check.link,
|
||||||
startedAt: check.startedAt,
|
startedAt: check.startedAt,
|
||||||
@@ -2081,6 +2116,7 @@ export class GitHubClient {
|
|||||||
owner: string | undefined,
|
owner: string | undefined,
|
||||||
repo: string | undefined,
|
repo: string | undefined,
|
||||||
number: number,
|
number: number,
|
||||||
|
requiredCheckNames: string[] = [],
|
||||||
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
|
||||||
const resolved = this.resolveRepo(owner, repo);
|
const resolved = this.resolveRepo(owner, repo);
|
||||||
const response = await fetch(`${this.baseUrl}/graphql`, {
|
const response = await fetch(`${this.baseUrl}/graphql`, {
|
||||||
@@ -2165,12 +2201,13 @@ export class GitHubClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nodes = payload.data?.repository?.pullRequest?.commits.nodes[0]?.commit.statusCheckRollup?.contexts?.nodes ?? [];
|
const nodes = payload.data?.repository?.pullRequest?.commits.nodes[0]?.commit.statusCheckRollup?.contexts?.nodes ?? [];
|
||||||
|
const namedSet = new Set(requiredCheckNames);
|
||||||
const checks = nodes.flatMap((node) => {
|
const checks = nodes.flatMap((node) => {
|
||||||
if (!node) return [];
|
if (!node) return [];
|
||||||
if (node.__typename === "CheckRun") {
|
if (node.__typename === "CheckRun") {
|
||||||
return [{
|
return [{
|
||||||
name: node.name,
|
name: node.name,
|
||||||
required: Boolean(node.isRequired),
|
required: Boolean(node.isRequired) || namedSet.has(node.name),
|
||||||
state: normalizeCheckState(node.conclusion ?? node.status),
|
state: normalizeCheckState(node.conclusion ?? node.status),
|
||||||
detailsUrl: node.detailsUrl ?? undefined,
|
detailsUrl: node.detailsUrl ?? undefined,
|
||||||
startedAt: node.startedAt ?? undefined,
|
startedAt: node.startedAt ?? undefined,
|
||||||
@@ -2180,7 +2217,7 @@ export class GitHubClient {
|
|||||||
|
|
||||||
return [{
|
return [{
|
||||||
name: node.context,
|
name: node.context,
|
||||||
required: Boolean(node.isRequired),
|
required: Boolean(node.isRequired) || namedSet.has(node.context),
|
||||||
state: normalizeCheckState(node.state),
|
state: normalizeCheckState(node.state),
|
||||||
detailsUrl: node.targetUrl ?? undefined,
|
detailsUrl: node.targetUrl ?? undefined,
|
||||||
} satisfies PrCheckStatus];
|
} satisfies PrCheckStatus];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createLogger, resolveWorkflowIrForTask, resolveReviewColumns, resolveReboundTarget } from "@fusion/core";
|
import { createLogger, resolveRequiredCheckNames, resolveWorkflowIrForTask, resolveReviewColumns, resolveReboundTarget } from "@fusion/core";
|
||||||
|
|
||||||
const severityAuditLog = createLogger("dashboard-register-git-github");
|
const severityAuditLog = createLogger("dashboard-register-git-github");
|
||||||
import { type NextFunction, type Request, type Response } from "express";
|
import { type NextFunction, type Request, type Response } from "express";
|
||||||
@@ -2375,9 +2375,29 @@ async function mergeTaskPr(
|
|||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
const method = resolvePrMergeMethod(settings, task.prInfo, explicitMethod);
|
const method = resolvePrMergeMethod(settings, task.prInfo, explicitMethod);
|
||||||
const client = new GitHubClient(token);
|
const client = new GitHubClient(token);
|
||||||
|
const requiredCheckNames = resolveRequiredCheckNames(settings);
|
||||||
|
const mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number, { requiredCheckNames });
|
||||||
|
if (!mergeStatus.mergeReady) {
|
||||||
|
throw conflict(`PR cannot merge: ${mergeStatus.blockingReasons.join("; ")}`);
|
||||||
|
}
|
||||||
|
if (!mergeStatus.prInfo.headOid) {
|
||||||
|
throw conflict("PR cannot merge: GitHub did not provide a head commit ID for the checked PR");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DashboardPrMergeGate 2026-08-09-08:26:
|
||||||
|
A dashboard-requested merge must apply the same configured check policy as automatic
|
||||||
|
merging, then bind GitHub's merge operation to the checked head SHA. This prevents a
|
||||||
|
push after readiness evaluation from merging an unchecked revision without branch protection.
|
||||||
|
*/
|
||||||
try {
|
try {
|
||||||
const mergedPrInfo = await client.mergePr({ owner: repo.owner, repo: repo.repo, number: task.prInfo.number, method });
|
const mergedPrInfo = await client.mergePr({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
number: task.prInfo.number,
|
||||||
|
method,
|
||||||
|
expectedHeadOid: mergeStatus.prInfo.headOid,
|
||||||
|
});
|
||||||
const updated = {
|
const updated = {
|
||||||
...task.prInfo,
|
...task.prInfo,
|
||||||
...mergedPrInfo,
|
...mergedPrInfo,
|
||||||
@@ -2397,7 +2417,7 @@ async function mergeTaskPr(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
let mergeStatus: Awaited<ReturnType<GitHubClient["getPrMergeStatus"]>> | undefined;
|
let mergeStatus: Awaited<ReturnType<GitHubClient["getPrMergeStatus"]>> | undefined;
|
||||||
try {
|
try {
|
||||||
mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number);
|
mergeStatus = await client.getPrMergeStatus(repo.owner, repo.repo, task.prInfo.number, { requiredCheckNames });
|
||||||
} catch {
|
} catch {
|
||||||
// A refresh failure cannot invent GitHub state; retain the original command diagnosis.
|
// A refresh failure cannot invent GitHub state; retain the original command diagnosis.
|
||||||
}
|
}
|
||||||
@@ -2498,8 +2518,8 @@ export async function refreshPrInBackground(
|
|||||||
const taskPrs = task ? getTaskPrList(task) : currentPrInfos;
|
const taskPrs = task ? getTaskPrList(task) : currentPrInfos;
|
||||||
|
|
||||||
for (const currentPrInfo of taskPrs) {
|
for (const currentPrInfo of taskPrs) {
|
||||||
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number);
|
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await store.getSettings()) });
|
||||||
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number);
|
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await store.getSettings()) });
|
||||||
const prior = getTaskPrList(task).find((entry) => entry.number === currentPrInfo.number) ?? currentPrInfo;
|
const prior = getTaskPrList(task).find((entry) => entry.number === currentPrInfo.number) ?? currentPrInfo;
|
||||||
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
||||||
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
||||||
@@ -5978,8 +5998,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
for (let i = 0; i < prList.length; i += batchSize) {
|
for (let i = 0; i < prList.length; i += batchSize) {
|
||||||
const batch = prList.slice(i, i + batchSize);
|
const batch = prList.slice(i, i + batchSize);
|
||||||
const results = await Promise.all(batch.map(async (priorPr) => {
|
const results = await Promise.all(batch.map(async (priorPr) => {
|
||||||
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, priorPr.number);
|
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, priorPr.number, { requiredCheckNames: resolveRequiredCheckNames(settings) });
|
||||||
const mergeStatus = await client.getPrMergeStatus(owner, repo, priorPr.number);
|
const mergeStatus = await client.getPrMergeStatus(owner, repo, priorPr.number, { requiredCheckNames: resolveRequiredCheckNames(settings) });
|
||||||
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
||||||
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
||||||
try {
|
try {
|
||||||
@@ -6248,7 +6268,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new GitHubClient();
|
const client = new GitHubClient();
|
||||||
const snapshot = await client.getPrReviewSnapshot(owner, repo, primaryPr.number);
|
const snapshot = await client.getPrReviewSnapshot(owner, repo, primaryPr.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
|
||||||
const fusionThread = (task.comments ?? []).filter((comment) =>
|
const fusionThread = (task.comments ?? []).filter((comment) =>
|
||||||
comment.source === "github-review" || comment.source === "github-review-comment"
|
comment.source === "github-review" || comment.source === "github-review-comment"
|
||||||
);
|
);
|
||||||
@@ -6322,7 +6342,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new GitHubClient();
|
const client = new GitHubClient();
|
||||||
const checksResult = await client.getAllPrChecks(owner, repo, primaryPr.number);
|
const checksResult = await client.getAllPrChecks(owner, repo, primaryPr.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
checks: checksResult.checks,
|
checks: checksResult.checks,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createLogger } from "@fusion/core";
|
import { createLogger, resolveRequiredCheckNames } from "@fusion/core";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
const severityAuditLog = createLogger("dashboard-register-task-workflow-routes");
|
const severityAuditLog = createLogger("dashboard-register-task-workflow-routes");
|
||||||
@@ -6484,7 +6484,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw badRequest("Could not determine GitHub repository for PR review fetch");
|
throw badRequest("Could not determine GitHub repository for PR review fetch");
|
||||||
}
|
}
|
||||||
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number);
|
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
|
||||||
} else {
|
} else {
|
||||||
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
}
|
}
|
||||||
@@ -6512,7 +6512,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw badRequest("Could not determine GitHub repository for PR review refresh");
|
throw badRequest("Could not determine GitHub repository for PR review refresh");
|
||||||
}
|
}
|
||||||
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number);
|
reviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
|
||||||
} else {
|
} else {
|
||||||
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
reviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
}
|
}
|
||||||
@@ -6561,7 +6561,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw badRequest("Could not determine GitHub repository for PR review fetch");
|
throw badRequest("Could not determine GitHub repository for PR review fetch");
|
||||||
}
|
}
|
||||||
canonicalReviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number);
|
canonicalReviewData = await new GitHubClient(options?.githubToken ?? process.env.GITHUB_TOKEN).getPrReviewDetails(owner, repo, task.prInfo.number, { requiredCheckNames: resolveRequiredCheckNames(await scopedStore.getSettings()) });
|
||||||
} else {
|
} else {
|
||||||
canonicalReviewData = await buildDirectTaskReviewData(task, scopedStore);
|
canonicalReviewData = await buildDirectTaskReviewData(task, scopedStore);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6375,7 +6375,9 @@
|
|||||||
"gitRemoteThatMergedResultsArePushedTo": "Git remote that merged results are pushed to. Default: \"origin\".",
|
"gitRemoteThatMergedResultsArePushedTo": "Git remote that merged results are pushed to. Default: \"origin\".",
|
||||||
"pushTargetBranch": "Push target branch",
|
"pushTargetBranch": "Push target branch",
|
||||||
"sameAsIntegrationBranchDefault": "(same as integration branch — default)",
|
"sameAsIntegrationBranchDefault": "(same as integration branch — default)",
|
||||||
"pushTargetBranchHelp": "Branch on the remote that merged results are pushed to. Leave on the default to push the integration branch to its same-named remote branch; pick a listed remote branch or choose Custom… to type one that doesn't exist on the remote yet (the push creates it)."
|
"pushTargetBranchHelp": "Branch on the remote that merged results are pushed to. Leave on the default to push the integration branch to its same-named remote branch; pick a listed remote branch or choose Custom… to type one that doesn't exist on the remote yet (the push creates it).",
|
||||||
|
"requiredChecks": "Required pull-request checks",
|
||||||
|
"requiredChecksHelp": "No default — unset. Comma-separated check names match GitHub exactly (case-sensitive). Leaving this empty uses GitHub required-status checks only; a named check that never reports blocks the merge."
|
||||||
},
|
},
|
||||||
"mergeManually": "Merge Manually",
|
"mergeManually": "Merge Manually",
|
||||||
"mobileNav": {
|
"mobileNav": {
|
||||||
|
|||||||
Reference in New Issue
Block a user