FN-8884: enable GitHub-native PR auto-merge

Enable opt-in GitHub-managed auto-merge for pull requests.

- Add a project setting and dashboard control for GitHub native auto-merge.
- Arm individual, group, dashboard, and workflow PRs with `gh pr merge --auto` or GraphQL, including while checks are pending.
- Preserve deferred PR reconciliation, unavailable-feature errors, project-scoped runtime configuration, tests, documentation, and a release changeset.

Files changed:
 .changeset/fn-8884-github-native-auto-merge.md     |   7 +
 docs/settings-reference.md                         |   5 +
 packages/cli/src/commands/__tests__/daemon.test.ts |  16 +++
 packages/cli/src/commands/__tests__/serve.test.ts  |  14 ++
 .../src/commands/__tests__/task-lifecycle.test.ts  | 158 +++++++++++++++++++++
 packages/cli/src/commands/daemon.ts                |  11 +-
 packages/cli/src/commands/dashboard.ts             |  11 +-
 packages/cli/src/commands/serve.ts                 |  11 +-
 packages/cli/src/commands/task-lifecycle.ts        |  45 ++++--
 .../core/src/__tests__/settings-defaults.test.ts   |   4 +
 packages/core/src/config/settings-schema.ts        |   1 +
 packages/core/src/types/settings/settings-scope.ts |   8 ++
 .../settings/__tests__/section-keys.test.ts        |   1 +
 .../app/components/settings/section-keys.ts        |   1 +
 .../components/settings/sections/MergeSection.tsx  |  14 +-
 .../settings-default-descriptions.test.tsx         |   1 +
 .../src/__tests__/github-native-auto-merge.test.ts | 114 +++++++++++++++
 .../src/__tests__/routes-pr-merge.test.ts          |  84 +++++++++++
 packages/dashboard/src/github.ts                   |  88 +++++++++++--
 packages/dashboard/src/index.ts                    |   2 +-
 .../dashboard/src/routes/register-git-github.ts    |  33 +++--
 .../project-engine-deferred-startup.test.ts        |  21 +++
 packages/engine/src/project-engine-manager.ts      |   2 +
 packages/engine/src/project-engine.ts              |   6 +
 packages/engine/src/project/project-runtime.ts     |   6 +
 packages/engine/src/runtimes/in-process-runtime.ts |   9 +-
 packages/i18n/locales/en/app.json                  |   4 +-
 27 files changed, 634 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-8884

Fusion-Task-Lineage: cf1b1ea4-7ef7-4050-bd87-25933456a5b6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-09 04:50:30 -07:00
parent c60a11607f
commit 2643f4e567
27 changed files with 634 additions and 43 deletions

View File

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

View File

@@ -493,6 +493,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
| `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 `<remote>/<defaultBranch>`; 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.

View File

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

View File

@@ -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

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();

View File

@@ -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),

View File

@@ -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,

View File

@@ -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),

View File

@@ -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<PrInfo>;
mergePr(params: { owner?: string; repo?: string; number: number; method?: "merge" | "squash" | "rebase"; expectedHeadOid?: string; auto?: boolean }): Promise<PrInfo>;
getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo>;
/** Reply to a specific review thread (U2). */
replyToReviewThread(threadId: string, body: string): Promise<void>;
@@ -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<boolean>;
} = {},
): 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<ReturnType<GitHubOperations["getPrMergeStatus"]>>;
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";
}

View File

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

View File

@@ -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",

View File

@@ -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

View File

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

View File

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

View File

@@ -278,7 +278,8 @@ export function MergeSection({ form, setForm, integrationBranchOptions, integrat
<option value="pull-request">{t("settings.merge.createMonitorAndMergeAGitHubPullRequest", "Create, monitor, and merge a GitHub pull request")}</option>
</select>
</div>
{form.mergeStrategy === "pull-request" && <div className="form-group">
{form.mergeStrategy === "pull-request" && <>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="requiredChecks">{t("settings.merge.requiredChecks", "Required pull-request checks")}</label>
<SettingsHelpTip settingKey="requiredChecks">{t("settings.merge.requiredChecksHelp", "No default — unset. Comma-separated check names match GitHub exactly (case-sensitive). Leaving this empty uses GitHub required-status checks only; a named check that never reports blocks the merge.")}</SettingsHelpTip>
@@ -290,7 +291,16 @@ export function MergeSection({ form, setForm, integrationBranchOptions, integrat
emittedRequiredCheckNames.current = requiredChecks;
setForm((current) => ({ ...current, requiredChecks: requiredChecks.length > 0 ? requiredChecks : undefined }));
}}/>
</div>}
</div>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="githubNativeAutoMerge" className="checkbox-label">
<input id="githubNativeAutoMerge" type="checkbox" checked={form.githubNativeAutoMerge === true} onChange={(e) => setForm((f) => ({ ...f, githubNativeAutoMerge: e.target.checked }))}/>{t("settings.merge.githubNativeAutoMerge", "Use GitHub native auto-merge")}
</label>
<SettingsHelpTip settingKey="githubNativeAutoMerge">{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.")}</SettingsHelpTip>
</div>
</div>
</>}
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="integrationBranch">{t("settings.merge.integrationBranch", "Integration branch")}</label>

View File

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

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@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);
});
});

View File

@@ -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<string, unknown> = {}) {
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();
});
});

View File

@@ -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<PrInfo> {
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<PrInfo> {
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<string, string> = { 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<T>(query: string, variables: Record<string, string | number>): Promise<T> {
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<string> {
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<PrInfo> {
if (this.forceMode === "token") return this.getPrStatusWithApi(owner, repo, number);
if (this.hasGhAuth()) {
try {
return await this.getPrStatusWithGh(owner, repo, number);

View File

@@ -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,

View File

@@ -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<ReturnType<GitHubClient["getPrMergeStatus"]>> | 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");
}
}

View File

@@ -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" },

View File

@@ -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,

View File

@@ -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

View File

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

View File

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

View File

@@ -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": {