From 69e7a34077d0755c1381ccc7cb9422cc86410c51 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 08:01:44 -0700 Subject: [PATCH] FN-8273: add mission auto-merge overrides Allow operators to override project auto-merge behavior for each mission. - Add create and update API support for mission auto-merge overrides, including clearing inherited behavior. - Expose inherited, auto-merge, and single-pull-request choices in all mission forms. - Apply disabled mission auto-merge to newly triaged tasks and cover storage, API, and UI behavior. - Document the override and add a CLI changeset. Files changed: .changeset/fn-8273-mission-auto-merge.md | 7 ++ docs/missions.md | 4 + .../mission-store.sync-auto-merge.test.ts | 22 ++++ .../__tests__/postgres/mission-store.pg.test.ts | 35 ++++++ packages/core/src/async-mission-store.ts | 5 + packages/core/src/mission-store.ts | 5 + packages/dashboard/app/api/legacy.ts | 4 +- .../dashboard/app/components/MissionManager.tsx | 55 +++++++++ .../__tests__/MissionManager.auto-merge.test.tsx | 132 +++++++++++++++++++++ packages/dashboard/app/components/mission-types.ts | 2 + packages/dashboard/src/mission-routes.ts | 18 ++- .../routes/__tests__/mission-autoMerge-e2e.test.ts | 66 +++++++++++ 12 files changed, 351 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8273 Fusion-Task-Lineage: d24d7c22-5a14-4446-b3e1-c35b2f0480c3 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8273-mission-auto-merge.md | 7 + docs/missions.md | 4 + .../mission-store.sync-auto-merge.test.ts | 22 +++ .../postgres/mission-store.pg.test.ts | 35 +++++ packages/core/src/async-mission-store.ts | 5 + packages/core/src/mission-store.ts | 5 + packages/dashboard/app/api/legacy.ts | 4 +- .../app/components/MissionManager.tsx | 55 ++++++++ .../MissionManager.auto-merge.test.tsx | 132 ++++++++++++++++++ .../dashboard/app/components/mission-types.ts | 2 + packages/dashboard/src/mission-routes.ts | 18 ++- .../__tests__/mission-autoMerge-e2e.test.ts | 66 +++++++++ 12 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-8273-mission-auto-merge.md create mode 100644 packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts create mode 100644 packages/dashboard/app/components/__tests__/MissionManager.auto-merge.test.tsx create mode 100644 packages/dashboard/src/routes/__tests__/mission-autoMerge-e2e.test.ts diff --git a/.changeset/fn-8273-mission-auto-merge.md b/.changeset/fn-8273-mission-auto-merge.md new file mode 100644 index 0000000000..511988216b --- /dev/null +++ b/.changeset/fn-8273-mission-auto-merge.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a mission auto-merge override so a mission's features share one branch and one PR. +category: feature +dev: MissionManager create/edit tri-state control persists Mission.autoMerge; mission triage stamps task.autoMerge=false when the mission override is false. POST accepts autoMerge and PATCH null clears to inherited. diff --git a/docs/missions.md b/docs/missions.md index 16fe5084e4..992e7db329 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -97,6 +97,10 @@ Supported modes: The Mission Manager create/edit form exposes this as **Branch strategy** plus a conditional **Branch name** field for `existing` and `custom-new`. +### Mission auto-merge override + +The **Merge behavior** control can inherit the project default, explicitly enable auto-merge, or select **Single pull request**. The latter persists `autoMerge: false` on the mission and stamps newly triaged feature tasks with the same false override, while preserving the mission's shared branch group. Returning the control to inherited clears the mission override. + ### Shared branch-group invariant across entry points Across all branch entry points (planning/subtask creation, mission triage, and New Task `shared-group` creation), Fusion enforces one rule: diff --git a/packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts b/packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts new file mode 100644 index 0000000000..c6e67c8c7c --- /dev/null +++ b/packages/core/src/__tests__/mission-store.sync-auto-merge.test.ts @@ -0,0 +1,22 @@ +/* +FNXC:MissionAutoMerge 2026-07-18-12:00: +The legacy synchronous MissionStore remains a supported fallback even though PostgreSQL +is the production backend. Keep its create-only triage contract aligned with AsyncMissionStore: +only an explicit false mission override is forwarded to TaskStore.createTask. +*/ + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("MissionStore synchronous triage auto-merge contract", () => { + it("stamps only false on the synchronous create branch after the duplicate guard", async () => { + const source = await readFile(fileURLToPath(new URL("../mission-store.ts", import.meta.url)), "utf8"); + const triageFeature = source.slice(source.indexOf("async triageFeature("), source.indexOf("async triageSlice(")); + + expect(triageFeature).toContain('if (guard.action === "duplicate" && guard.existing)'); + expect(triageFeature).toContain("...(mission?.autoMerge === false ? { autoMerge: false } : {}),"); + expect(triageFeature.indexOf('if (guard.action === "duplicate" && guard.existing)')) + .toBeLessThan(triageFeature.indexOf("...(mission?.autoMerge === false ? { autoMerge: false } : {}),")); + }); +}); diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 18f81fcf88..d3ab7fcd48 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -135,6 +135,41 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(tree!.milestones[0]!.slices[0]!.features[0]!.id).toBe(feature.id); }); + it("stamps only autoMerge:false mission triage tasks while preserving the shared branch group", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Single PR", autoMerge: false }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const [single, bulk] = await Promise.all([ + m.addFeature(slice.id, { title: "Single" }), + m.addFeature(slice.id, { title: "Bulk" }), + ]); + + await m.triageFeature(single.id); + await m.triageSlice(slice.id); + const tasks = await h.store().listTasks(); + const triaged = tasks.filter((task) => ["Single", "Bulk"].includes(task.title)); + expect(triaged).toHaveLength(2); + expect(triaged.map((task) => task.autoMerge)).toEqual([false, false]); + // Single and bulk triage must join the one lazily-created mission group, not merely any group. + expect(new Set(triaged.map((task) => task.branchContext?.groupId))).toEqual(new Set([triaged[0]!.branchContext!.groupId])); + expect(triaged[0]!.branchContext?.groupId).toBeDefined(); + + }); + + it("leaves task autoMerge inherited for undefined and true mission overrides", async () => { + const m = missions(); + for (const autoMerge of [undefined, true] as const) { + const mission = await m.createMission({ title: `Inherited ${String(autoMerge)}`, autoMerge }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Feature" }); + await m.triageFeature(feature.id); + const task = (await h.store().listTasks()).find((candidate) => candidate.title === "Feature"); + expect(task?.autoMerge).toBeUndefined(); + } + }); + it("listMissionsWithSummaries returns hierarchy counts", async () => { const m = missions(); const mission = await m.createMission({ title: "Counted" }); diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 1018bb7139..3a5eeb5532 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -1653,6 +1653,11 @@ export class AsyncMissionStore extends EventEmitter { }, } : {}), + /* + FNXC:MissionAutoMerge 2026-07-18-12:00: + An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override. + */ + ...(mission?.autoMerge === false ? { autoMerge: false } : {}), ...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}), }); if (guard.fingerprint) { diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index e30dbf2d9f..e7a1e278cf 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -4066,6 +4066,11 @@ export class MissionStore extends EventEmitter { FNXC:MissionWorkflows 2026-06-25-00:00: Apply the selected Missions header workflow atomically during TaskStore.createTask so newly triaged features land in the intended workflow lane. Duplicate-guard reuses skip this create path, preserving existing duplicate tasks without workflow mutation. */ + /* + FNXC:MissionAutoMerge 2026-07-18-12:00: + An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override. + */ + ...(mission?.autoMerge === false ? { autoMerge: false } : {}), ...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}), }); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index ed2d66f6e4..ddc95f6cd2 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -7249,7 +7249,7 @@ export function fetchMissions(projectId?: string): Promise } /** Create a new mission */ -export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise { +export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; autoMerge?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise { return api(withProjectId("/missions", projectId), { method: "POST", body: JSON.stringify(input), @@ -7262,7 +7262,7 @@ export function fetchMission(missionId: string, projectId?: string): Promise, projectId?: string): Promise { +export function updateMission(missionId: string, updates: Partial & { autoMerge?: boolean | null }, projectId?: string): Promise { return api(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), { method: "PATCH", body: JSON.stringify(updates), diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 1ca0ad59d0..512da211e3 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -286,11 +286,15 @@ interface MissionBranchStrategy { branchName?: string; } +type MissionAutoMergeOverride = "inherit" | "on" | "off"; + interface MissionFormData { title: string; description: string; status: MissionStatus; autopilotEnabled: boolean; + /** FNXC:MissionAutoMerge 2026-07-18-12:00: Preserve inherited project behavior until an operator explicitly selects an override. */ + autoMergeOverride: MissionAutoMergeOverride; baseBranch: string; branchStrategy: MissionBranchStrategy; } @@ -321,6 +325,7 @@ const EMPTY_MISSION_FORM: MissionFormData = { description: "", status: "planning", autopilotEnabled: false, + autoMergeOverride: "inherit", baseBranch: "", branchStrategy: { mode: "project-default", @@ -348,6 +353,14 @@ const EMPTY_FEATURE_FORM: FeatureFormData = { status: "defined", }; +function missionAutoMergeOverride(autoMerge?: boolean): MissionAutoMergeOverride { + return autoMerge === true ? "on" : autoMerge === false ? "off" : "inherit"; +} + +function resolveMissionAutoMerge(override: MissionAutoMergeOverride): boolean | undefined { + return override === "on" ? true : override === "off" ? false : undefined; +} + function normalizeMissionBranchStrategy(strategy?: Mission["branchStrategy"]): MissionBranchStrategy { if (!strategy) { return { mode: "project-default" }; @@ -1607,6 +1620,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr description: mission.description || "", status: mission.status, autopilotEnabled: mission.autopilotEnabled ?? false, + autoMergeOverride: missionAutoMergeOverride(mission.autoMerge), baseBranch: mission.baseBranch ?? "", branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy), }); @@ -1644,6 +1658,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr title: missionForm.title.trim(), description: missionForm.description.trim() || undefined, autopilotEnabled: missionForm.autopilotEnabled, + ...(resolveMissionAutoMerge(missionForm.autoMergeOverride) !== undefined + ? { autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) } + : {}), baseBranch: missionForm.baseBranch.trim() || undefined, branchStrategy, }, projectId); @@ -1656,6 +1673,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr description: missionForm.description.trim() || undefined, status: missionForm.status, autopilotEnabled: missionForm.autopilotEnabled, + // FNXC:MissionAutoMerge 2026-07-18-12:00: JSON drops undefined, so edit inheritance must use null to clear a saved override. + autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) ?? null, baseBranch: missionForm.baseBranch.trim() || "", branchStrategy, }; @@ -2829,6 +2848,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr + {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( + {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( + {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (