diff --git a/.changeset/fn-8855-required-checks.md b/.changeset/fn-8855-required-checks.md new file mode 100644 index 0000000000..0f0c6f8146 --- /dev/null +++ b/.changeset/fn-8855-required-checks.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ab4513b4ee..4f3e729b77 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 09d4606b8b..013dbaa37f 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index fc2bb43ec1..2fda5a1ae6 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -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).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 }> })._updates.at(-1)?.patch).toEqual({ status: "awaiting-pr-checks" }); + expect((store as { _updates: Array<{ patch: Record }> })._updates.some( + (update) => "mergeRetries" in update.patch, + )).toBe(false); + }); }); }); diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index db6d877964..00c5556d55 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -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; createPr(params: { owner?: string; repo?: string; title: string; body: string; head: string; base?: string }): Promise; - 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>; try { - refreshedStatus = await github.getPrMergeStatus(prRepo.owner, prRepo.repo, prInfo.number); + refreshedStatus = await getPrMergeStatus(prInfo.number); } catch { throw err; } diff --git a/packages/core/src/__tests__/required-checks.test.ts b/packages/core/src/__tests__/required-checks.test.ts new file mode 100644 index 0000000000..c975c7bf8e --- /dev/null +++ b/packages/core/src/__tests__/required-checks.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index ae6185b8a0..26f7e4b787 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -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"; diff --git a/packages/core/src/config/required-checks.ts b/packages/core/src/config/required-checks.ts new file mode 100644 index 0000000000..5db608f70c --- /dev/null +++ b/packages/core/src/config/required-checks.ts @@ -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(); + for (const value of settings.requiredChecks) { + if (typeof value !== "string") continue; + const name = value.trim(); + if (name) names.add(name); + } + return [...names]; +} diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index 446d1554d0..6412ef8b4f 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -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", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af9e4222f2..3a0b014022 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5dfe07f357..7a7f4076d9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index 2c7cb3fd0e..f30e90895e 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -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; diff --git a/packages/core/src/types/task/task-tracking.ts b/packages/core/src/types/task/task-tracking.ts index dc2f6186d7..c0d0f977d4 100644 --- a/packages/core/src/types/task/task-tracking.ts +++ b/packages/core/src/types/task/task-tracking.ts @@ -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; diff --git a/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts b/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts index d4a1a65f79..1734053189 100644 --- a/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts +++ b/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts @@ -143,6 +143,7 @@ describe("settings section-keys registry", () => { "mergeConflictStrategy", "mergeIntegrationWorktree", "mergeStrategy", + "requiredChecks", "mergeStrategyOverlapBehavior", "merger", "planApprovalMode", diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index 9753c3efa5..51bc19a1aa 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -171,6 +171,7 @@ export const PROJECT_SECTION_KEYS: Readonly> = "mergeConflictStrategy", "mergeIntegrationWorktree", "mergeStrategy", + "requiredChecks", "mergeStrategyOverlapBehavior", "merger", "planApprovalMode", diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.search.ts b/packages/dashboard/app/components/settings/sections/MergeSection.search.ts index fa6ac498d1..961ac32dc2 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/MergeSection.search.ts @@ -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", diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx index 39059fd23e..7fe14e9ea2 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -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([]); const [pushBranchCustomMode, setPushBranchCustomMode] = useState(false); @@ -265,6 +278,19 @@ export function MergeSection({ form, setForm, integrationBranchOptions, integrat + {form.mergeStrategy === "pull-request" &&
+
+ + {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.")} +
+ { + 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 })); + }}/> +
}
diff --git a/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.requiredChecks.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.requiredChecks.test.tsx new file mode 100644 index 0000000000..06eafb42a7 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.requiredChecks.test.tsx @@ -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 { + 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 <> + + {JSON.stringify(form.requiredChecks)} + ; +} + +describe("MergeSection requiredChecks", () => { + it("round-trips comma-separated check names and clears to undefined", async () => { + const user = userEvent.setup(); + render(); + + 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(); + expect(screen.getByLabelText("Required pull-request checks")).toHaveValue(""); + expect(mergeSearchEntries).toContainEqual(expect.objectContaining({ key: "requiredChecks" })); + }); +}); diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index af5f3763d1..d6f58aa866 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -67,6 +67,7 @@ function resolveCanonicalDefault(settingKey: string): unknown { * English description states that setting's default value. */ const SETTING_DESCRIPTION_KEYS: Record = { + requiredChecks: "merge.requiredChecksHelp", // AuthenticationSection — Anthropic dual-credential precedence (default api-key) anthropicAuthPreference: "auth.anthropicPreferenceHint", // GlobalGeneralSection diff --git a/packages/dashboard/src/__tests__/github.test.ts b/packages/dashboard/src/__tests__/github.test.ts index 7f1b5a55d2..a68ba3fdd5 100644 --- a/packages/dashboard/src/__tests__/github.test.ts +++ b/packages/dashboard/src/__tests__/github.test.ts @@ -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", diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 954ed0d8c2..70b208d5e1 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -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 { + async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number, options?: PrCheckGateOptions): Promise { 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 { + async getPrReviewDetails(owner: string | undefined, repo: string | undefined, number: number, options?: PrCheckGateOptions): Promise { 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 { + async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number, options?: PrCheckGateOptions): Promise { + 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 { + private async getPrMergeStatusWithGh(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[]): Promise { const resolved = this.resolveRepo(owner, repo); const pr = await runGhJsonAsync([ "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([ - "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([ + "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 { + private async getPrMergeStatusWithApi(owner: string | undefined, repo: string | undefined, number: number, requiredCheckNames: string[]): Promise { 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]; diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 3f14428469..6c5055753f 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -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> | 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, diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 184e296cf5..ec85a38421 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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); } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 6bdd616952..a36c40690c 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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": {