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:
gsxdsm
2026-08-09 01:33:06 -07:00
parent 2809fcb608
commit d59c1b162f
24 changed files with 441 additions and 79 deletions

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

View File

@@ -2415,3 +2415,8 @@ The Review tab shows a custom workflow result only when its selected workflow de
### 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.
### 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.

View File

@@ -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. |
| `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). |
| `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"`. |
| `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. |

View File

@@ -273,7 +273,7 @@ describe("processPullRequestMergeTask", () => {
column: "in-review",
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({
id: "BG-4",
sourceType: "planning",
@@ -333,6 +333,9 @@ describe("processPullRequestMergeTask", () => {
expect(github.mergePr).toHaveBeenCalledWith(
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(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);
});
});
});

View File

@@ -39,6 +39,7 @@ import {
WorkspaceTaskMergeError,
acquireWorktreePathReservation,
type WorktreePathReservation,
resolveRequiredCheckNames,
} from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
import { resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "@fusion/core";
@@ -87,7 +88,7 @@ import type {
interface GitHubOperations {
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>;
getPrMergeStatus(owner?: string, repo?: string, number?: number): Promise<{
getPrMergeStatus(owner?: string, repo?: string, number?: number, options?: { requiredCheckNames?: string[] }): Promise<{
prInfo: PrInfo;
reviewDecision: string | null;
checks: Array<{ name: string; required: boolean; state: string }>;
@@ -1304,6 +1305,10 @@ export async function processPullRequestMergeTask(
} catch {
// 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 projectDefaultBranch = resolvedIntegrationBranch;
@@ -1393,7 +1398,7 @@ export async function processPullRequestMergeTask(
prState: toBranchGroupPrState(groupPrInfo),
});
const mergeStatus = await github.getPrMergeStatus(prRepo.owner, prRepo.repo, groupPrInfo.number);
const mergeStatus = await getPrMergeStatus(groupPrInfo.number);
const refreshedPrInfo: PrInfo = {
...groupPrInfo,
...mergeStatus.prInfo,
@@ -1439,7 +1444,7 @@ export async function processPullRequestMergeTask(
signal,
});
const latestMergeStatus = refreshedHead.refreshed
? await github.getPrMergeStatus(prRepo.owner, prRepo.repo, refreshedPrInfo.number) ?? mergeStatus
? await getPrMergeStatus(refreshedPrInfo.number) ?? mergeStatus
: mergeStatus;
if (refreshedHead.refreshed) {
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}`);
}
const mergeStatus = await github.getPrMergeStatus(prRepo.owner, prRepo.repo, prInfo.number);
const mergeStatus = await getPrMergeStatus(prInfo.number);
const refreshedPrInfo: PrInfo = {
...prInfo,
...mergeStatus.prInfo,
@@ -1607,7 +1612,7 @@ export async function processPullRequestMergeTask(
signal,
});
const latestMergeStatus = refreshedHead.refreshed
? await github.getPrMergeStatus(prRepo.owner, prRepo.repo, prInfo.number) ?? mergeStatus
? await getPrMergeStatus(prInfo.number) ?? mergeStatus
: mergeStatus;
/*
FNXC:PullRequestFreshness 2026-08-09-03:02:
@@ -1641,7 +1646,7 @@ export async function processPullRequestMergeTask(
} catch (err: unknown) {
let refreshedStatus: Awaited<ReturnType<GitHubOperations["getPrMergeStatus"]>>;
try {
refreshedStatus = await github.getPrMergeStatus(prRepo.owner, prRepo.repo, prInfo.number);
refreshedStatus = await getPrMergeStatus(prInfo.number);
} catch {
throw err;
}

View 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);
});
});

View File

@@ -9,6 +9,7 @@ export * from "./global-settings.js";
export * from "./mcp-config.js";
export * from "./mcp-discovery.js";
export * from "./moved-settings.js";
export * from "./required-checks.js";
export * from "./settings-export.js";
export * from "./settings-schema.js";
export * from "./settings-validation.js";

View 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];
}

View File

@@ -608,6 +608,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
mergeDiffVolumeMinLines: undefined,
mergeDiffVolumeThreshold: undefined,
mergeDiffVolumeAllowlist: undefined,
requiredChecks: undefined,
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
postMergeAuditMode: "warn",
mergeAuditAutoRecovery: "ai-assisted",

View File

@@ -77,6 +77,7 @@ export {
mergeSupplementalOpenAiCodexModels,
} 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 type { DetectedImageMime } from "./i18n/image-mime.js";
export {

View File

@@ -40,6 +40,12 @@ export {
export type { GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput } from "./git/gitlab-config.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:
* Keep deprecated IDs browser-safe because Settings loads the management list

View File

@@ -1661,6 +1661,12 @@ export interface ProjectSettings {
mergeDiffVolumeThreshold?: number;
/** Additional file globs allowlisted by the pre-commit diff-volume gate on top of generated/lockfile patterns. Default applied at read site: []. */
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"`
* reaches its Attempt 3 fallback. Default: "flip-to-prefer-branch". */
mergeStrategyOverlapBehavior?: MergeStrategyOverlapBehavior;

View File

@@ -18,6 +18,11 @@ export interface PrInfo {
status: PrStatus;
title: 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;
commentCount: number;
isDraft?: boolean;

View File

@@ -143,6 +143,7 @@ describe("settings section-keys registry", () => {
"mergeConflictStrategy",
"mergeIntegrationWorktree",
"mergeStrategy",
"requiredChecks",
"mergeStrategyOverlapBehavior",
"merger",
"planApprovalMode",

View File

@@ -171,6 +171,7 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
"mergeConflictStrategy",
"mergeIntegrationWorktree",
"mergeStrategy",
"requiredChecks",
"mergeStrategyOverlapBehavior",
"merger",
"planApprovalMode",

View File

@@ -11,6 +11,9 @@
import type { SettingsSearchEntry } from "../search/types";
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",
key: "maxAutoMergeRetries",

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Settings } from "@fusion/core";
import { resolveRequiredCheckNames, type Settings } from "@fusion/core";
import { fetchGitRemoteBranches } from "../../../api";
import { MovedSettingsStub } from "./MovedSettingsStub";
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) {
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 [pushBranchOptions, setPushBranchOptions] = useState<string[]>([]);
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>
</select>
</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="settings-field-label-row">
<label htmlFor="integrationBranch">{t("settings.merge.integrationBranch", "Integration branch")}</label>

View File

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

View File

@@ -67,6 +67,7 @@ function resolveCanonicalDefault(settingKey: string): unknown {
* English description states that setting's default value.
*/
const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
requiredChecks: "merge.requiredChecksHelp",
// AuthenticationSection — Anthropic dual-credential precedence (default api-key)
anthropicAuthPreference: "auth.anthropicPreferenceHint",
// GlobalGeneralSection

View File

@@ -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", () => {
it("returns required and non-required checks in gh mode and computes rollup from required checks", async () => {
mockRunGhJsonAsync.mockResolvedValueOnce([
@@ -2118,6 +2206,22 @@ describe("GitHubClient", () => {
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 () => {
mockRunGh.mockImplementation(() => {
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", () => {
expect(isPrMergeReady({
status: "open",

View File

@@ -12,6 +12,7 @@ import {
getGhErrorMessage,
getCurrentRepo,
runGh,
resolveRequiredCheckNames,
} from "@fusion/core";
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js";
@@ -473,6 +474,7 @@ interface GhPrViewJson {
mergeStateStatus?: GhPrMergeStateStatus;
baseRefName: string;
headRefName: string;
headRefOid?: string;
comments: Array<{
id: string;
body: string;
@@ -712,6 +714,7 @@ function toPrInfo(input: {
title: string;
status: PrInfo["status"];
headBranch: string;
headOid?: string;
baseBranch: string;
isDraft?: boolean;
commentCount?: number;
@@ -725,6 +728,7 @@ function toPrInfo(input: {
status: input.status,
title: input.title,
headBranch: input.headBranch,
headOid: input.headOid,
baseBranch: input.baseBranch,
commentCount: input.commentCount ?? 0,
isDraft: input.isDraft,
@@ -744,38 +748,48 @@ export function isPrMergeReady(input: {
reviewDecision: ReviewDecision;
checks: PrCheckStatus[];
mergeable: PrConflictState;
requiredCheckNames?: string[];
checkListTruncated?: boolean;
}): { ready: boolean; blockingReasons: string[] } {
const blockingReasons: string[] = [];
if (input.status !== "open") {
blockingReasons.push(`PR is ${input.status}`);
}
if (input.status !== "open") 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") {
blockingReasons.push("changes requested review is active");
}
if (input.mergeable !== "clean") {
blockingReasons.push(`PR mergeability is ${input.mergeable}`);
}
const blockingChecks = input.checks.filter(
(check) => check.required && check.state !== "success",
);
const satisfies = (state: PrCheckState) => state === "success" || state === "skipped" || state === "neutral";
const blockingChecks = input.checks.filter((check) => check.required && !satisfies(check.state));
if (blockingChecks.length > 0) {
blockingReasons.push(
`required checks not successful: ${blockingChecks
.map((check) => `${check.name} (${check.state})`)
.join(", ")}`,
);
blockingReasons.push(`required checks not successful: ${blockingChecks.map((check) => `${check.name} (${check.state})`).join(", ")}`);
}
return {
ready: blockingReasons.length === 0,
blockingReasons,
};
const namedChecks = new Set(input.requiredCheckNames ?? []);
/*
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 {
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 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 commentItems: PrReviewStateItem[] = (details.comments ?? []).map((comment) => ({
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 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 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()) {
try {
return await this.getPrMergeStatusWithGh(owner, repo, number);
return await this.getPrMergeStatusWithGh(owner, repo, number, requiredCheckNames);
} catch (err) {
if (this.token) {
return this.getPrMergeStatusWithApi(owner, repo, number);
return this.getPrMergeStatusWithApi(owner, repo, number, requiredCheckNames);
}
throw new Error(getGhErrorMessage(err));
}
}
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.");
}
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 pr = await runGhJsonAsync<GhPrViewJson>([
"pr", "view", String(number),
"--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 checks = await runGhJsonAsync<GhPrCheckJson[]>([
"pr", "checks", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
"--required",
"--json", "name,state,link,startedAt,completedAt",
]).catch(() => []);
/* 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. */
const namedSet = new Set(requiredCheckNames);
const checks = requiredCheckNames.length > 0
? await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames).then((result) => result.checks).catch(() => [])
: await runGhJsonAsync<GhPrCheckJson[]>([
"pr", "checks", String(number), "--repo", `${resolved.owner}/${resolved.repo}`,
"--required", "--json", "name,state,link,startedAt,completedAt",
]).catch(() => []);
const prInfo = toPrInfo({
url: pr.url,
@@ -1808,6 +1825,7 @@ export class GitHubClient {
status: this.mapGhPrState(pr.state),
title: pr.title,
headBranch: pr.headRefName,
headOid: pr.headRefOid,
baseBranch: pr.baseRefName,
isDraft: pr.isDraft,
commentCount: 0,
@@ -1815,17 +1833,18 @@ export class GitHubClient {
});
const normalizedChecks = checks.map((check) => ({
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),
detailsUrl: check.link,
startedAt: check.startedAt,
completedAt: check.completedAt,
detailsUrl: (check as GhPrCheckJson).link,
startedAt: (check as GhPrCheckJson).startedAt,
completedAt: (check as GhPrCheckJson).completedAt,
} satisfies PrCheckStatus));
const readiness = isPrMergeReady({
status: prInfo.status,
reviewDecision: pr.reviewDecision ?? null,
checks: normalizedChecks,
mergeable,
requiredCheckNames,
});
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 response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
@@ -1857,12 +1876,14 @@ export class GitHubClient {
isDraft
baseRefName
headRefName
headRefOid
comments { totalCount }
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(first: 100) {
pageInfo { hasNextPage }
nodes {
__typename
... on CheckRun {
@@ -1907,12 +1928,14 @@ export class GitHubClient {
isDraft?: boolean;
baseRefName: string;
headRefName: string;
headRefOid?: string | null;
comments: { totalCount: number };
commits: {
nodes: Array<{
commit: {
statusCheckRollup?: {
contexts?: {
pageInfo?: { hasNextPage?: boolean };
nodes?: Array<
| {
__typename: "CheckRun";
@@ -1948,13 +1971,16 @@ export class GitHubClient {
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) => {
if (!node || !node.isRequired) return [];
if (!node) return [];
if (node.__typename === "CheckRun") {
return [{
name: node.name,
required: true,
required: Boolean(node.isRequired) || namedSet.has(node.name),
state: normalizeCheckState(node.conclusion ?? node.status),
detailsUrl: node.detailsUrl ?? undefined,
startedAt: node.startedAt ?? undefined,
@@ -1963,12 +1989,13 @@ export class GitHubClient {
}
return [{
name: node.context,
required: true,
required: Boolean(node.isRequired) || namedSet.has(node.context),
state: normalizeCheckState(node.state),
detailsUrl: node.targetUrl ?? undefined,
} satisfies PrCheckStatus];
});
const gateChecks = checks.filter((check) => check.required);
const mergeable = mapPrConflictState(pr.mergeable, pr.mergeStateStatus);
const prInfo = toPrInfo({
url: pr.url,
@@ -1976,6 +2003,7 @@ export class GitHubClient {
status: this.mapGhPrState(pr.state),
title: pr.title,
headBranch: pr.headRefName,
headOid: pr.headRefOid ?? undefined,
baseBranch: pr.baseRefName,
isDraft: pr.isDraft,
commentCount: pr.comments.totalCount,
@@ -1984,14 +2012,16 @@ export class GitHubClient {
const readiness = isPrMergeReady({
status: prInfo.status,
reviewDecision: pr.reviewDecision,
checks,
checks: gateChecks,
mergeable,
requiredCheckNames,
checkListTruncated: Boolean(contexts?.pageInfo?.hasNextPage) && requiredCheckNames.some((name) => !gateChecks.some((check) => check.name === name)),
});
return {
prInfo,
reviewDecision: pr.reviewDecision,
checks,
checks: gateChecks,
mergeable,
mergeReady: readiness.ready,
blockingReasons: readiness.blockingReasons,
@@ -2002,20 +2032,22 @@ export class GitHubClient {
owner: string | undefined,
repo: string | undefined,
number: number,
options?: PrCheckGateOptions,
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
const requiredCheckNames = resolveRequiredCheckNames({ requiredChecks: options?.requiredCheckNames });
if (this.hasGhAuth()) {
try {
return await this.getAllPrChecksWithGh(owner, repo, number);
return await this.getAllPrChecksWithGh(owner, repo, number, requiredCheckNames);
} catch (err) {
if (this.token) {
return this.getAllPrChecksWithApi(owner, repo, number);
return this.getAllPrChecksWithApi(owner, repo, number, requiredCheckNames);
}
throw new Error(getGhErrorMessage(err));
}
}
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.");
}
@@ -2038,6 +2070,7 @@ export class GitHubClient {
owner: string | undefined,
repo: string | undefined,
number: number,
requiredCheckNames: string[] = [],
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
const resolved = this.resolveRepo(owner, repo);
@@ -2062,9 +2095,11 @@ export class GitHubClient {
});
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) => ({
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),
detailsUrl: check.link,
startedAt: check.startedAt,
@@ -2081,6 +2116,7 @@ export class GitHubClient {
owner: string | undefined,
repo: string | undefined,
number: number,
requiredCheckNames: string[] = [],
): Promise<{ checks: PrCheckStatus[]; rollupRequired: PrCheckState | "unknown" }> {
const resolved = this.resolveRepo(owner, repo);
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 namedSet = new Set(requiredCheckNames);
const checks = nodes.flatMap((node) => {
if (!node) return [];
if (node.__typename === "CheckRun") {
return [{
name: node.name,
required: Boolean(node.isRequired),
required: Boolean(node.isRequired) || namedSet.has(node.name),
state: normalizeCheckState(node.conclusion ?? node.status),
detailsUrl: node.detailsUrl ?? undefined,
startedAt: node.startedAt ?? undefined,
@@ -2180,7 +2217,7 @@ export class GitHubClient {
return [{
name: node.context,
required: Boolean(node.isRequired),
required: Boolean(node.isRequired) || namedSet.has(node.context),
state: normalizeCheckState(node.state),
detailsUrl: node.targetUrl ?? undefined,
} satisfies PrCheckStatus];

View File

@@ -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");
import { type NextFunction, type Request, type Response } from "express";
@@ -2375,9 +2375,29 @@ async function mergeTaskPr(
const settings = await scopedStore.getSettings();
const method = resolvePrMergeMethod(settings, task.prInfo, explicitMethod);
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 {
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 = {
...task.prInfo,
...mergedPrInfo,
@@ -2397,7 +2417,7 @@ async function mergeTaskPr(
} catch (error) {
let mergeStatus: Awaited<ReturnType<GitHubClient["getPrMergeStatus"]>> | undefined;
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 {
// 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;
for (const currentPrInfo of taskPrs) {
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number);
const mergeStatus = await client.getPrMergeStatus(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, { requiredCheckNames: resolveRequiredCheckNames(await store.getSettings()) });
const prior = getTaskPrList(task).find((entry) => entry.number === currentPrInfo.number) ?? currentPrInfo;
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
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) {
const batch = prList.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (priorPr) => {
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, priorPr.number);
const mergeStatus = await client.getPrMergeStatus(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, { requiredCheckNames: resolveRequiredCheckNames(settings) });
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
try {
@@ -6248,7 +6268,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
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) =>
comment.source === "github-review" || comment.source === "github-review-comment"
);
@@ -6322,7 +6342,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
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({
checks: checksResult.checks,

View File

@@ -1,4 +1,4 @@
import { createLogger } from "@fusion/core";
import { createLogger, resolveRequiredCheckNames } from "@fusion/core";
import type { Request, Response } from "express";
const severityAuditLog = createLogger("dashboard-register-task-workflow-routes");
@@ -6484,7 +6484,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!owner || !repo) {
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 {
reviewData = await buildDirectTaskReviewData(task, scopedStore);
}
@@ -6512,7 +6512,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!owner || !repo) {
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 {
reviewData = await buildDirectTaskReviewData(task, scopedStore);
}
@@ -6561,7 +6561,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!owner || !repo) {
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 {
canonicalReviewData = await buildDirectTaskReviewData(task, scopedStore);
}

View File

@@ -6375,7 +6375,9 @@
"gitRemoteThatMergedResultsArePushedTo": "Git remote that merged results are pushed to. Default: \"origin\".",
"pushTargetBranch": "Push target branch",
"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",
"mobileNav": {