FN-8275: clarify mission merge behavior

Clarify mission merge choices and surface shared branch status in mission detail.

- Explain auto-merge and single-pull-request behavior in mission forms
- Show mission-owned shared branch member and PR status
- Cover UI behavior and PostgreSQL triage auto-merge cascading

Files changed:
 .changeset/fn-8275-mission-merge-summary.md        |  7 ++
 docs/missions.md                                   |  2 +-
 .../dashboard/app/components/MissionManager.tsx    | 75 ++++++++++++++++++++++
 .../__tests__/MissionManager.auto-merge.test.tsx   | 64 ++++++++++++++++++
 .../mission-auto-merge-cascade.pg.test.ts          | 58 +++++++++++++++++
 5 files changed, 205 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-8275

Fusion-Task-Lineage: cd259293-b68e-4bc5-803e-40d2287d8438

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 09:41:25 -07:00
parent 119fcedcfe
commit 2b455672c6
5 changed files with 205 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Mission auto-merge controls now explain merge behavior and show shared branch PR status.
category: feature
dev: Mission detail resolves its group from linked task branchContext data and adds engine cascade coverage.

View File

@@ -99,7 +99,7 @@ The Mission Manager create/edit form exposes this as **Branch strategy** plus a
### Mission auto-merge override ### 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. The **Merge behavior** control can inherit the project default, explicitly enable auto-merge, or select **Single pull request**. In-context help explains that auto-merge lands each feature individually while a single pull request retains all features on a shared branch for joint review. 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. Once a shared branch has members, Mission detail displays its branch name, member count, and PR state.
### Shared branch-group invariant across entry points ### Shared branch-group invariant across entry points

View File

@@ -100,8 +100,11 @@ import {
fetchAiSession, fetchAiSession,
fetchMissionInterviewDrafts, fetchMissionInterviewDrafts,
discardMissionInterviewDraft, discardMissionInterviewDraft,
fetchTaskDetail,
apiGetBranchGroup,
api, api,
type AiSessionSummary, type AiSessionSummary,
type BranchGroupSummary,
} from "../api"; } from "../api";
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types"; import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache"; import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
@@ -640,6 +643,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const initialMissions = readCache<MissionWithSummary[]>(missionsCacheKey); const initialMissions = readCache<MissionWithSummary[]>(missionsCacheKey);
const [missions, setMissions] = useState<MissionWithSummary[]>(() => (Array.isArray(initialMissions) ? initialMissions : [])); const [missions, setMissions] = useState<MissionWithSummary[]>(() => (Array.isArray(initialMissions) ? initialMissions : []));
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null); const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [selectedMissionBranchGroup, setSelectedMissionBranchGroup] = useState<BranchGroupSummary | null>(null);
const [loading, setLoading] = useState(!(Array.isArray(initialMissions) && initialMissions.length > 0)); const [loading, setLoading] = useState(!(Array.isArray(initialMissions) && initialMissions.length > 0));
const hasHydratedRef = useRef(Array.isArray(initialMissions) && initialMissions.length > 0); const hasHydratedRef = useRef(Array.isArray(initialMissions) && initialMissions.length > 0);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
@@ -1121,6 +1125,41 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
} }
}, [addToast, loadAssertionsForMilestone, projectId]); }, [addToast, loadAssertionsForMilestone, projectId]);
useEffect(() => {
let cancelled = false;
setSelectedMissionBranchGroup(null);
const selectedMissionId = selectedMission?.id;
const taskIds = selectedMission?.milestones.flatMap((milestone) =>
milestone.slices.flatMap((slice) => slice.features.flatMap((feature) => feature.taskId ? [feature.taskId] : [])),
) ?? [];
if (!selectedMissionId || taskIds.length === 0) return () => { cancelled = true; };
void (async () => {
for (const taskId of taskIds) {
try {
const task = await fetchTaskDetail(taskId, projectId);
const groupId = task.branchContext?.source === "mission" ? task.branchContext.groupId : undefined;
if (!groupId) continue;
const { group } = await apiGetBranchGroup(groupId, projectId);
/*
FNXC:MissionAutoMerge 2026-07-19-00:00:
A task can retain a stale or colliding branch-group id. Only show a group that
is owned by the selected mission so its detail pane never reports another
mission's branch or PR state.
*/
if (group?.sourceType !== "mission" || group.sourceId !== selectedMissionId) continue;
if (!cancelled) setSelectedMissionBranchGroup(group);
return;
} catch {
// A stale/deleted linked task or unavailable group must not break mission detail rendering.
}
}
})();
return () => { cancelled = true; };
}, [projectId, selectedMission]);
useEffect(() => { useEffect(() => {
if (!isActive || !selectedMilestoneId) { if (!isActive || !selectedMilestoneId) {
setValidationTelemetry(null); setValidationTelemetry(null);
@@ -2739,6 +2778,29 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)} )}
</div> </div>
{/*
FNXC:MissionAutoMerge 2026-07-19-00:00:
A mission detail must show its actual shared branch, member count, and PR state. Resolve the group through linked member tasks' branchContext.groupId rather than source ownership because branch-name collisions can reuse another mission's group.
*/}
{selectedMissionBranchGroup && (
<div className="mission-detail__autopilot" data-testid="mission-shared-branch-summary">
<div className="mission-detail__autopilot-toggle">
<span>{t("missions.sharedBranch", "Shared branch")}: {selectedMissionBranchGroup.branchName}</span>
<span
className="mission-status-badge mission-status-badge--sm"
style={{ backgroundColor: "var(--surface-hover)", color: "var(--text-muted)" }}
>
{selectedMissionBranchGroup.prState === "none"
? t("missions.noPullRequest", "No PR")
: t(`missions.prState.${selectedMissionBranchGroup.prState}`, selectedMissionBranchGroup.prState)}
</span>
</div>
<span className="mission-detail__autopilot-description">
{t("missions.sharedBranchMembers", "{{count}} member", { count: selectedMissionBranchGroup.completion.total })}
</span>
</div>
)}
<div className="mission-detail__actions"> <div className="mission-detail__actions">
<div className="mission-detail__run-controls"> <div className="mission-detail__run-controls">
{selectedMission.status === "active" && ( {selectedMission.status === "active" && (
@@ -2859,6 +2921,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option> <option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option> <option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select> </select>
{/*
FNXC:MissionAutoMerge 2026-07-19-00:00:
Operators need in-context guidance for the per-mission merge choice: auto-merge lands each feature independently, while a single pull request keeps all features on one shared branch for joint review.
*/}
<span className="mission-detail__autopilot-description">
{t("missions.autoMergeOverrideDescription", "Auto-merge merges each feature as it passes. Single pull request keeps every feature on one shared branch to review and merge together.")}
</span>
</label> </label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label> <label>
@@ -4596,6 +4665,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option> <option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option> <option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select> </select>
<span className="mission-detail__autopilot-description">
{t("missions.autoMergeOverrideDescription", "Auto-merge merges each feature as it passes. Single pull request keeps every feature on one shared branch to review and merge together.")}
</span>
</label> </label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label> <label>
@@ -4702,6 +4774,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option> <option value="on">{t("missions.autoMergeOn", "Auto-merge")}</option>
<option value="off">{t("missions.singlePullRequest", "Single pull request")}</option> <option value="off">{t("missions.singlePullRequest", "Single pull request")}</option>
</select> </select>
<span className="mission-detail__autopilot-description">
{t("missions.autoMergeOverrideDescription", "Auto-merge merges each feature as it passes. Single pull request keeps every feature on one shared branch to review and merge together.")}
</span>
</label> </label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && ( {(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label> <label>

View File

@@ -14,6 +14,8 @@ const mockFetchMissionsHealth = vi.fn();
const mockFetchAiSessions = vi.fn(); const mockFetchAiSessions = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn(); const mockFetchMissionInterviewDrafts = vi.fn();
const mockUpdateMission = vi.fn(); const mockUpdateMission = vi.fn();
const mockFetchTaskDetail = vi.fn();
const mockGetBranchGroup = vi.fn();
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>(); const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
@@ -33,6 +35,8 @@ vi.mock("../../api", async (importOriginal) => {
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args), fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
updateMission: (...args: unknown[]) => mockUpdateMission(...args), updateMission: (...args: unknown[]) => mockUpdateMission(...args),
fetchTaskDetail: (...args: unknown[]) => mockFetchTaskDetail(...args),
apiGetBranchGroup: (...args: unknown[]) => mockGetBranchGroup(...args),
}; };
}); });
@@ -88,6 +92,18 @@ describe("MissionManager auto-merge override", () => {
mockFetchAiSessions.mockResolvedValue([]); mockFetchAiSessions.mockResolvedValue([]);
mockFetchMissionInterviewDrafts.mockResolvedValue([]); mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockUpdateMission.mockResolvedValue(mission()); mockUpdateMission.mockResolvedValue(mission());
mockFetchTaskDetail.mockResolvedValue({});
mockGetBranchGroup.mockResolvedValue({ group: null });
});
it("renders merge behavior guidance in both mission edit forms", async () => {
await openEditForm();
expect(screen.getByText(/Single pull request keeps every feature/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
const editButtons = screen.getAllByRole("button", { name: "Edit mission" });
fireEvent.click(editButtons.at(-1)!);
expect(screen.getByText(/Single pull request keeps every feature/i)).toBeInTheDocument();
}); });
it.each([ it.each([
@@ -113,6 +129,54 @@ describe("MissionManager auto-merge override", () => {
}); });
}); });
it("rejects a reused branch group owned by a different mission", async () => {
const first = {
...mission(false),
milestones: [{ id: "MS-001", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-001", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [{ id: "F-001", title: "Feature", taskId: "FN-001", status: "triaged", createdAt: now, updatedAt: now }] }] }],
};
const second = {
...mission(false), id: "M-002", title: "Collision Mission",
milestones: [{ id: "MS-002", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-002", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [{ id: "F-002", title: "Feature", taskId: "FN-002", status: "triaged", createdAt: now, updatedAt: now }] }] }],
};
mockFetchMissions.mockResolvedValue([first, second]);
mockFetchMission.mockImplementation((id: string) => Promise.resolve(id === "M-001" ? first : second));
mockFetchTaskDetail.mockImplementation((id: string) => Promise.resolve({
id,
branchContext: { source: "mission", groupId: "BG-001", assignmentMode: "shared" },
}));
mockGetBranchGroup.mockResolvedValue({ group: {
id: "BG-001", sourceType: "mission", sourceId: "M-001", branchName: "main", autoMerge: false,
prState: "open", status: "open", createdAt: 0, updatedAt: 0, members: [], completion: { landed: 0, total: 2, complete: false },
} });
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Collision Mission"));
await waitFor(() => expect(mockGetBranchGroup).toHaveBeenCalledWith("BG-001", "project-1"));
expect(screen.queryByTestId("mission-shared-branch-summary")).toBeNull();
});
it("shows the selected mission's owned branch group without an action button", async () => {
const detail = {
...mission(false),
milestones: [{ id: "MS-001", title: "Milestone", status: "planning", createdAt: now, updatedAt: now, slices: [{ id: "SL-001", title: "Slice", status: "pending", createdAt: now, updatedAt: now, features: [{ id: "F-001", title: "Feature", taskId: "FN-001", status: "triaged", createdAt: now, updatedAt: now }] }] }],
};
mockFetchMissions.mockResolvedValue([detail]);
mockFetchMission.mockResolvedValue(detail);
mockFetchTaskDetail.mockResolvedValue({ id: "FN-001", branchContext: { source: "mission", groupId: "BG-001", assignmentMode: "shared" } });
mockGetBranchGroup.mockResolvedValue({ group: {
id: "BG-001", sourceType: "mission", sourceId: "M-001", branchName: "main", autoMerge: false,
prState: "open", status: "open", createdAt: 0, updatedAt: 0, members: [], completion: { landed: 0, total: 2, complete: false },
} });
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} projectId="project-1" />);
fireEvent.click(await screen.findByText("Single PR Mission"));
const summary = await screen.findByTestId("mission-shared-branch-summary");
expect(summary).toHaveTextContent("main");
expect(summary).toHaveTextContent("2 member");
expect(summary).toHaveTextContent("open");
expect(summary.querySelector("button")).toBeNull();
});
it.each([ it.each([
["on", true], ["on", true],
["off", false], ["off", false],

View File

@@ -0,0 +1,58 @@
/*
FNXC:MissionAutoMerge 2026-07-19-00:00:
Engine coverage must prove the production PostgreSQL MissionStore path stamps only
single-pull-request mission tasks and puts every member in the same lazy branch group.
*/
import { expect, it } from "vitest";
import type { AsyncMissionStore } from "@fusion/core";
import { createTaskStoreForTest, pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
pgDescribe("mission auto-merge cascade PostgreSQL", () => {
it("stamps false missions and preserves inherited true or undefined missions", async () => {
const harness = await createTaskStoreForTest({ prefix: "fusion_mission_auto_merge_cascade" });
try {
const missions = harness.store.getMissionStore() as AsyncMissionStore;
const singlePr = await missions.createMission({ title: "Single PR", autoMerge: false });
const milestone = await missions.addMilestone(singlePr.id, { title: "Milestone" });
const slice = await missions.addSlice(milestone.id, { title: "Slice" });
const first = await missions.addFeature(slice.id, { title: "First feature" });
const second = await missions.addFeature(slice.id, { title: "Second feature" });
await missions.triageFeature(first.id);
await missions.triageFeature(second.id);
const tasks = (await harness.store.listTasks()).filter((task) =>
["First feature", "Second feature"].includes(task.title),
);
expect(tasks).toHaveLength(2);
expect(tasks.map((task) => task.autoMerge)).toEqual([false, false]);
const groupIds = new Set(tasks.map((task) => task.branchContext?.groupId));
expect(groupIds.size).toBe(1);
const groupId = tasks[0]?.branchContext?.groupId;
expect(groupId).toBeDefined();
// FNXC:MissionAutoMerge 2026-07-19-16:35:
// A shared task context is useful only when it points to the lazily materialized
// group that owns this mission and contains every triaged member. Assert the
// persisted group rather than accepting a matching but dangling group id.
const group = await harness.store.getBranchGroup(groupId!);
expect(group).toMatchObject({
id: groupId,
sourceType: "mission",
sourceId: singlePr.id,
});
expect(await harness.store.listTasksByBranchGroup(groupId!)).toHaveLength(2);
for (const autoMerge of [true, undefined] as const) {
const mission = await missions.createMission({ title: `Inherited ${String(autoMerge)}`, autoMerge });
const inheritedMilestone = await missions.addMilestone(mission.id, { title: "Milestone" });
const inheritedSlice = await missions.addSlice(inheritedMilestone.id, { title: "Slice" });
const feature = await missions.addFeature(inheritedSlice.id, { title: `Feature ${String(autoMerge)}` });
await missions.triageFeature(feature.id);
const task = (await harness.store.listTasks()).find((candidate) => candidate.title === `Feature ${String(autoMerge)}`);
expect(task?.autoMerge).toBeUndefined();
}
} finally {
await harness.teardown();
}
});
});