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:
7
.changeset/fn-8884-github-native-auto-merge.md
Normal file
7
.changeset/fn-8884-github-native-auto-merge.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("settings section-keys registry", () => {
|
||||
"mergeConflictStrategy",
|
||||
"mergeIntegrationWorktree",
|
||||
"mergeStrategy",
|
||||
"githubNativeAutoMerge",
|
||||
"requiredChecks",
|
||||
"mergeStrategyOverlapBehavior",
|
||||
"merger",
|
||||
|
||||
@@ -171,6 +171,7 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
|
||||
"mergeConflictStrategy",
|
||||
"mergeIntegrationWorktree",
|
||||
"mergeStrategy",
|
||||
"githubNativeAutoMerge",
|
||||
"requiredChecks",
|
||||
"mergeStrategyOverlapBehavior",
|
||||
"merger",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user