FN-5828: route shared mission triage to per-task working branches
Ensure mission shared-branch triage creates unique task branches while preserving a single shared merge target. - Compute branch assignment during mission feature triage with resolveEntryPointBranchAssignment and persist per-task working branches in shared mode. - Keep shared branch-group initialization tied to the mission merge-target branch for shared assignment flows. - Update core and dashboard mission triage tests to assert distinct per-task branches under shared strategy and verify branch-group behavior. - Refresh mission docs to clarify shared vs per-task-derived branch strategy semantics. Files changed: docs/missions.md | 8 +-- packages/core/src/__tests__/mission-store.test.ts | 42 +++++++++++++-- packages/core/src/mission-store.ts | 19 ++++--- packages/dashboard/src/__tests__/mission-e2e.test.ts | 63 ++++++++++++++++++++-- 4 files changed, 115 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-5828 Fusion-Task-Lineage: 9854ea25-6926-4883-92ae-36a5c0a817c5
This commit is contained in:
@@ -2155,7 +2155,9 @@ describe("MissionStore", () => {
|
||||
const triaged = await msWithTs.triageFeature(feature.id);
|
||||
const task = await ts.getTask(triaged.taskId!);
|
||||
|
||||
expect(task?.branch).toBe("release/shared");
|
||||
expect(task?.branch).toMatch(/^release\/shared\//);
|
||||
expect(task?.branch).not.toBe("release/shared");
|
||||
expect(task?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
||||
expect(task?.branchContext?.assignmentMode).toBe("shared");
|
||||
});
|
||||
|
||||
@@ -2175,7 +2177,9 @@ describe("MissionStore", () => {
|
||||
});
|
||||
const task = await ts.getTask(triaged.taskId!);
|
||||
|
||||
expect(task?.branch).toBe("hotfix/shared");
|
||||
expect(task?.branch).toMatch(/^hotfix\/shared\//);
|
||||
expect(task?.branch).not.toBe("hotfix/shared");
|
||||
expect(task?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
||||
expect(task?.branchContext?.assignmentMode).toBe("shared");
|
||||
});
|
||||
|
||||
@@ -2399,11 +2403,43 @@ describe("MissionStore", () => {
|
||||
});
|
||||
const task = await ts.getTask(triaged[0].taskId!);
|
||||
|
||||
expect(task?.branch).toBe("feature/manual");
|
||||
expect(task?.branch).toMatch(/^feature\/manual\//);
|
||||
expect(task?.branch).not.toBe("feature/manual");
|
||||
expect(task?.baseBranch).toBe("release");
|
||||
expect(task?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
||||
expect(task?.branchContext?.assignmentMode).toBe("shared");
|
||||
});
|
||||
|
||||
it("triageSlice shared mode creates distinct per-task branches with one shared merge target", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
const msWithTs = ts.getMissionStore();
|
||||
|
||||
const mission = msWithTs.createMission({
|
||||
title: "Mission",
|
||||
branchStrategy: { mode: "existing", branchName: "feature/shared" },
|
||||
});
|
||||
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
|
||||
msWithTs.addFeature(slice.id, { title: "Feature 1" });
|
||||
msWithTs.addFeature(slice.id, { title: "Feature 2" });
|
||||
|
||||
const triaged = await msWithTs.triageSlice(slice.id);
|
||||
const firstTask = await ts.getTask(triaged[0].taskId!);
|
||||
const secondTask = await ts.getTask(triaged[1].taskId!);
|
||||
|
||||
expect(firstTask?.branch).toMatch(/^feature\/shared\//);
|
||||
expect(secondTask?.branch).toMatch(/^feature\/shared\//);
|
||||
expect(firstTask?.branch).not.toBe("feature/shared");
|
||||
expect(secondTask?.branch).not.toBe("feature/shared");
|
||||
expect(firstTask?.branch).not.toBe(secondTask?.branch);
|
||||
expect(firstTask?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
||||
expect(secondTask?.branchContext?.groupId).toBe(`mission:${mission.id}`);
|
||||
|
||||
const branchGroup = ts.getBranchGroupBySource("mission", mission.id);
|
||||
expect(branchGroup?.branchName).toBe("feature/shared");
|
||||
});
|
||||
|
||||
it("triageSlice does not inject baseBranch when mission has none", async () => {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
type MissionHierarchySnapshot,
|
||||
} from "./shared-mesh-state.js";
|
||||
import { reconcileDeterministicDuplicate, runDeterministicDuplicateGuard } from "./duplicate-guard.js";
|
||||
import { resolveEntryPointBranchAssignment } from "./branch-assignment.js";
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -3526,6 +3527,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
if (guard.action === "duplicate" && guard.existing) {
|
||||
linkedTaskId = guard.existing.id;
|
||||
} else {
|
||||
let sharedBranchBaseForMission: string | undefined;
|
||||
if (missionId && resolvedAssignmentMode === "shared") {
|
||||
const settings = await this.taskStore.getSettings();
|
||||
const settingsDefaultBranch =
|
||||
@@ -3533,16 +3535,24 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
? settings.defaultBranch
|
||||
: "main";
|
||||
const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false;
|
||||
sharedBranchBaseForMission = resolvedBranch ?? resolvedBaseBranch ?? settingsDefaultBranch;
|
||||
this.taskStore.ensureBranchGroupForSource("mission", missionId, {
|
||||
branchName: resolvedBranch ?? resolvedBaseBranch ?? settingsDefaultBranch,
|
||||
branchName: sharedBranchBaseForMission,
|
||||
autoMerge: mission?.autoMerge ?? settingsAutoMerge,
|
||||
});
|
||||
}
|
||||
|
||||
const taskSegment = feature.id;
|
||||
const branchAssignment = resolveEntryPointBranchAssignment({
|
||||
assignmentMode: resolvedAssignmentMode,
|
||||
resolvedBranch: resolvedAssignmentMode === "shared" ? sharedBranchBaseForMission ?? resolvedBranch : resolvedBranch,
|
||||
taskSegment,
|
||||
});
|
||||
|
||||
const createdTask = await this.taskStore.createTask({
|
||||
title: taskTitle || feature.title,
|
||||
description,
|
||||
branch: resolvedBranch,
|
||||
branch: branchAssignment.workingBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
...(missionId
|
||||
? {
|
||||
@@ -3617,14 +3627,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
|
||||
const triaged: MissionFeature[] = [];
|
||||
for (const feature of definedFeatures) {
|
||||
const strategyBranch = resolvedAssignmentMode === "per-task-derived"
|
||||
? (resolvedBranch ? `${resolvedBranch}/${feature.id.toLowerCase()}` : undefined)
|
||||
: resolvedBranch;
|
||||
const strategyBranch = resolvedBranch;
|
||||
const updated = await this.triageFeature(feature.id, undefined, undefined, {
|
||||
branch: strategyBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
assignmentMode: resolvedAssignmentMode,
|
||||
...branchOptions,
|
||||
});
|
||||
triaged.push(updated);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { createMissionRouter } from "../mission-routes.js";
|
||||
import { request, get } from "../test-request.js";
|
||||
import { resolveEntryPointBranchAssignment } from "@fusion/core";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type {
|
||||
Mission,
|
||||
@@ -42,6 +43,7 @@ import * as projectStoreResolver from "../project-store-resolver.js";
|
||||
function createMockMissionStore(options?: {
|
||||
ensureBranchGroupForSource?: (sourceType: "planning" | "mission", sourceId: string, init: { branchName: string; autoMerge?: boolean }) => unknown;
|
||||
settingsAutoMerge?: boolean;
|
||||
persistTask?: (task: { id: string; branch?: string; baseBranch?: string }) => void;
|
||||
}) {
|
||||
const missions: Map<string, Mission> = new Map();
|
||||
const milestones: Map<string, Milestone> = new Map();
|
||||
@@ -657,6 +659,16 @@ function createMockMissionStore(options?: {
|
||||
}
|
||||
|
||||
const taskId = "FN-" + String(features.size + 1).padStart(3, "0");
|
||||
const assignment = resolveEntryPointBranchAssignment({
|
||||
assignmentMode: branchOptions?.assignmentMode ?? "shared",
|
||||
resolvedBranch: branchOptions?.branch,
|
||||
taskSegment: feature.id,
|
||||
});
|
||||
options?.persistTask?.({
|
||||
id: taskId,
|
||||
branch: assignment.workingBranch,
|
||||
baseBranch: branchOptions?.baseBranch,
|
||||
});
|
||||
const updated = { ...feature, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() };
|
||||
features.set(featureId, updated);
|
||||
return updated;
|
||||
@@ -701,6 +713,7 @@ function createMockMissionStore(options?: {
|
||||
}
|
||||
|
||||
function createMockStore(): TaskStore {
|
||||
const tasks = new Map<string, { id: string; branch?: string; baseBranch?: string }>();
|
||||
const branchGroups = new Map<string, {
|
||||
id: string;
|
||||
sourceType: "planning" | "mission";
|
||||
@@ -732,14 +745,15 @@ function createMockStore(): TaskStore {
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore({
|
||||
ensureBranchGroupForSource,
|
||||
settingsAutoMerge: false,
|
||||
persistTask: (task) => {
|
||||
tasks.set(task.id, task);
|
||||
},
|
||||
})),
|
||||
ensureBranchGroupForSource,
|
||||
getBranchGroupBySource,
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getSettings: vi.fn().mockResolvedValue({ promptOverrides: {}, autoMerge: false }),
|
||||
getTask: vi.fn(async (id: string) => {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}),
|
||||
getTask: vi.fn(async (id: string) => tasks.get(id)),
|
||||
pauseTask: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -4013,8 +4027,11 @@ describe("Mission API", () => {
|
||||
expect.objectContaining({ branchName: "feature/mission-shared", autoMerge: true }),
|
||||
);
|
||||
expect(taskStore.getBranchGroupBySource("mission", mission.id)).toEqual(
|
||||
expect.objectContaining({ autoMerge: true }),
|
||||
expect.objectContaining({ autoMerge: true, branchName: "feature/mission-shared" }),
|
||||
);
|
||||
const triagedTask = await taskStore.getTask(res.body.taskId);
|
||||
expect(triagedTask?.branch).toMatch(/^feature\/mission-shared\//);
|
||||
expect(triagedTask?.branch).not.toBe("feature/mission-shared");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4056,6 +4073,44 @@ describe("Mission API", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("persists distinct per-task branches while keeping one shared merge target", async () => {
|
||||
const { app, missionStore, store } = buildApp();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
const taskStore = store as unknown as TaskStore;
|
||||
|
||||
const mission = ms.createMission({ title: "Test Mission", autoMerge: true });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||
ms.addFeature(slice.id, { title: "Feature 1" });
|
||||
ms.addFeature(slice.id, { title: "Feature 2" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/slices/${slice.id}/triage-all`,
|
||||
JSON.stringify({
|
||||
branchSelection: { mode: "existing", branchName: "feature/mission-existing", baseBranch: "main" },
|
||||
branchAssignment: { mode: "shared" },
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(taskStore.getBranchGroupBySource("mission", mission.id)).toEqual(
|
||||
expect.objectContaining({ branchName: "feature/mission-existing" }),
|
||||
);
|
||||
|
||||
const [taskA, taskB] = await Promise.all([
|
||||
taskStore.getTask(res.body.triaged[0].taskId),
|
||||
taskStore.getTask(res.body.triaged[1].taskId),
|
||||
]);
|
||||
expect(taskA?.branch).toMatch(/^feature\/mission-existing\//);
|
||||
expect(taskB?.branch).toMatch(/^feature\/mission-existing\//);
|
||||
expect(taskA?.branch).not.toBe("feature/mission-existing");
|
||||
expect(taskB?.branch).not.toBe("feature/mission-existing");
|
||||
expect(taskA?.branch).not.toBe(taskB?.branch);
|
||||
});
|
||||
|
||||
it("forwards branch selection and assignment mode when triaging all slice features", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
Reference in New Issue
Block a user