diff --git a/.changeset/fn-8884-github-native-auto-merge.md b/.changeset/fn-8884-github-native-auto-merge.md new file mode 100644 index 0000000000..811578045a --- /dev/null +++ b/.changeset/fn-8884-github-native-auto-merge.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add opt-in GitHub-native pull request auto-merge. +category: feature +dev: Adds githubNativeAutoMerge using gh and token-pinned GraphQL transports with deferred merge reconciliation. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 73282a5043..924731dcc2 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). | +| `githubNativeAutoMerge` | `boolean` | `false` | Pull-request-mode-only opt-in that enables GitHub native auto-merge (`gh pr merge --auto` or GraphQL). The repository must permit auto-merge; rejection fails closed and Fusion does not fall back to an immediate merge. Fusion stays in review until a later poll observes GitHub's merged PR state. | | `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. | @@ -516,6 +517,10 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF | `autoRecovery.maxRetries` | `number` | `3` | Retry budget for dispatcher decisions. When `retryCount >= maxRetries`, dispatcher forces `pause` with rationale `retry-budget-exhausted`. | | `reliabilityStatsResetAt` | `string` (ISO-8601) | `undefined` | Optional reliability baseline cursor used by `/api/health/reliability`; events older than this timestamp are excluded from reliability aggregates but retained in storage. | +### GitHub-native pull-request auto-merge + +When `githubNativeAutoMerge` is enabled, Fusion still evaluates its configured `requirePrApproval`, `autoMergeOnGreen`, and `requiredChecks` gates before arming GitHub auto-merge. GitHub then owns final ruleset evaluation and merges only when its server-side requirements are satisfied. Native auto-merge is deferred, so Fusion intentionally does not use the immediate-merge `expectedHeadOid` head fence and waits for polling to report the actual merge. + ### Worktree and pre-merge rebase settings These settings have different reachability at HEAD. `worktreeRebaseBeforeMerge` (default `true`) is live in `rebaseNewWorktreeOntoRemote(...)`: after a fresh task worktree is created, Fusion best-effort fetches and rebases the branch onto its resolved remote integration target. The same setting also makes the executor's dependency-base resolver prefer `/`; when disabled or no remote can be resolved, that resolver uses the root directory's `HEAD` instead. `worktreeRebaseRemote` (default empty) selects the remote used by these paths and by the live integration-worktree resolver used for `GET /api/git/pull` and direct-merge integration work. diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index f6ec8b7d6a..63b0f957c9 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -720,6 +720,22 @@ describe("runDaemon", () => { expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + it("binds native auto-merge to the executing engine's store", async () => { + const lifecycle = await import("../task-lifecycle.js"); + const { ProjectEngineManager } = await import("@fusion/engine"); + await runDaemon({}); + + const factory = vi.mocked(ProjectEngineManager).mock.calls.at(-1)?.[1]?.createPrNodeGithubOps; + const otherProjectStore = { getSettings: vi.fn().mockResolvedValue({ githubNativeAutoMerge: true }) }; + factory?.(otherProjectStore as never); + const resolver = vi.mocked(lifecycle.createPrNodeGithubOps).mock.calls.at(-1)?.[1]?.isNativeAutoMergeEnabled; + + await expect(resolver?.({ id: "FN-shared" } as never)).resolves.toBe(true); + expect(otherProjectStore.getSettings).toHaveBeenCalledOnce(); + + await triggerSignal("SIGINT"); + }); + it("registers built-in zai GLM-5.2 before refreshing models", async () => { await runDaemon({}); diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 528fb3c4a8..f267cc245f 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -857,6 +857,20 @@ describe("runServe", () => { expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + it("binds native auto-merge to the executing engine's store", async () => { + const lifecycle = await import("../task-lifecycle.js"); + const { ProjectEngineManager } = await import("@fusion/engine"); + await runServe(0, {}); + + const factory = vi.mocked(ProjectEngineManager).mock.calls.at(-1)?.[1]?.createPrNodeGithubOps; + const otherProjectStore = { getSettings: vi.fn().mockResolvedValue({ githubNativeAutoMerge: true }) }; + factory?.(otherProjectStore as never); + const resolver = vi.mocked(lifecycle.createPrNodeGithubOps).mock.calls.at(-1)?.[1]?.isNativeAutoMergeEnabled; + + await expect(resolver?.({ id: "FN-shared" } as never)).resolves.toBe(true); + expect(otherProjectStore.getSettings).toHaveBeenCalledOnce(); + }); + // FNXC:DaemonSignalExit 2026-07-10-16:00: `fn serve` must honor the same POSIX // exit-code contract as `fn daemon` — a memory-pressure SIGTERM exits non-zero // (143) so `Restart=on-failure` restarts it; SIGINT exits 130. Guards against diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index 2fda5a1ae6..54a56585ac 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -1713,6 +1713,132 @@ describe("processPullRequestMergeTask", () => { )).toBe(false); }); }); + + it("arms native auto-merge for a per-task PR without finalizing its open result", async () => { + const task: MockTask = { + id: "FN-native-per-task", title: "native", description: "desc", column: "in-review", + prInfo: { number: 801, url: "https://github.com/owner/repo/pull/801", status: "open", headBranch: "fusion/fn-native-per-task", baseBranch: "main" }, + }; + const store = makeStore(task, { githubNativeAutoMerge: true }); + const github = { + findPrForBranch: vi.fn(), createPr: vi.fn(), + getPrMergeStatus: vi.fn().mockResolvedValue({ prInfo: { ...task.prInfo, mergeable: "clean" }, reviewDecision: "APPROVED", checks: [], mergeReady: false, blockingReasons: [] }), + mergePr: vi.fn().mockResolvedValue({ ...task.prInfo, status: "open" }), + }; + + await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined)).resolves.toBe("waiting"); + expect(github.mergePr).toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + expect(github.mergePr).not.toHaveBeenCalledWith(expect.objectContaining({ expectedHeadOid: expect.anything() })); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + + it("finalizes a per-task PR when GitHub merges native auto-merge synchronously", async () => { + const task: MockTask = { + id: "FN-native-per-task-merged", title: "native", description: "desc", column: "in-review", + prInfo: { number: 803, url: "https://github.com/owner/repo/pull/803", status: "open", headBranch: "fusion/fn-native-per-task-merged", baseBranch: "main" }, + }; + const store = makeStore(task, { githubNativeAutoMerge: true }); + const github = { + findPrForBranch: vi.fn(), createPr: vi.fn(), + getPrMergeStatus: vi.fn().mockResolvedValue({ prInfo: { ...task.prInfo, mergeable: "clean" }, reviewDecision: "APPROVED", checks: [], mergeReady: true, blockingReasons: [] }), + mergePr: vi.fn().mockResolvedValue({ ...task.prInfo, status: "merged" }), + }; + + await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined)).resolves.toBe("merged"); + expect(github.mergePr).toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + expect(store.moveTask).toHaveBeenCalledWith(task.id, "done"); + }); + + it("keeps the per-task head fence when native auto-merge is disabled", async () => { + const task: MockTask = { + id: "FN-legacy-per-task", title: "legacy", description: "desc", column: "in-review", + prInfo: { number: 804, url: "https://github.com/owner/repo/pull/804", status: "open", headBranch: "fusion/fn-legacy-per-task", baseBranch: "main" }, + }; + const store = makeStore(task); + const github = { + findPrForBranch: vi.fn(), createPr: vi.fn(), + getPrMergeStatus: vi.fn().mockResolvedValue({ prInfo: { ...task.prInfo, mergeable: "clean" }, reviewDecision: "APPROVED", checks: [], mergeReady: true, blockingReasons: [] }), + mergePr: vi.fn().mockResolvedValue({ ...task.prInfo, status: "merged" }), + }; + + await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined)).resolves.toBe("merged"); + expect(github.mergePr).toHaveBeenCalledWith(expect.objectContaining({ expectedHeadOid: "1111111111111111111111111111111111111111" })); + expect(github.mergePr).not.toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + }); + + it("arms native auto-merge for a shared branch group without finalizing its open result", async () => { + const task: MockTask = { + id: "FN-native-group", title: "native group", description: "desc", column: "in-review", + branchContext: { groupId: "BG-native", source: "planning", assignmentMode: "shared" }, + }; + const store = makeStore(task, { githubNativeAutoMerge: true }); + (store.getBranchGroup as ReturnType).mockReturnValue({ + id: "BG-native", sourceType: "planning", sourceId: "planning:native", branchName: "fusion/groups/native", autoMerge: true, + prState: "open", prNumber: 802, prUrl: "https://github.com/owner/repo/pull/802", status: "open", createdAt: Date.now(), updatedAt: Date.now(), + }); + (store.listTasksByBranchGroup as ReturnType).mockResolvedValue([task]); + execMock.mockImplementation((command: string) => command.includes("rev-list --count") ? "1\n" : ""); + const groupPr = { number: 802, url: "https://github.com/owner/repo/pull/802", status: "open" as const, headBranch: "fusion/groups/native", baseBranch: "main" }; + const github = { + findPrForBranch: vi.fn(), createPr: vi.fn(), + getPrMergeStatus: vi.fn().mockResolvedValue({ prInfo: { ...groupPr, mergeable: "clean" }, reviewDecision: "APPROVED", checks: [], mergeReady: false, blockingReasons: [] }), + mergePr: vi.fn().mockResolvedValue(groupPr), + }; + + await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined)).resolves.toBe("waiting"); + expect(github.mergePr).toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateBranchGroup).toHaveBeenCalledWith("BG-native", expect.objectContaining({ prState: "open" })); + }); + + it("finalizes shared branch members when GitHub merges native auto-merge synchronously", async () => { + const task: MockTask = { + id: "FN-native-group-merged", title: "native group", description: "desc", column: "in-review", + branchContext: { groupId: "BG-native-merged", source: "planning", assignmentMode: "shared" }, + }; + const store = makeStore(task, { githubNativeAutoMerge: true }); + (store.getBranchGroup as ReturnType).mockReturnValue({ + id: "BG-native-merged", sourceType: "planning", sourceId: "planning:native", branchName: "fusion/groups/native-merged", autoMerge: true, + prState: "open", prNumber: 805, prUrl: "https://github.com/owner/repo/pull/805", status: "open", createdAt: Date.now(), updatedAt: Date.now(), + }); + (store.listTasksByBranchGroup as ReturnType).mockResolvedValue([task]); + execMock.mockImplementation((command: string) => command.includes("rev-list --count") ? "1\n" : ""); + const groupPr = { number: 805, url: "https://github.com/owner/repo/pull/805", status: "open" as const, headBranch: "fusion/groups/native-merged", baseBranch: "main" }; + const github = { + findPrForBranch: vi.fn(), createPr: vi.fn(), + getPrMergeStatus: vi.fn().mockResolvedValue({ prInfo: { ...groupPr, mergeable: "clean" }, reviewDecision: "APPROVED", checks: [], mergeReady: true, blockingReasons: [] }), + mergePr: vi.fn().mockResolvedValue({ ...groupPr, status: "merged" }), + }; + + await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined)).resolves.toBe("merged"); + expect(github.mergePr).toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + expect(store.moveTask).toHaveBeenCalledWith(task.id, "done"); + expect(store.updateBranchGroup).toHaveBeenCalledWith("BG-native-merged", expect.objectContaining({ status: "finalized", prState: "merged" })); + }); + + it("keeps the shared branch head fence when native auto-merge is disabled", async () => { + const task: MockTask = { + id: "FN-legacy-group", title: "legacy group", description: "desc", column: "in-review", + branchContext: { groupId: "BG-legacy", source: "planning", assignmentMode: "shared" }, + }; + const store = makeStore(task); + (store.getBranchGroup as ReturnType).mockReturnValue({ + id: "BG-legacy", sourceType: "planning", sourceId: "planning:legacy", branchName: "fusion/groups/legacy", autoMerge: true, + prState: "open", prNumber: 806, prUrl: "https://github.com/owner/repo/pull/806", status: "open", createdAt: Date.now(), updatedAt: Date.now(), + }); + (store.listTasksByBranchGroup as ReturnType).mockResolvedValue([task]); + execMock.mockImplementation((command: string) => command.includes("rev-list --count") ? "1\n" : ""); + const groupPr = { number: 806, url: "https://github.com/owner/repo/pull/806", status: "open" as const, headBranch: "fusion/groups/legacy", baseBranch: "main" }; + const github = { + findPrForBranch: vi.fn(), createPr: vi.fn(), + getPrMergeStatus: vi.fn().mockResolvedValue({ prInfo: { ...groupPr, mergeable: "clean" }, reviewDecision: "APPROVED", checks: [], mergeReady: true, blockingReasons: [] }), + mergePr: vi.fn().mockResolvedValue({ ...groupPr, status: "merged" }), + }; + + await expect(processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined)).resolves.toBe("merged"); + expect(github.mergePr).toHaveBeenCalledWith(expect.objectContaining({ expectedHeadOid: "1111111111111111111111111111111111111111" })); + expect(github.mergePr).not.toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + }); }); describe("cleanupMergedTaskArtifacts FN-5455", () => { @@ -2354,6 +2480,7 @@ describe("createPrNodeGithubOps repo resolution (gh-4)", () => { expect(github.mergePr).toHaveBeenCalledWith( expect.objectContaining({ owner: "central-owner", repo: "central-repo", number: 9, method: "squash", expectedHeadOid: "1111111111111111111111111111111111111111" }), ); + expect(github.mergePr).not.toHaveBeenCalledWith(expect.objectContaining({ auto: true })); }); it("fails closed when workflow PR merge is cancelled", async () => { @@ -2368,6 +2495,37 @@ describe("createPrNodeGithubOps repo resolution (gh-4)", () => { expect(github.mergePr).not.toHaveBeenCalled(); }); + it("resolves native auto-merge against the workflow task rather than a process-global setting", async () => { + const github = githubStub(); + const resolver = vi.fn(async (task: { id: string }) => task.id === "FN-native"); + const ops = createPrNodeGithubOps(github as never, { isNativeAutoMergeEnabled: resolver }); + + await ops.mergePr({ + task: { id: "FN-native" }, + entity: { id: "e1", sourceId: "FN-native", repo: "central-owner/central-repo", prNumber: 9 }, + } as never); + await ops.mergePr({ + task: { id: "FN-legacy" }, + entity: { id: "e2", sourceId: "FN-legacy", repo: "central-owner/central-repo", prNumber: 10 }, + } as never); + + expect(resolver).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: "FN-native" })); + expect(resolver).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: "FN-legacy" })); + expect(github.mergePr).toHaveBeenNthCalledWith(1, expect.objectContaining({ auto: true })); + expect(github.mergePr).toHaveBeenNthCalledWith(2, expect.objectContaining({ expectedHeadOid: "1111111111111111111111111111111111111111" })); + }); + + it("propagates unavailable native auto-merge failures from the workflow callback", async () => { + const github = githubStub(); + github.mergePr.mockRejectedValue(Object.assign(new Error("auto merge disabled"), { code: "auto-merge-unavailable" })); + const ops = createPrNodeGithubOps(github as never, { isNativeAutoMergeEnabled: () => true }); + + await expect(ops.mergePr({ + task: { id: "FN-native" }, + entity: { id: "e1", sourceId: "FN-native", repo: "central-owner/central-repo", prNumber: 9 }, + } as never)).rejects.toMatchObject({ code: "auto-merge-unavailable", message: "auto merge disabled" }); + }); + it("persists the refreshed workflow head before requesting GitHub merge", async () => { const calls: string[] = []; const github = githubStub(); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index ff0732a077..c633b67e69 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -361,7 +361,7 @@ export async function runDaemon(opts: DaemonOptions = {}) { const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url); const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion; - const engineManager = new ProjectEngineManager(sharedCentralCore, { + const engineManager: ProjectEngineManager = new ProjectEngineManager(sharedCentralCore, { onMigrationProgress: (event) => migrationHoldingServer?.setMigrationProgress(event), cliPackageVersion, getMergeStrategy, @@ -369,7 +369,14 @@ export async function runDaemon(opts: DaemonOptions = {}) { processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool, signal), createGroupPr: createGroupPrCallback(githubClient), syncGroupPr: syncGroupPrCallback(githubClient), - prNodeGithubOps: createPrNodeGithubOps(githubClient), + /* + FNXC:PrMergeAutoMerge 2026-08-09-10:59: + Each daemon engine supplies its own TaskStore to this factory. Never infer + the project from a task ID because IDs are only project-scoped. + */ + createPrNodeGithubOps: (taskStore) => createPrNodeGithubOps(githubClient, { + isNativeAutoMergeEnabled: async () => (await taskStore.getSettings()).githubNativeAutoMerge === true, + }), prReconcileGithubOps: createPrReconcileGithubOps(githubClient), getTaskMergeBlocker, onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 1010c274d6..199d04e7f4 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -2132,14 +2132,21 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ProjectEngineManager only applies this store when the project's working directory matches the store root (multi-project safety). */ - const engineManager = new ProjectEngineManager(centralCoreForEngine, { + const engineManager: ProjectEngineManager = new ProjectEngineManager(centralCoreForEngine, { cliPackageVersion, getMergeStrategy, processPullRequestMerge: (s, wd, taskId, pool, signal) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool, signal), createGroupPr: createGroupPrCallback(githubClient), syncGroupPr: syncGroupPrCallback(githubClient), - prNodeGithubOps: createPrNodeGithubOps(githubClient), + /* + FNXC:PrMergeAutoMerge 2026-08-09-10:59: + Dashboard-managed engines bind this resolver to their own TaskStore; the + dashboard boot store is not a safe proxy for every registered project. + */ + createPrNodeGithubOps: (taskStore) => createPrNodeGithubOps(githubClient, { + isNativeAutoMergeEnabled: async () => (await taskStore.getSettings()).githubNativeAutoMerge === true, + }), prReconcileGithubOps: createPrReconcileGithubOps(githubClient), getTaskMergeBlocker, externalTaskStore: store, diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d0243500bf..859c22e20b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -422,14 +422,21 @@ export async function runServe( const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url); const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion; - const engineManager = startupEngineManager = new ProjectEngineManager(sharedCentralCore, { + const engineManager: ProjectEngineManager = startupEngineManager = new ProjectEngineManager(sharedCentralCore, { cliPackageVersion, getMergeStrategy, processPullRequestMerge: (s, wd, taskId, pool, signal) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool, signal), createGroupPr: createGroupPrCallback(githubClient), syncGroupPr: syncGroupPrCallback(githubClient), - prNodeGithubOps: createPrNodeGithubOps(githubClient), + /* + FNXC:PrMergeAutoMerge 2026-08-09-10:59: + Serve may own several engines. Bind native auto-merge policy to the runtime + TaskStore instead of guessing task ownership from a project-scoped task ID. + */ + createPrNodeGithubOps: (taskStore) => createPrNodeGithubOps(githubClient, { + isNativeAutoMergeEnabled: async () => (await taskStore.getSettings()).githubNativeAutoMerge === true, + }), prReconcileGithubOps: createPrReconcileGithubOps(githubClient), getTaskMergeBlocker, onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index 00c5556d55..3cabbd3c11 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -95,7 +95,7 @@ interface GitHubOperations { mergeReady: boolean; blockingReasons: string[]; }>; - mergePr(params: { owner?: string; repo?: string; number: number; method?: "merge" | "squash" | "rebase"; expectedHeadOid?: string }): Promise; + mergePr(params: { owner?: string; repo?: string; number: number; method?: "merge" | "squash" | "rebase"; expectedHeadOid?: string; auto?: boolean }): Promise; getPrStatus(owner: string, repo: string, number: number): Promise; /** Reply to a specific review thread (U2). */ replyToReviewThread(threadId: string, body: string): Promise; @@ -873,6 +873,12 @@ export function createPrNodeGithubOps( * single-project daemon/serve case). */ getTaskWorktree?: (taskId: string) => string | undefined; + /** + * Resolves the opt-in from the store that owns this task. The task parameter + * keeps one CLI process from applying its primary project's setting to every + * managed project's workflow callback. + */ + isNativeAutoMergeEnabled?: (task: TaskDetail) => boolean | Promise; } = {}, ): PrNodeGithubOps { const getCwd = (entity: { sourceId: string }): string => @@ -961,12 +967,13 @@ export function createPrNodeGithubOps( await persistRefreshedHead?.(refreshed.headOid); try { throwIfRefreshAborted(signal); + const auto = await options.isNativeAutoMergeEnabled?.(task) ?? false; await github.mergePr({ owner, repo: name, number: entity.prNumber, method: "squash", - expectedHeadOid: refreshed.headOid, + ...(auto ? { auto: true } : { expectedHeadOid: refreshed.headOid }), }); return { status: "merged-requested", headOid: refreshed.headOid }; } catch (err) { @@ -1419,12 +1426,13 @@ export async function processPullRequestMergeTask( return "merged"; } + const nativeAutoMerge = settings.githubNativeAutoMerge === true; if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") { await store.updateTask(task.id, { status: "awaiting-pr-checks" }); return "waiting"; } - if (!mergeStatus.mergeReady) { + if (!nativeAutoMerge && !mergeStatus.mergeReady) { await store.updateTask(task.id, { status: mergeStatus.prInfo.status === "open" ? "awaiting-pr-checks" : null }); return "waiting"; } @@ -1459,18 +1467,30 @@ export async function processPullRequestMergeTask( await store.updateTask(task.id, { status: "awaiting-pr-checks" }); return "waiting"; } - if (!latestMergeStatus.mergeReady) { + if (!nativeAutoMerge && !latestMergeStatus.mergeReady) { await store.updateTask(task.id, { status: "awaiting-pr-checks" }); return "waiting"; } await store.updateTask(task.id, { status: "merging-pr" }); throwIfRefreshAborted(signal); - const mergedPr = await github.mergePr({ owner: prRepo.owner, repo: prRepo.repo, number: refreshedPrInfo.number, method: "squash", expectedHeadOid: refreshedHead.headOid }); + const mergedPr = await github.mergePr({ + owner: prRepo.owner, repo: prRepo.repo, number: refreshedPrInfo.number, method: "squash", + ...(nativeAutoMerge ? { auto: true } : { expectedHeadOid: refreshedHead.headOid }), + }); await store.updateBranchGroup(branchGroup.id, { prNumber: mergedPr.number, prUrl: mergedPr.url, prState: toBranchGroupPrState(mergedPr), }); + /* + FNXC:PrMergeAutoMerge 2026-08-09-09:28: + Native auto-merge intentionally leaves an open PR; the existing poll owns finalization + only after GitHub reports merged. + */ + if (mergedPr.status !== "merged") { + await store.updateTask(task.id, { status: "awaiting-pr-checks" }); + return "waiting"; + } for (const member of members) { const memberDetail = await store.getTask(member.id); await finalizePullRequestMerge(store, cwd, memberDetail, mergedPr, "Group pull request merged", pool); @@ -1567,12 +1587,13 @@ export async function processPullRequestMergeTask( // immediately. `requirePrApproval` lets users keep PR mode as "open the // PR, wait for me to approve and merge it" by holding the merge until // reviewDecision === "APPROVED". + const nativeAutoMerge = settings.githubNativeAutoMerge === true; if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") { await store.updateTask(task.id, { status: "awaiting-pr-checks" }); return "waiting"; } - if (!mergeStatus.mergeReady) { + if (!nativeAutoMerge && !mergeStatus.mergeReady) { if (mergeStatus.prInfo.status === "open") { // A stale-base PR that GitHub reports as CONFLICTING never becomes // mergeable on its own — nothing in the PR path rebases the head branch — @@ -1634,7 +1655,7 @@ export async function processPullRequestMergeTask( await store.updateTask(task.id, { status: "awaiting-pr-checks" }); return "waiting"; } - if (!latestMergeStatus.mergeReady) { + if (!nativeAutoMerge && !latestMergeStatus.mergeReady) { await store.updateTask(task.id, { status: "awaiting-pr-checks" }); return "waiting"; } @@ -1642,7 +1663,10 @@ export async function processPullRequestMergeTask( let mergedPr: PrInfo; try { throwIfRefreshAborted(signal); - mergedPr = await github.mergePr({ owner: prRepo.owner, repo: prRepo.repo, number: prInfo.number, method: "squash", expectedHeadOid: refreshedHead.headOid }); + mergedPr = await github.mergePr({ + owner: prRepo.owner, repo: prRepo.repo, number: prInfo.number, method: "squash", + ...(nativeAutoMerge ? { auto: true } : { expectedHeadOid: refreshedHead.headOid }), + }); } catch (err: unknown) { let refreshedStatus: Awaited>; try { @@ -1683,6 +1707,11 @@ export async function processPullRequestMergeTask( throw Object.assign(new Error(diagnosis.message), { code: diagnosis.code, cause: diagnosis.cause }); } await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() }); + /* FNXC:PrMergeAutoMerge 2026-08-09-09:28: Leave native auto-merge requests open until polling confirms GitHub merged them. */ + if (mergedPr.status !== "merged") { + await store.updateTask(task.id, { status: "awaiting-pr-checks" }); + return "waiting"; + } await finalizePullRequestMerge(store, cwd, task, mergedPr, "Pull request merged", pool); return "merged"; } diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 1fb08a2620..f8f9dd629d 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -166,6 +166,10 @@ describe("settings defaults invariants", () => { expect(DEFAULT_PROJECT_SETTINGS.useAiMergeCommitSummary).toBe(true); }); + it("keeps GitHub native PR auto-merge opt-in", () => { + expect(DEFAULT_PROJECT_SETTINGS.githubNativeAutoMerge).toBe(false); + }); + describe("recycleWorktrees default", () => { it("keeps recycleWorktrees explicitly false in project defaults", () => { expect(DEFAULT_PROJECT_SETTINGS.recycleWorktrees).toBe(false); diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index 6412ef8b4f..6ec23b353d 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -485,6 +485,7 @@ export const DEFAULT_PROJECT_SETTINGS = { voiceInput: undefined, mergeRequestContractShadowEnabled: false, mergeStrategy: "direct", + githubNativeAutoMerge: false, directMergeCommitStrategy: "always-squash", mergeIntegrationWorktree: "reuse-task-worktree", mergeAdvanceAutoSync: "stash-and-ff", diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index f30e90895e..21d6b3ad5c 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -1255,6 +1255,14 @@ export interface ProjectSettings { * be enforced server-side. Only applies when `mergeStrategy === "pull-request"`. * Default: false. */ requirePrApproval?: boolean; + /* + FNXC:PrMergeAutoMerge 2026-08-09-09:28: + Issue #3359(c) requires this opt-in to hand final policy evaluation and wait-for-green + to GitHub native auto-merge. It only applies to pull-request merging and fails closed + when the repository does not permit auto-merge rather than immediately merging. + */ + /** Enable GitHub native PR auto-merge in pull-request mode. Default: false. */ + githubNativeAutoMerge?: boolean; /** When true (default), the Review-response loop automatically acts on PR review * threads (human + bot): it dispatches an agent that fixes + pushes + replies, or * disagrees with reasoning. When false, the loop is inert — review threads are left 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 1734053189..47ed7dc514 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", + "githubNativeAutoMerge", "requiredChecks", "mergeStrategyOverlapBehavior", "merger", diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index 51bc19a1aa..821b5ae50e 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", + "githubNativeAutoMerge", "requiredChecks", "mergeStrategyOverlapBehavior", "merger", diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx index 7fe14e9ea2..21501438eb 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -278,7 +278,8 @@ export function MergeSection({ form, setForm, integrationBranchOptions, integrat - {form.mergeStrategy === "pull-request" &&
+ {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.")} @@ -290,7 +291,16 @@ export function MergeSection({ form, setForm, integrationBranchOptions, integrat emittedRequiredCheckNames.current = requiredChecks; setForm((current) => ({ ...current, requiredChecks: requiredChecks.length > 0 ? requiredChecks : undefined })); }}/> -
} +
+
+
+ + {t("settings.merge.githubNativeAutoMergeHelp", "GitHub waits for its full ruleset before merging. The repository must allow auto-merge; Fusion fails closed if it is unavailable. Default: disabled.")} +
+
+ }
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 d6f58aa866..6f8ce98932 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 = { + githubNativeAutoMerge: "merge.githubNativeAutoMergeHelp", requiredChecks: "merge.requiredChecksHelp", // AuthenticationSection — Anthropic dual-credential precedence (default api-key) anthropicAuthPreference: "auth.anthropicPreferenceHint", diff --git a/packages/dashboard/src/__tests__/github-native-auto-merge.test.ts b/packages/dashboard/src/__tests__/github-native-auto-merge.test.ts new file mode 100644 index 0000000000..b57a497f21 --- /dev/null +++ b/packages/dashboard/src/__tests__/github-native-auto-merge.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + isGhAvailable: vi.fn(() => true), + isGhAuthenticated: vi.fn(() => true), + runGh: vi.fn(), + runGhAsync: vi.fn(), + }; +}); + +import { runGh, runGhAsync } from "@fusion/core"; +import { GitHubClient, PrAutoMergeUnavailableError } from "../github.js"; + +const openPr = { number: 8, url: "https://github.test/o/r/pull/8", status: "open" as const, title: "PR", headBranch: "head", baseBranch: "main", commentCount: 0 }; + +function client(mode: "gh-cli" | "token", getPrStatus = vi.fn().mockResolvedValue(openPr)) { + const result = new GitHubClient({ token: "token", forceMode: mode }); + vi.spyOn(result, "getPrStatus").mockImplementation(getPrStatus); + return result; +} + +function graphqlResponse(data: unknown) { + return new Response(JSON.stringify({ data }), { status: 200 }); +} + +beforeEach(() => vi.clearAllMocks()); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("GitHubClient native auto-merge", () => { + it("uses gh --auto without a head fence", async () => { + const github = client("gh-cli"); + await github.mergePr({ owner: "o", repo: "r", number: 8, auto: true, expectedHeadOid: "ignored" }); + + expect(runGh).toHaveBeenCalledWith([ + "pr", "merge", "8", "--repo", "o/r", "--squash", "--auto", "--delete-branch", + ]); + expect(vi.mocked(runGh).mock.calls[0]?.[0]).not.toContain("--match-head-commit"); + }); + + it("preserves the legacy gh argv when auto is unset", async () => { + const github = client("gh-cli"); + await github.mergePr({ owner: "o", repo: "r", number: 8, method: "merge", expectedHeadOid: "head" }); + + expect(runGh).toHaveBeenCalledWith([ + "pr", "merge", "8", "--repo", "o/r", "--merge", "--delete-branch", "--match-head-commit", "head", + ]); + }); + + it.each([ + [undefined, "SQUASH"], + ["rebase", "REBASE"], + ["merge", "MERGE"], + ] as const)("enables token auto-merge with %s as %s and returns the live open state", async (method, enumValue) => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(graphqlResponse({ repository: { pullRequest: { id: "PR_node" } } })) + .mockResolvedValueOnce(graphqlResponse({ enablePullRequestAutoMerge: { pullRequest: { id: "PR_node" } } })); + vi.stubGlobal("fetch", fetchMock); + const github = client("token"); + + const result = await github.mergePr({ owner: "o", repo: "r", number: 8, auto: true, ...(method ? { method } : {}) }); + + expect(result.status).toBe("open"); + expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes("/pulls/8/merge") || init?.method === "PUT")).toBe(false); + expect(String(fetchMock.mock.calls[1]?.[1]?.body)).toContain(`mergeMethod: ${enumValue}`); + }); + + it("pins the API mutation to fetch even when gh auth is available", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(graphqlResponse({ repository: { pullRequest: { id: "PR_node" } } })) + .mockResolvedValueOnce(graphqlResponse({ enablePullRequestAutoMerge: { pullRequest: { id: "PR_node" } } })); + vi.stubGlobal("fetch", fetchMock); + const github = client("token"); + + await github.mergePr({ owner: "o", repo: "r", number: 8, auto: true }); + + expect(runGhAsync).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith("https://api.github.com/graphql", expect.objectContaining({ method: "POST" })); + }); + + it("preserves auto through a gh-to-token fallback without issuing a REST merge", async () => { + vi.mocked(runGh).mockImplementation(() => { throw new Error("gh transient failure"); }); + const fetchMock = vi.fn() + .mockResolvedValueOnce(graphqlResponse({ repository: { pullRequest: { id: "PR_node" } } })) + .mockResolvedValueOnce(graphqlResponse({ enablePullRequestAutoMerge: { pullRequest: { id: "PR_node" } } })); + vi.stubGlobal("fetch", fetchMock); + const github = client("gh-cli"); + + await github.mergePr({ owner: "o", repo: "r", number: 8, auto: true }); + + expect(String(fetchMock.mock.calls[1]?.[1]?.body)).toContain("enablePullRequestAutoMerge"); + expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes("/pulls/8/merge") || init?.method === "PUT")).toBe(false); + }); + + it.each(["gh-cli", "token"] as const)("fails closed when %s reports native auto-merge unavailable", async (mode) => { + const github = client(mode); + if (mode === "gh-cli") { + vi.mocked(runGh).mockImplementation(() => { throw new Error("Pull request auto merge is not allowed for this repository"); }); + } else { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce(graphqlResponse({ repository: { pullRequest: { id: "PR_node" } } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ errors: [{ message: "Pull request auto merge is not allowed for this repository" }] }), { status: 200 }))); + } + + await expect(github.mergePr({ owner: "o", repo: "r", number: 8, auto: true })).rejects.toBeInstanceOf(PrAutoMergeUnavailableError); + expect(vi.mocked(runGh).mock.calls.filter(([args]) => args.includes("--auto"))).toHaveLength(mode === "gh-cli" ? 1 : 0); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-pr-merge.test.ts b/packages/dashboard/src/__tests__/routes-pr-merge.test.ts index dc9b27073b..5b8f67df85 100644 --- a/packages/dashboard/src/__tests__/routes-pr-merge.test.ts +++ b/packages/dashboard/src/__tests__/routes-pr-merge.test.ts @@ -1,6 +1,40 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { resolvePrMergeMethod } from "../routes/register-git-github.js"; +import { GitHubClient, PrAutoMergeUnavailableError } from "../github.js"; +import { mergeTaskPr, resolvePrMergeMethod } from "../routes/register-git-github.js"; + +const prInfo = { + number: 8, + url: "https://github.com/o/r/pull/8", + status: "open" as const, + title: "PR", + headBranch: "fusion/fn-8", + baseBranch: "main", + commentCount: 0, +}; + +function store(settings: Record = {}) { + return { + getSettings: vi.fn().mockResolvedValue(settings), + getRootDir: vi.fn().mockReturnValue("/repo"), + updatePrInfo: vi.fn().mockResolvedValue(undefined), + applyPrMergedTransition: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockClient(mergeResult: typeof prInfo | Error, mergeReady = true) { + vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({ + prInfo: { ...prInfo, headOid: "checked-head", mergeable: "clean" }, + mergeReady, + blockingReasons: [], + checks: [], + reviewDecision: "APPROVED", + mergeable: "clean", + } as never); + if (mergeResult instanceof Error) vi.spyOn(GitHubClient.prototype, "mergePr").mockRejectedValue(mergeResult); + else vi.spyOn(GitHubClient.prototype, "mergePr").mockResolvedValue(mergeResult as never); + return GitHubClient; +} describe("resolvePrMergeMethod", () => { it("prefers explicit request method", () => { @@ -21,3 +55,49 @@ describe("resolvePrMergeMethod", () => { expect(resolvePrMergeMethod(undefined, undefined)).toBe("squash"); }); }); + +describe("mergeTaskPr native auto-merge", () => { + afterEach(() => vi.restoreAllMocks()); + + it("arms native auto-merge and retains an open PR for background reconciliation", async () => { + const scopedStore = store({ githubNativeAutoMerge: true }); + const GitHubClient = mockClient(prInfo, false); + + const result = await mergeTaskPr(scopedStore as never, { id: "FN-8", prInfo } as never, undefined); + + expect(result.status).toBe("open"); + expect(GitHubClient.prototype.mergePr).toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + expect(GitHubClient.prototype.mergePr).not.toHaveBeenCalledWith(expect.objectContaining({ expectedHeadOid: expect.anything() })); + expect(scopedStore.updatePrInfo).toHaveBeenCalledWith("FN-8", expect.objectContaining({ status: "open", lastMergeError: undefined })); + expect(scopedStore.applyPrMergedTransition).not.toHaveBeenCalled(); + }); + + it("transitions only when GitHub already reports the PR merged", async () => { + const scopedStore = store({ githubNativeAutoMerge: true }); + mockClient({ ...prInfo, status: "merged" }); + + await mergeTaskPr(scopedStore as never, { id: "FN-8", prInfo } as never, undefined); + + expect(scopedStore.applyPrMergedTransition).toHaveBeenCalledWith("FN-8", expect.any(Object)); + }); + + it("preserves direct merge fencing when the setting is disabled", async () => { + const scopedStore = store(); + const GitHubClient = mockClient({ ...prInfo, status: "merged" }); + + await mergeTaskPr(scopedStore as never, { id: "FN-8", prInfo } as never, undefined); + + expect(GitHubClient.prototype.mergePr).toHaveBeenCalledWith(expect.objectContaining({ expectedHeadOid: "checked-head" })); + expect(GitHubClient.prototype.mergePr).not.toHaveBeenCalledWith(expect.objectContaining({ auto: true })); + }); + + it("records an unavailable native auto-merge error without transitioning", async () => { + const scopedStore = store({ githubNativeAutoMerge: true }); + mockClient(new PrAutoMergeUnavailableError("auto merge disabled")); + + await expect(mergeTaskPr(scopedStore as never, { id: "FN-8", prInfo } as never, undefined)).rejects.toMatchObject({ message: "auto merge disabled" }); + + expect(scopedStore.updatePrInfo).toHaveBeenCalledWith("FN-8", expect.objectContaining({ lastMergeError: "auto merge disabled" })); + expect(scopedStore.applyPrMergedTransition).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 70b208d5e1..abd421ff6f 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -411,6 +411,9 @@ export interface MergePrParams { * PrStaleHeadError so the pr-merge node can re-evaluate against the new head. */ expectedHeadOid?: string; + /** When true, enable GitHub auto-merge rather than merge immediately. The returned + * PR is normally still open; a deferred merge cannot honor expectedHeadOid. */ + auto?: boolean; } /** Thrown when a merge is rejected because the PR head moved (expectedHeadOid mismatch). */ @@ -422,6 +425,15 @@ export class PrStaleHeadError extends Error { } } +/** GitHub rejected enabling native auto-merge for this repository. */ +export class PrAutoMergeUnavailableError extends Error { + readonly code = "auto-merge-unavailable" as const; + constructor(message: string) { + super(message); + this.name = "PrAutoMergeUnavailableError"; + } +} + export interface UpdatePrParams { owner?: string; repo?: string; @@ -2230,13 +2242,14 @@ export class GitHubClient { } async mergePr(params: MergePrParams): Promise { + if (this.forceMode === "token") return this.mergePrWithApi(params); if (this.hasGhAuth()) { try { return await this.mergePrWithGh(params); } catch (err) { // A stale-head rejection is a real outcome, not a gh-vs-API fallback // trigger — re-running on the API path would merge the wrong head. - if (err instanceof PrStaleHeadError) throw err; + if (err instanceof PrStaleHeadError || err instanceof PrAutoMergeUnavailableError) throw err; if (this.token) { return this.mergePrWithApi(params); } @@ -2256,15 +2269,21 @@ export class GitHubClient { "pr", "merge", String(params.number), "--repo", `${resolved.owner}/${resolved.repo}`, `--${params.method ?? "squash"}`, - "--delete-branch", ]; - if (params.expectedHeadOid) { + if (params.auto) { + args.push("--auto"); + } + args.push("--delete-branch"); + if (!params.auto && params.expectedHeadOid) { args.push("--match-head-commit", params.expectedHeadOid); } try { runGh(args); } catch (err) { const message = getGhErrorMessage(err); + if (params.auto && /auto.?merge.*(not allowed|not enabled|disabled)|auto.?merge is not/i.test(message)) { + throw new PrAutoMergeUnavailableError(`GitHub native auto-merge is unavailable for PR #${params.number}: ${message}`); + } if ( params.expectedHeadOid && /head.*(changed|modified|match|stale)|not the most recent|base branch was modified/i.test(message) @@ -2278,29 +2297,75 @@ export class GitHubClient { private async mergePrWithApi(params: MergePrParams): Promise { const resolved = this.resolveRepo(params.owner, params.repo); + if (params.auto) { + const pullRequestId = await this.getPrNodeId(resolved.owner, resolved.repo, params.number); + const mergeMethod = (() => { + switch (params.method ?? "squash") { + case "merge": return "MERGE"; + case "rebase": return "REBASE"; + case "squash": return "SQUASH"; + } + })(); + try { + await this.runGraphqlOverToken( + `mutation($pullRequestId: ID!) { enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: ${mergeMethod} }) { pullRequest { id } } }`, + { pullRequestId }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/auto.?merge.*(not allowed|not enabled|disabled)|auto.?merge is not/i.test(message)) { + throw new PrAutoMergeUnavailableError(`GitHub native auto-merge is unavailable for PR #${params.number}: ${message}`); + } + throw error; + } + return this.getPrStatus(resolved.owner, resolved.repo, params.number); + } + const body: Record = { merge_method: params.method ?? "squash" }; if (params.expectedHeadOid) body.sha = params.expectedHeadOid; const response = await fetch( `${this.baseUrl}/repos/${encodeURIComponent(resolved.owner)}/${encodeURIComponent(resolved.repo)}/pulls/${params.number}/merge`, - { - method: "PUT", - headers: this.buildHeaders(), - body: JSON.stringify(body), - }, + { method: "PUT", headers: this.buildHeaders(), body: JSON.stringify(body) }, ); - if (!response.ok) { const error = await response.json().catch(() => ({ message: response.statusText })); - // 409 Conflict with a `sha` set means the head moved (stale-head race). if (params.expectedHeadOid && response.status === 409) { throw new PrStaleHeadError(`PR #${params.number} head moved since ${params.expectedHeadOid}; merge aborted`); } throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`); } - return this.getPrStatus(resolved.owner, resolved.repo, params.number); } + /* + FNXC:PrMergeAutoMerge 2026-08-09-09:28: + The API fallback is entered after gh failed, so native auto-merge GraphQL must remain + token-pinned rather than using helpers that opportunistically select gh again. + */ + private async runGraphqlOverToken(query: string, variables: Record): Promise { + this.requireToken(); + const response = await fetch(`${this.baseUrl}/graphql`, { + method: "POST", + headers: { ...this.buildHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + const payload = await response.json() as { data?: T; errors?: Array<{ message: string }> }; + if (!response.ok || payload.errors?.length) { + throw new Error(`GitHub API error: ${response.status} ${payload.errors?.[0]?.message || response.statusText}`); + } + return payload.data as T; + } + + private async getPrNodeId(owner: string, repo: string, number: number): Promise { + const data = await this.runGraphqlOverToken<{ repository?: { pullRequest?: { id?: string } | null } | null }>( + `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { id } } }`, + { owner, repo, number }, + ); + const id = data.repository?.pullRequest?.id; + if (!id) throw new Error(`GitHub did not return a node id for PR #${number}`); + return id; + } + /** * Reply to a specific review thread (U2). GraphQL only — REST has no * thread-level reply that also carries thread identity. Honors viewerCanReply @@ -2594,6 +2659,7 @@ export class GitHubClient { * Fetch current PR status using gh CLI if available, otherwise REST API. */ async getPrStatus(owner: string, repo: string, number: number): Promise { + if (this.forceMode === "token") return this.getPrStatusWithApi(owner, repo, number); if (this.hasGhAuth()) { try { return await this.getPrStatusWithGh(owner, repo, number); diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index c6aa39c65f..9042e094ca 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -17,7 +17,7 @@ export { type RuntimeLogSink, } from "./runtime-logger.js"; export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry, type SkillFileContent } from "./skills-adapter.js"; -export { GitHubClient, isPrMergeReady, closeGroupPullRequest, reconcileGroupPullRequest, buildGitHubIssueSource, isGitHubIssueAlreadyImported, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type DiscussionCategory, type DiscussionCandidate, type CreatedDiscussion, type CreateGroupPrResult } from "./github.js"; +export { GitHubClient, isPrMergeReady, closeGroupPullRequest, reconcileGroupPullRequest, buildGitHubIssueSource, isGitHubIssueAlreadyImported, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, PrAutoMergeUnavailableError, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type DiscussionCategory, type DiscussionCandidate, type CreatedDiscussion, type CreateGroupPrResult } from "./github.js"; export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js"; export { resolvePrConflicts, diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 6c5055753f..649fbad1f7 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -2352,7 +2352,7 @@ export function resolvePrMergeMethod( } } -async function mergeTaskPr( +export async function mergeTaskPr( scopedStore: TaskStore, task: Task, token: string | undefined, @@ -2377,10 +2377,11 @@ async function mergeTaskPr( 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) { + const nativeAutoMerge = settings.githubNativeAutoMerge === true; + if (!nativeAutoMerge && !mergeStatus.mergeReady) { throw conflict(`PR cannot merge: ${mergeStatus.blockingReasons.join("; ")}`); } - if (!mergeStatus.prInfo.headOid) { + if (!nativeAutoMerge && !mergeStatus.prInfo.headOid) { throw conflict("PR cannot merge: GitHub did not provide a head commit ID for the checked PR"); } @@ -2396,7 +2397,7 @@ async function mergeTaskPr( repo: repo.repo, number: task.prInfo.number, method, - expectedHeadOid: mergeStatus.prInfo.headOid, + ...(nativeAutoMerge ? { auto: true } : { expectedHeadOid: mergeStatus.prInfo.headOid }), }); const updated = { ...task.prInfo, @@ -2409,10 +2410,13 @@ async function mergeTaskPr( draft: mergedPrInfo.draft ?? mergedPrInfo.isDraft, } satisfies PrInfo; await scopedStore.updatePrInfo(task.id, updated); - await scopedStore.applyPrMergedTransition(task.id, { - agentId: "dashboard", - runId: `${runIdPrefix}-${task.id}-${Date.now()}`, - }); + // GitHub-native auto-merge is deferred; only a later refresh that observes merged may transition the task. + if (updated.status === "merged") { + await scopedStore.applyPrMergedTransition(task.id, { + agentId: "dashboard", + runId: `${runIdPrefix}-${task.id}-${Date.now()}`, + }); + } return updated; } catch (error) { let mergeStatus: Awaited> | undefined; @@ -2516,10 +2520,12 @@ export async function refreshPrInBackground( const client = new GitHubClient(token); const task = await store.getTask(taskId); const taskPrs = task ? getTaskPrList(task) : currentPrInfos; + const settings = await store.getSettings(); + const requiredCheckNames = resolveRequiredCheckNames(settings); for (const currentPrInfo of taskPrs) { - 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 reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number, { requiredCheckNames }); + const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number, { requiredCheckNames }); 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) { @@ -2570,7 +2576,12 @@ export async function refreshPrInBackground( const lastMergeErrorAt = prior?.lastMergeErrorAt ? Date.parse(prior.lastMergeErrorAt) : Number.NaN; const recentlyFailed = Number.isFinite(lastMergeErrorAt) && Date.now() - lastMergeErrorAt < 5 * 60 * 1000; - if (prior?.autoMergeOnGreen && mergeStatus.mergeReady && !recentlyFailed) { + if (prior?.autoMergeOnGreen && (settings.githubNativeAutoMerge === true || mergeStatus.mergeReady) && !recentlyFailed) { + /* + FNXC:PrMergeAutoMerge 2026-08-09-11:47: + Native auto-merge must be armed while checks are pending so GitHub, rather + than Fusion polling, owns the wait-for-green transition and retries remain safe. + */ await mergeTaskPr(store, task, token, undefined, "pr-refresh"); } } diff --git a/packages/engine/src/__tests__/project-engine-deferred-startup.test.ts b/packages/engine/src/__tests__/project-engine-deferred-startup.test.ts index 3b3631ef5d..973293dbee 100644 --- a/packages/engine/src/__tests__/project-engine-deferred-startup.test.ts +++ b/packages/engine/src/__tests__/project-engine-deferred-startup.test.ts @@ -130,6 +130,7 @@ vi.mock("../merger.js", () => ({ })); import { ProjectEngine } from "../project-engine.js"; +import { InProcessRuntime } from "../runtimes/in-process-runtime.js"; function makeEngine(projectId: string, skipNotifier: boolean) { return new ProjectEngine( @@ -151,6 +152,26 @@ describe("ProjectEngine deferred startup", () => { listTasks.mockResolvedValue([]); }); + it("forwards the per-engine PR callback factory to the runtime", () => { + const factory = vi.fn(); + new ProjectEngine( + { + projectId: "proj_pr_factory", + workingDirectory: "/tmp/proj_pr_factory", + isolationMode: "in-process", + maxConcurrent: 1, + maxWorktrees: 1, + }, + { on: vi.fn(), off: vi.fn() } as any, + { skipNotifier: true, createPrNodeGithubOps: factory }, + ); + + expect(InProcessRuntime).toHaveBeenLastCalledWith( + expect.objectContaining({ createPrNodeGithubOps: factory }), + expect.anything(), + ); + }); + it("clears stale merging statuses during start critical path", async () => { listTasks.mockResolvedValue([ { id: "FN-1", column: "in-review", status: "merging" }, diff --git a/packages/engine/src/project-engine-manager.ts b/packages/engine/src/project-engine-manager.ts index 769d13030b..58933112db 100644 --- a/packages/engine/src/project-engine-manager.ts +++ b/packages/engine/src/project-engine-manager.ts @@ -46,6 +46,7 @@ export interface EngineManagerOptions { createGroupPr?: ProjectEngineOptions["createGroupPr"]; syncGroupPr?: ProjectEngineOptions["syncGroupPr"]; prNodeGithubOps?: ProjectEngineOptions["prNodeGithubOps"]; + createPrNodeGithubOps?: ProjectEngineOptions["createPrNodeGithubOps"]; prReconcileGithubOps?: ProjectEngineOptions["prReconcileGithubOps"]; getTaskMergeBlocker?: ProjectEngineOptions["getTaskMergeBlocker"]; onInsightRunProcessed?: ProjectEngineOptions["onInsightRunProcessed"]; @@ -568,6 +569,7 @@ export class ProjectEngineManager { createGroupPr: this.options.createGroupPr, syncGroupPr: this.options.syncGroupPr, prNodeGithubOps: this.options.prNodeGithubOps, + createPrNodeGithubOps: this.options.createPrNodeGithubOps, prReconcileGithubOps: this.options.prReconcileGithubOps, getTaskMergeBlocker: this.options.getTaskMergeBlocker, onInsightRunProcessed: this.options.onInsightRunProcessed, diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 90e050c99e..5ca256b102 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -348,6 +348,11 @@ export interface ProjectEngineOptions { * kinds fail closed (value:"pr-nodes-unwired"). */ prNodeGithubOps?: PrNodeGithubOps; + /** + * Factory evaluated by the runtime after it owns its project TaskStore. + * Use this for callbacks that need project-scoped settings. + */ + createPrNodeGithubOps?: (store: TaskStore) => PrNodeGithubOps; /** * Node-agnostic GitHub reconcile ops (U4): the injected ETag-probe + * deep-fetch callbacks backing {@link PrReconciler}. Injected from the CLI @@ -653,6 +658,7 @@ export class ProjectEngine { ...config, ...(options.externalTaskStore ? { externalTaskStore: options.externalTaskStore } : {}), ...(options.prNodeGithubOps ? { prNodeGithubOps: options.prNodeGithubOps } : {}), + ...(options.createPrNodeGithubOps ? { createPrNodeGithubOps: options.createPrNodeGithubOps } : {}), }; this.runtime = new InProcessRuntime(runtimeConfig, centralCore); // Let the runtime's SelfHealingManager re-enqueue tasks directly into our diff --git a/packages/engine/src/project/project-runtime.ts b/packages/engine/src/project/project-runtime.ts index bbc96c4de5..313f1dbc82 100644 --- a/packages/engine/src/project/project-runtime.ts +++ b/packages/engine/src/project/project-runtime.ts @@ -70,6 +70,12 @@ export interface ProjectRuntimeConfig { * pr-* node kinds fail closed. */ prNodeGithubOps?: import("../merge/pr-nodes.js").PrNodeGithubOps; + /** + * Builds PR-node GitHub callbacks after this runtime has selected its own + * TaskStore. This binds project settings to the executing engine rather than + * asking process-wide CLI wiring to rediscover task ownership. + */ + createPrNodeGithubOps?: (store: TaskStore) => import("../merge/pr-nodes.js").PrNodeGithubOps; /** * Absolute URL of the dashboard's CLI-agent hook ingestion endpoint that * generated hook scripts POST to (e.g. `http://127.0.0.1:4040/api/cli-agent/hooks`). diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index bd6f70b502..9f9261633b 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -1344,7 +1344,14 @@ export class InProcessRuntime } } - const prNodeGithubOps = this.config.prNodeGithubOps; + /* + FNXC:PrMergeAutoMerge 2026-08-09-10:59: + Native auto-merge is project policy, so construct its CLI callbacks only + after this runtime owns the TaskStore. A process-wide callback cannot + safely infer which concurrently running project's setting applies. + */ + const prNodeGithubOps = this.config.createPrNodeGithubOps?.(this.taskStore) + ?? this.config.prNodeGithubOps; /* FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:30: `secretsEnv` materializes only through fresh executor and heartbeat worktree acquisitions. diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index a36c40690c..62ce026a79 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6377,7 +6377,9 @@ "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).", "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." + "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.", + "githubNativeAutoMerge": "Use GitHub native auto-merge", + "githubNativeAutoMergeHelp": "GitHub waits for its full ruleset before merging. The repository must allow auto-merge; Fusion fails closed if it is unavailable. Default: disabled." }, "mergeManually": "Merge Manually", "mobileNav": {