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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8273-mission-auto-merge.md
Normal file
7
.changeset/fn-8273-mission-auto-merge.md
Normal file
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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 } : {}),"));
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -1653,6 +1653,11 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
/*
|
||||
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) {
|
||||
|
||||
@@ -4066,6 +4066,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
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 } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -7249,7 +7249,7 @@ export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]>
|
||||
}
|
||||
|
||||
/** Create a new mission */
|
||||
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise<Mission> {
|
||||
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; autoMerge?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise<Mission> {
|
||||
return api<Mission>(withProjectId("/missions", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
@@ -7262,7 +7262,7 @@ export function fetchMission(missionId: string, projectId?: string): Promise<Mis
|
||||
}
|
||||
|
||||
/** Update mission */
|
||||
export function updateMission(missionId: string, updates: Partial<Mission>, projectId?: string): Promise<Mission> {
|
||||
export function updateMission(missionId: string, updates: Partial<Mission> & { autoMerge?: boolean | null }, projectId?: string): Promise<Mission> {
|
||||
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
|
||||
@@ -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
|
||||
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("missions.autoMergeOverride", "Merge behavior")}
|
||||
<select
|
||||
value={missionForm.autoMergeOverride}
|
||||
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })}
|
||||
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
|
||||
>
|
||||
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
|
||||
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
|
||||
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
|
||||
<label>
|
||||
{t("missions.branchName", "Branch name")}
|
||||
@@ -4554,6 +4585,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("missions.autoMergeOverride", "Merge behavior")}
|
||||
<select
|
||||
value={missionForm.autoMergeOverride}
|
||||
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })}
|
||||
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
|
||||
>
|
||||
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
|
||||
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
|
||||
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
|
||||
<label>
|
||||
{t("missions.branchName", "Branch name")}
|
||||
@@ -4648,6 +4691,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("missions.autoMergeOverride", "Merge behavior")}
|
||||
<select
|
||||
value={missionForm.autoMergeOverride}
|
||||
onChange={(e) => setMissionForm({ ...missionForm, autoMergeOverride: e.target.value as MissionAutoMergeOverride })}
|
||||
aria-label={t("missions.autoMergeOverrideAriaLabel", "Mission auto-merge override")}
|
||||
>
|
||||
<option value="inherit">{t("missions.autoMergeInherited", "Use project default")}</option>
|
||||
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
|
||||
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
|
||||
<label>
|
||||
{t("missions.branchName", "Branch name")}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
FNXC:MissionAutoMerge 2026-07-18-12:00:
|
||||
Mission edits need an explicit inherited state: the client must send null rather than
|
||||
undefined so JSON serialization clears an existing mission auto-merge override.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MissionManager } from "../MissionManager";
|
||||
|
||||
const mockFetchMissions = vi.fn();
|
||||
const mockFetchMission = vi.fn();
|
||||
const mockFetchMissionsHealth = vi.fn();
|
||||
const mockFetchAiSessions = vi.fn();
|
||||
const mockFetchMissionInterviewDrafts = vi.fn();
|
||||
const mockUpdateMission = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
|
||||
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
|
||||
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
|
||||
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
|
||||
updateMission: (...args: unknown[]) => mockUpdateMission(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const now = "2026-07-18T12:00:00.000Z";
|
||||
|
||||
function mission(autoMerge?: boolean) {
|
||||
return {
|
||||
id: "M-001",
|
||||
title: "Single PR Mission",
|
||||
description: "",
|
||||
status: "planning",
|
||||
autoMerge,
|
||||
milestones: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function setDesktopViewport() {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1440, configurable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function openEditForm(autoMerge?: boolean) {
|
||||
const detail = mission(autoMerge);
|
||||
mockFetchMissions.mockResolvedValue([detail]);
|
||||
mockFetchMission.mockResolvedValue(detail);
|
||||
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
|
||||
fireEvent.click(await screen.findByText("Single PR Mission"));
|
||||
const editButtons = await screen.findAllByRole("button", { name: "Edit mission" });
|
||||
fireEvent.click(editButtons[0]!);
|
||||
return screen.getByLabelText("Mission auto-merge override") as HTMLSelectElement;
|
||||
}
|
||||
|
||||
describe("MissionManager auto-merge override", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
setDesktopViewport();
|
||||
mockFetchMissionsHealth.mockResolvedValue({});
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
|
||||
mockUpdateMission.mockResolvedValue(mission());
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "inherit"],
|
||||
[true, "on"],
|
||||
[false, "off"],
|
||||
] as const)("reflects a %s mission override as %s", async (autoMerge, expected) => {
|
||||
const control = await openEditForm(autoMerge);
|
||||
expect(control.value).toBe(expected);
|
||||
});
|
||||
|
||||
it("sends null when an existing override is returned to inherited", async () => {
|
||||
const control = await openEditForm(false);
|
||||
fireEvent.change(control, { target: { value: "inherit" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMission).toHaveBeenCalledWith(
|
||||
"M-001",
|
||||
expect.objectContaining({ autoMerge: null }),
|
||||
"project-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["on", true],
|
||||
["off", false],
|
||||
] as const)("sends %s as an explicit %s override", async (selection, expected) => {
|
||||
const control = await openEditForm();
|
||||
fireEvent.change(control, { target: { value: selection } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMission).toHaveBeenCalledWith(
|
||||
"M-001",
|
||||
expect.objectContaining({ autoMerge: expected }),
|
||||
"project-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,8 @@ export interface Mission {
|
||||
};
|
||||
status: MissionStatus;
|
||||
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
||||
/** Mission-level auto-merge override for linked task branches. */
|
||||
autoMerge?: boolean;
|
||||
autoAdvance?: boolean;
|
||||
autopilotEnabled?: boolean;
|
||||
autopilotState?: AutopilotState;
|
||||
|
||||
@@ -442,7 +442,7 @@ export function createMissionRouter(
|
||||
router.post(
|
||||
"/",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { title, description, autoAdvance, baseBranch, branchStrategy, goalIds } = req.body;
|
||||
const { title, description, autoAdvance, autoMerge, baseBranch, branchStrategy, goalIds } = req.body;
|
||||
|
||||
const validatedTitle = validateTitle(title);
|
||||
const validatedDescription = validateDescription(description);
|
||||
@@ -453,6 +453,14 @@ export function createMissionRouter(
|
||||
description: validatedDescription,
|
||||
baseBranch: validateDescription(baseBranch),
|
||||
branchStrategy: validateMissionBranchStrategy(branchStrategy),
|
||||
...(autoMerge !== undefined
|
||||
? {
|
||||
// FNXC:MissionAutoMerge 2026-07-18-12:00: Create accepts only a real boolean; null is reserved for PATCH clear-to-inherited.
|
||||
autoMerge: typeof autoMerge === "boolean"
|
||||
? validateBoolean(autoMerge, "autoMerge")
|
||||
: (() => { throw badRequest("autoMerge must be a boolean"); })(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const mission = await missionStore.createMission(input);
|
||||
@@ -1079,7 +1087,7 @@ export function createMissionRouter(
|
||||
"/:missionId",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const { title, description, status, autoAdvance, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
|
||||
const { title, description, status, autoAdvance, autoMerge, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
throw badRequest("Invalid mission ID format");
|
||||
@@ -1105,6 +1113,12 @@ export function createMissionRouter(
|
||||
if (autoAdvance !== undefined) {
|
||||
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
|
||||
}
|
||||
// FNXC:MissionAutoMerge 2026-07-18-12:00: PATCH null explicitly clears a mission override; omission preserves it.
|
||||
if (autoMerge === null) {
|
||||
updates.autoMerge = undefined;
|
||||
} else if (autoMerge !== undefined) {
|
||||
updates.autoMerge = validateBoolean(autoMerge, "autoMerge");
|
||||
}
|
||||
if (autopilotEnabled !== undefined) {
|
||||
updates.autopilotEnabled = validateBoolean(autopilotEnabled, "autopilotEnabled");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment node
|
||||
|
||||
/*
|
||||
FNXC:MissionAutoMerge 2026-07-18-12:00:
|
||||
The mission HTTP surface must preserve boolean overrides and accept PATCH null as the
|
||||
explicit clear operation. Triage through the production router then stamps only false
|
||||
onto the created task while retaining its shared mission BranchGroup context.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
pgDescribe("mission autoMerge routes", () => {
|
||||
let harness: PgTestHarness;
|
||||
let store: TaskStore;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createTaskStoreForTest();
|
||||
store = harness.store;
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.teardown();
|
||||
});
|
||||
|
||||
const request = (method: "POST" | "PATCH", path: string, body: unknown) =>
|
||||
REQUEST(app, method, path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
|
||||
it("round-trips false, true, and clear-to-inherited while triage stamps false", async () => {
|
||||
const created = await request("POST", "/api/missions", { title: "Single PR", autoMerge: false });
|
||||
expect(created.status).toBe(201);
|
||||
const missionId = (created.body as { id: string; autoMerge?: boolean }).id;
|
||||
expect((created.body as { autoMerge?: boolean }).autoMerge).toBe(false);
|
||||
|
||||
const enabled = await request("PATCH", `/api/missions/${missionId}`, { autoMerge: true });
|
||||
expect(enabled.status).toBe(200);
|
||||
expect((enabled.body as { autoMerge?: boolean }).autoMerge).toBe(true);
|
||||
|
||||
const cleared = await request("PATCH", `/api/missions/${missionId}`, { autoMerge: null });
|
||||
expect(cleared.status).toBe(200);
|
||||
expect((cleared.body as { autoMerge?: boolean }).autoMerge).toBeUndefined();
|
||||
expect((await store.getMissionStore().getMission(missionId))?.autoMerge).toBeUndefined();
|
||||
|
||||
const nullCreate = await request("POST", "/api/missions", { title: "Invalid null", autoMerge: null });
|
||||
expect(nullCreate.status).toBe(400);
|
||||
|
||||
const missionStore = store.getMissionStore();
|
||||
const falseMission = await missionStore.createMission({ title: "False triage", autoMerge: false });
|
||||
const milestone = await missionStore.addMilestone(falseMission.id, { title: "Milestone" });
|
||||
const slice = await missionStore.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = await missionStore.addFeature(slice.id, { title: "Task is held for one PR" });
|
||||
const triaged = await request("POST", `/api/missions/features/${feature.id}/triage`, {});
|
||||
expect(triaged.status).toBe(200);
|
||||
const task = await store.getTask((triaged.body as { taskId: string }).taskId);
|
||||
expect(task?.autoMerge).toBe(false);
|
||||
expect(task?.branchContext?.groupId).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user