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
|
||||
|
||||
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. |
|
||||
| `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. |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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-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";
|
||||
|
||||
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,
|
||||
mergeDiffVolumeThreshold: undefined,
|
||||
mergeDiffVolumeAllowlist: undefined,
|
||||
requiredChecks: undefined,
|
||||
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
|
||||
postMergeAuditMode: "warn",
|
||||
mergeAuditAutoRecovery: "ai-assisted",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("settings section-keys registry", () => {
|
||||
"mergeConflictStrategy",
|
||||
"mergeIntegrationWorktree",
|
||||
"mergeStrategy",
|
||||
"requiredChecks",
|
||||
"mergeStrategyOverlapBehavior",
|
||||
"merger",
|
||||
"planApprovalMode",
|
||||
|
||||
@@ -171,6 +171,7 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
|
||||
"mergeConflictStrategy",
|
||||
"mergeIntegrationWorktree",
|
||||
"mergeStrategy",
|
||||
"requiredChecks",
|
||||
"mergeStrategyOverlapBehavior",
|
||||
"merger",
|
||||
"planApprovalMode",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
requiredChecks: "merge.requiredChecksHelp",
|
||||
// AuthenticationSection — Anthropic dual-credential precedence (default api-key)
|
||||
anthropicAuthPreference: "auth.anthropicPreferenceHint",
|
||||
// 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", () => {
|
||||
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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user