FN-5829: add shared feature branch option for new tasks
Allow new tasks to target a shared branch-group feature branch during creation. - Add branch-group aware branch selection handling in task workflow routes. - Update store/types/db wiring to support and persist shared feature-branch targeting. - Extend dashboard task creation UI/tests and route tests to cover the new option. - Add a changeset for @runfusion/fusion documenting the new CLI/task behavior. Files changed: .changeset/fn-5829-shared-feature-branch-option.md | 7 ++ .../src/commands/__tests__/task-lifecycle.test.ts | 2 +- .../core/src/__tests__/branch-group-store.test.ts | 26 ++++++ packages/core/src/db.ts | 4 +- packages/core/src/store.ts | 9 +- packages/core/src/types.ts | 2 +- packages/dashboard/app/components/NewTaskModal.tsx | 2 +- packages/dashboard/app/components/TaskForm.tsx | 9 +- .../app/components/__tests__/NewTaskModal.test.tsx | 37 ++++++++ .../src/__tests__/branch-selection.test.ts | 12 +++ .../dashboard/src/__tests__/mission-e2e.test.ts | 8 +- .../src/__tests__/routes-planning.test.ts | 6 +- .../src/__tests__/routes-tasks.test.ts | 102 +++++++++++++++++++++ packages/dashboard/src/routes/branch-selection.ts | 19 +++- .../src/routes/register-task-workflow-routes.ts | 23 +++-- 15 files changed, 242 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-5829 Fusion-Task-Lineage: 7d69acc8-19a3-4112-9bf2-5ac7b5e5a929
This commit is contained in:
7
.changeset/fn-5829-shared-feature-branch-option.md
Normal file
7
.changeset/fn-5829-shared-feature-branch-option.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a new New Task branch strategy option, **Merge into a shared feature branch** (`shared-group`).
|
||||||
|
|
||||||
|
When selected, task creation now joins an existing open branch group by shared branch name (or creates a `new-task` sourced group when missing), links `branchContext` with `assignmentMode: "shared"`, and derives a per-task working branch from the shared branch instead of running directly on the shared integration branch.
|
||||||
@@ -37,7 +37,7 @@ interface MockTask {
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
branchContext?: {
|
branchContext?: {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
source: "planning" | "mission";
|
source: "planning" | "mission" | "new-task";
|
||||||
assignmentMode: "shared" | "per-task-derived";
|
assignmentMode: "shared" | "per-task-derived";
|
||||||
inheritedBaseBranch?: string;
|
inheritedBaseBranch?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ describe("TaskStore branch groups", () => {
|
|||||||
expect(second.autoMerge).toBe(true);
|
expect(second.autoMerge).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports new-task branch group sources and round-trips through lookups", () => {
|
||||||
|
const group = store.ensureBranchGroupForSource("new-task", "shared/onboarding", {
|
||||||
|
branchName: "shared/onboarding",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group.sourceType).toBe("new-task");
|
||||||
|
expect(store.getBranchGroupBySource("new-task", "shared/onboarding")?.id).toBe(group.id);
|
||||||
|
expect(store.getBranchGroup(group.id)?.sourceType).toBe("new-task");
|
||||||
|
});
|
||||||
|
|
||||||
it("enforces unique branchName", () => {
|
it("enforces unique branchName", () => {
|
||||||
store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
|
store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@@ -74,6 +84,22 @@ describe("TaskStore branch groups", () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finds open branch groups by branch name and ignores closed groups", () => {
|
||||||
|
expect(store.getBranchGroupByBranchName("fn/missing")).toBeNull();
|
||||||
|
|
||||||
|
const planning = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-open", branchName: "fn/open" });
|
||||||
|
expect(store.getBranchGroupByBranchName("fn/open")?.id).toBe(planning.id);
|
||||||
|
|
||||||
|
store.updateBranchGroup(planning.id, { status: "finalized" });
|
||||||
|
expect(store.getBranchGroupByBranchName("fn/open")).toBeNull();
|
||||||
|
|
||||||
|
const mission = store.createBranchGroup({ sourceType: "mission", sourceId: "M-open", branchName: "fn/mission-open" });
|
||||||
|
expect(store.getBranchGroupByBranchName("fn/mission-open")?.id).toBe(mission.id);
|
||||||
|
|
||||||
|
const newTask = store.createBranchGroup({ sourceType: "new-task", sourceId: "NT-open", branchName: "fn/new-task-open" });
|
||||||
|
expect(store.getBranchGroupByBranchName("fn/new-task-open")?.id).toBe(newTask.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects duplicate branch group primary key id", () => {
|
it("rejects duplicate branch group primary key id", () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
(store as any).db
|
(store as any).db
|
||||||
|
|||||||
@@ -787,7 +787,7 @@ CREATE TABLE IF NOT EXISTS missions (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS branch_groups (
|
CREATE TABLE IF NOT EXISTS branch_groups (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
sourceType TEXT NOT NULL CHECK (sourceType IN ('mission','planning')),
|
sourceType TEXT NOT NULL CHECK (sourceType IN ('mission','planning','new-task')),
|
||||||
sourceId TEXT NOT NULL,
|
sourceId TEXT NOT NULL,
|
||||||
branchName TEXT NOT NULL UNIQUE,
|
branchName TEXT NOT NULL UNIQUE,
|
||||||
worktreePath TEXT,
|
worktreePath TEXT,
|
||||||
@@ -3705,7 +3705,7 @@ export class Database {
|
|||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS branch_groups (
|
CREATE TABLE IF NOT EXISTS branch_groups (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
sourceType TEXT NOT NULL CHECK (sourceType IN ('mission','planning')),
|
sourceType TEXT NOT NULL CHECK (sourceType IN ('mission','planning','new-task')),
|
||||||
sourceId TEXT NOT NULL,
|
sourceId TEXT NOT NULL,
|
||||||
branchName TEXT NOT NULL UNIQUE,
|
branchName TEXT NOT NULL UNIQUE,
|
||||||
worktreePath TEXT,
|
worktreePath TEXT,
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ function parseTaskBranchContextFromSourceMetadata(sourceMetadata: Record<string,
|
|||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||||
const candidate = raw as Record<string, unknown>;
|
const candidate = raw as Record<string, unknown>;
|
||||||
if (typeof candidate.groupId !== "string" || !candidate.groupId.trim()) return undefined;
|
if (typeof candidate.groupId !== "string" || !candidate.groupId.trim()) return undefined;
|
||||||
if (candidate.source !== "planning" && candidate.source !== "mission") return undefined;
|
if (candidate.source !== "planning" && candidate.source !== "mission" && candidate.source !== "new-task") return undefined;
|
||||||
if (candidate.assignmentMode !== "shared" && candidate.assignmentMode !== "per-task-derived") return undefined;
|
if (candidate.assignmentMode !== "shared" && candidate.assignmentMode !== "per-task-derived") return undefined;
|
||||||
const inheritedBaseBranch = typeof candidate.inheritedBaseBranch === "string" && candidate.inheritedBaseBranch.trim().length > 0
|
const inheritedBaseBranch = typeof candidate.inheritedBaseBranch === "string" && candidate.inheritedBaseBranch.trim().length > 0
|
||||||
? candidate.inheritedBaseBranch.trim()
|
? candidate.inheritedBaseBranch.trim()
|
||||||
@@ -222,7 +222,7 @@ function withTaskBranchContextInSourceMetadata(
|
|||||||
|
|
||||||
interface BranchGroupRow {
|
interface BranchGroupRow {
|
||||||
id: string;
|
id: string;
|
||||||
sourceType: "mission" | "planning";
|
sourceType: "mission" | "planning" | "new-task";
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
worktreePath: string | null;
|
worktreePath: string | null;
|
||||||
@@ -4345,6 +4345,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return row ? this.rowToBranchGroup(row) : null;
|
return row ? this.rowToBranchGroup(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBranchGroupByBranchName(branchName: string): BranchGroup | null {
|
||||||
|
const row = this.db.prepare(`SELECT * FROM branch_groups WHERE branchName = ? AND status = 'open' ORDER BY createdAt DESC LIMIT 1`).get(branchName) as BranchGroupRow | undefined;
|
||||||
|
return row ? this.rowToBranchGroup(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
ensureBranchGroupForSource(
|
ensureBranchGroupForSource(
|
||||||
sourceType: BranchGroup["sourceType"],
|
sourceType: BranchGroup["sourceType"],
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
|
|||||||
@@ -1676,7 +1676,7 @@ export interface TaskSource {
|
|||||||
sourceMetadata?: Record<string, unknown>;
|
sourceMetadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskBranchGroupSource = "planning" | "mission";
|
export type TaskBranchGroupSource = "planning" | "mission" | "new-task";
|
||||||
|
|
||||||
export type TaskBranchAssignmentMode = "shared" | "per-task-derived";
|
export type TaskBranchAssignmentMode = "shared" | "per-task-derived";
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
|||||||
|
|
||||||
const githubRepoOverrideTrimmed = githubRepoOverride.trim();
|
const githubRepoOverrideTrimmed = githubRepoOverride.trim();
|
||||||
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed);
|
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed);
|
||||||
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new";
|
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group";
|
||||||
const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim();
|
const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim();
|
||||||
|
|
||||||
// Track dirty state
|
// Track dirty state
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export interface PendingImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskExecutionModeSelection = "standard" | "fast";
|
type TaskExecutionModeSelection = "standard" | "fast";
|
||||||
export type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new";
|
export type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new" | "shared-group";
|
||||||
|
|
||||||
export interface TaskFormProps {
|
export interface TaskFormProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
@@ -1029,20 +1029,21 @@ export function TaskForm({
|
|||||||
<option value="auto-new">Create auto-named branch per task</option>
|
<option value="auto-new">Create auto-named branch per task</option>
|
||||||
<option value="existing">Use existing branch</option>
|
<option value="existing">Use existing branch</option>
|
||||||
<option value="custom-new">Create custom new branch</option>
|
<option value="custom-new">Create custom new branch</option>
|
||||||
|
<option value="shared-group">Merge into a shared feature branch</option>
|
||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{onBranchChange && (!onBranchModeChange || branchMode === "existing" || branchMode === "custom-new") && (
|
{onBranchChange && (!onBranchModeChange || branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group") && (
|
||||||
<>
|
<>
|
||||||
<label htmlFor="task-working-branch" className="model-select-label">
|
<label htmlFor="task-working-branch" className="model-select-label">
|
||||||
{onBranchModeChange ? "Branch name" : "Working branch"}
|
{branchMode === "shared-group" ? "Shared feature branch" : (onBranchModeChange ? "Branch name" : "Working branch")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="task-working-branch"
|
id="task-working-branch"
|
||||||
className="input"
|
className="input"
|
||||||
value={branch || ""}
|
value={branch || ""}
|
||||||
onChange={(e) => onBranchChange(e.target.value)}
|
onChange={(e) => onBranchChange(e.target.value)}
|
||||||
placeholder="e.g. feature/my-task"
|
placeholder={branchMode === "shared-group" ? "e.g. clionboarding" : "e.g. feature/my-task"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -313,6 +313,43 @@ describe("NewTaskModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires branch name for shared-group mode", async () => {
|
||||||
|
const { props } = renderNewTaskModal();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } });
|
||||||
|
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||||
|
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } });
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled();
|
||||||
|
expect(screen.getByText("Branch name is required for this branch strategy.")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(props.onCreateTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits shared-group branch selection when shared branch exists", async () => {
|
||||||
|
const { props } = renderNewTaskModal();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } });
|
||||||
|
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||||
|
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Shared feature branch"), { target: { value: " feature/shared " } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
branchSelection: {
|
||||||
|
mode: "shared-group",
|
||||||
|
branchName: "feature/shared",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("still submits when setup warnings are shown", async () => {
|
it("still submits when setup warnings are shown", async () => {
|
||||||
const { fetchAuthStatus } = await import("../../api");
|
const { fetchAuthStatus } = await import("../../api");
|
||||||
vi.mocked(fetchAuthStatus).mockResolvedValueOnce({
|
vi.mocked(fetchAuthStatus).mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ describe("branch-selection", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves shared-group with shared feature branch and no working branch", () => {
|
||||||
|
expect(resolveBranchSelection({ mode: "shared-group", branchName: "feature/shared" }, undefined, "main")).toEqual({
|
||||||
|
branch: undefined,
|
||||||
|
baseBranch: undefined,
|
||||||
|
sharedFeatureBranch: "feature/shared",
|
||||||
|
});
|
||||||
|
expect(() => resolveBranchSelection({ mode: "shared-group" }, undefined, undefined)).toThrow(
|
||||||
|
"branchSelection.branchName is required for shared-group mode",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves assignment context", () => {
|
it("resolves assignment context", () => {
|
||||||
expect(resolveBranchAssignmentContext(undefined)).toEqual({ mode: "shared" });
|
expect(resolveBranchAssignmentContext(undefined)).toEqual({ mode: "shared" });
|
||||||
expect(resolveBranchAssignmentContext({ mode: "per-task-derived" })).toEqual({ mode: "per-task-derived" });
|
expect(resolveBranchAssignmentContext({ mode: "per-task-derived" })).toEqual({ mode: "per-task-derived" });
|
||||||
@@ -46,6 +57,7 @@ describe("branch-selection", () => {
|
|||||||
it("reads requested branch mode", () => {
|
it("reads requested branch mode", () => {
|
||||||
expect(getBranchSelectionMode(undefined)).toBeUndefined();
|
expect(getBranchSelectionMode(undefined)).toBeUndefined();
|
||||||
expect(getBranchSelectionMode({ mode: "auto-new" })).toBe("auto-new");
|
expect(getBranchSelectionMode({ mode: "auto-new" })).toBe("auto-new");
|
||||||
|
expect(getBranchSelectionMode({ mode: "shared-group" })).toBe("shared-group");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-exports entry-point branch assignment helper", () => {
|
it("re-exports entry-point branch assignment helper", () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import * as projectStoreResolver from "../project-store-resolver.js";
|
|||||||
|
|
||||||
// Mock MissionStore factory
|
// Mock MissionStore factory
|
||||||
function createMockMissionStore(options?: {
|
function createMockMissionStore(options?: {
|
||||||
ensureBranchGroupForSource?: (sourceType: "planning" | "mission", sourceId: string, init: { branchName: string; autoMerge?: boolean }) => unknown;
|
ensureBranchGroupForSource?: (sourceType: "planning" | "mission" | "new-task", sourceId: string, init: { branchName: string; autoMerge?: boolean }) => unknown;
|
||||||
settingsAutoMerge?: boolean;
|
settingsAutoMerge?: boolean;
|
||||||
persistTask?: (task: { id: string; branch?: string; baseBranch?: string }) => void;
|
persistTask?: (task: { id: string; branch?: string; baseBranch?: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -716,13 +716,13 @@ function createMockStore(): TaskStore {
|
|||||||
const tasks = new Map<string, { id: string; branch?: string; baseBranch?: string }>();
|
const tasks = new Map<string, { id: string; branch?: string; baseBranch?: string }>();
|
||||||
const branchGroups = new Map<string, {
|
const branchGroups = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
sourceType: "planning" | "mission";
|
sourceType: "planning" | "mission" | "new-task";
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
autoMerge: boolean;
|
autoMerge: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const ensureBranchGroupForSource = vi.fn((sourceType: "planning" | "mission", sourceId: string, init: { branchName: string; autoMerge?: boolean }) => {
|
const ensureBranchGroupForSource = vi.fn((sourceType: "planning" | "mission" | "new-task", sourceId: string, init: { branchName: string; autoMerge?: boolean }) => {
|
||||||
const key = `${sourceType}:${sourceId}`;
|
const key = `${sourceType}:${sourceId}`;
|
||||||
const existing = branchGroups.get(key);
|
const existing = branchGroups.get(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
@@ -737,7 +737,7 @@ function createMockStore(): TaskStore {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getBranchGroupBySource = vi.fn((sourceType: "planning" | "mission", sourceId: string) =>
|
const getBranchGroupBySource = vi.fn((sourceType: "planning" | "mission" | "new-task", sourceId: string) =>
|
||||||
branchGroups.get(`${sourceType}:${sourceId}`) ?? null,
|
branchGroups.get(`${sourceType}:${sourceId}`) ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ function createMockGlobalSettingsStore() {
|
|||||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||||
const branchGroups = new Map<string, {
|
const branchGroups = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
sourceType: "planning" | "mission";
|
sourceType: "planning" | "mission" | "new-task";
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
autoMerge: boolean;
|
autoMerge: boolean;
|
||||||
@@ -223,7 +223,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||||
ensureBranchGroupForSource: vi.fn(function (this: TaskStore, sourceType: "planning" | "mission", sourceId: string, init: { branchName: string; autoMerge?: boolean }) {
|
ensureBranchGroupForSource: vi.fn(function (this: TaskStore, sourceType: "planning" | "mission" | "new-task", sourceId: string, init: { branchName: string; autoMerge?: boolean }) {
|
||||||
const existing = this.getBranchGroupBySource(sourceType, sourceId);
|
const existing = this.getBranchGroupBySource(sourceType, sourceId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
@@ -242,7 +242,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
branchGroups.set(`${sourceType}:${sourceId}`, created);
|
branchGroups.set(`${sourceType}:${sourceId}`, created);
|
||||||
return created;
|
return created;
|
||||||
}),
|
}),
|
||||||
getBranchGroupBySource: vi.fn((sourceType: "planning" | "mission", sourceId: string) =>
|
getBranchGroupBySource: vi.fn((sourceType: "planning" | "mission" | "new-task", sourceId: string) =>
|
||||||
branchGroups.get(`${sourceType}:${sourceId}`) ?? null
|
branchGroups.get(`${sourceType}:${sourceId}`) ?? null
|
||||||
),
|
),
|
||||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
createTaskWithReservedId: undefined,
|
createTaskWithReservedId: undefined,
|
||||||
moveTask: vi.fn(),
|
moveTask: vi.fn(),
|
||||||
updateTask: vi.fn(),
|
updateTask: vi.fn(),
|
||||||
|
getBranchGroupByBranchName: vi.fn(),
|
||||||
|
ensureBranchGroupForSource: vi.fn(),
|
||||||
|
setTaskBranchGroup: vi.fn().mockResolvedValue(undefined),
|
||||||
deleteTask: vi.fn(),
|
deleteTask: vi.fn(),
|
||||||
mergeTask: vi.fn(),
|
mergeTask: vi.fn(),
|
||||||
archiveTask: vi.fn(),
|
archiveTask: vi.fn(),
|
||||||
@@ -975,6 +978,105 @@ describe("POST /tasks", () => {
|
|||||||
expect(res.body.branch).toBe("fusion/fn-5671-branch-strategy-dropdown");
|
expect(res.body.branch).toBe("fusion/fn-5671-branch-strategy-dropdown");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists per-task branch and shared group context for shared-group branchSelection", async () => {
|
||||||
|
const createdTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-7001",
|
||||||
|
title: "Shared Group Branch Task",
|
||||||
|
description: "Task with shared branch target",
|
||||||
|
column: "triage",
|
||||||
|
branch: undefined,
|
||||||
|
};
|
||||||
|
const ensuredGroup = {
|
||||||
|
id: "BG-001",
|
||||||
|
sourceType: "new-task" as const,
|
||||||
|
sourceId: "feature/shared",
|
||||||
|
branchName: "feature/shared",
|
||||||
|
autoMerge: false,
|
||||||
|
prState: "none" as const,
|
||||||
|
status: "open" as const,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||||||
|
(store.getBranchGroupByBranchName as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||||
|
(store.ensureBranchGroupForSource as ReturnType<typeof vi.fn>).mockReturnValue(ensuredGroup);
|
||||||
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...createdTask,
|
||||||
|
branch: "feature/shared/shared-group-branch-task",
|
||||||
|
branchContext: { groupId: "BG-001", source: "new-task", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks",
|
||||||
|
JSON.stringify({
|
||||||
|
description: "Task with shared branch target",
|
||||||
|
title: "Shared Group Branch Task",
|
||||||
|
branchSelection: {
|
||||||
|
mode: "shared-group",
|
||||||
|
branchName: "feature/shared",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(store.ensureBranchGroupForSource).toHaveBeenCalledWith("new-task", "feature/shared", { branchName: "feature/shared" });
|
||||||
|
expect(store.setTaskBranchGroup).toHaveBeenCalledWith("FN-7001", "BG-001");
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-7001", {
|
||||||
|
branch: "feature/shared/shared-group-branch-task",
|
||||||
|
});
|
||||||
|
expect(res.body.branch).toBe("feature/shared/shared-group-branch-task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins existing open branch groups by shared branch name", async () => {
|
||||||
|
const createdTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-7002",
|
||||||
|
title: "Join Existing Group",
|
||||||
|
description: "Task joins existing group",
|
||||||
|
column: "triage",
|
||||||
|
branch: undefined,
|
||||||
|
};
|
||||||
|
const existingGroup = {
|
||||||
|
id: "BG-existing",
|
||||||
|
sourceType: "planning" as const,
|
||||||
|
sourceId: "PS-1",
|
||||||
|
branchName: "feature/shared",
|
||||||
|
autoMerge: false,
|
||||||
|
prState: "none" as const,
|
||||||
|
status: "open" as const,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||||||
|
(store.getBranchGroupByBranchName as ReturnType<typeof vi.fn>).mockReturnValue(existingGroup);
|
||||||
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...createdTask,
|
||||||
|
branch: "feature/shared/join-existing-group",
|
||||||
|
branchContext: { groupId: "BG-existing", source: "planning", assignmentMode: "shared" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks",
|
||||||
|
JSON.stringify({
|
||||||
|
description: "Task joins existing group",
|
||||||
|
title: "Join Existing Group",
|
||||||
|
branchSelection: { mode: "shared-group", branchName: "feature/shared" },
|
||||||
|
}),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(store.ensureBranchGroupForSource).not.toHaveBeenCalled();
|
||||||
|
expect(store.setTaskBranchGroup).toHaveBeenCalledWith("FN-7002", "BG-existing");
|
||||||
|
expect(res.body.branch).not.toBe("feature/shared");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 400 when create branch payload is not a string", async () => {
|
it("returns 400 when create branch payload is not a string", async () => {
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildApp(),
|
buildApp(),
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export type BranchSelectionMode =
|
|||||||
| "project-default"
|
| "project-default"
|
||||||
| "auto-new"
|
| "auto-new"
|
||||||
| "existing"
|
| "existing"
|
||||||
| "custom-new";
|
| "custom-new"
|
||||||
|
| "shared-group";
|
||||||
|
|
||||||
export interface BranchSelectionPayload {
|
export interface BranchSelectionPayload {
|
||||||
mode?: unknown;
|
mode?: unknown;
|
||||||
@@ -47,8 +48,9 @@ export function getBranchSelectionMode(selectionInput: unknown): BranchSelection
|
|||||||
"auto-new",
|
"auto-new",
|
||||||
"existing",
|
"existing",
|
||||||
"custom-new",
|
"custom-new",
|
||||||
|
"shared-group",
|
||||||
].includes(mode)) {
|
].includes(mode)) {
|
||||||
throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new");
|
throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new, shared-group");
|
||||||
}
|
}
|
||||||
return mode as BranchSelectionMode;
|
return mode as BranchSelectionMode;
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,7 @@ export type PlanningBranchMode = "shared" | "per-task-derived";
|
|||||||
export interface ResolvedBranchSelection {
|
export interface ResolvedBranchSelection {
|
||||||
branch?: string;
|
branch?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
|
sharedFeatureBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BranchAssignmentContext {
|
export interface BranchAssignmentContext {
|
||||||
@@ -106,6 +109,18 @@ export function resolveBranchSelection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const branchName = normalizeOptionalBranch(selection.branchName, "branchSelection.branchName");
|
const branchName = normalizeOptionalBranch(selection.branchName, "branchSelection.branchName");
|
||||||
|
|
||||||
|
if (mode === "shared-group") {
|
||||||
|
if (!branchName) {
|
||||||
|
throw badRequest("branchSelection.branchName is required for shared-group mode");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branch: undefined,
|
||||||
|
baseBranch,
|
||||||
|
sharedFeatureBranch: branchName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!branchName) {
|
if (!branchName) {
|
||||||
throw badRequest("branchSelection.branchName is required for existing/custom-new modes");
|
throw badRequest("branchSelection.branchName is required for existing/custom-new modes");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { planTaskWorktreePath } from "@fusion/engine";
|
|||||||
import type { RunAuditEventInput } from "@fusion/core";
|
import type { RunAuditEventInput } from "@fusion/core";
|
||||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||||
import type { ApiRoutesContext } from "./types.js";
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
import { deriveAutoTaskBranch, getBranchSelectionMode, resolveBranchSelection } from "./branch-selection.js";
|
import { deriveAutoTaskBranch, derivePerTaskBranch, getBranchSelectionMode, resolveBranchSelection } from "./branch-selection.js";
|
||||||
|
|
||||||
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
|
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
|
||||||
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||||
@@ -952,7 +952,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
: { sourceType: "api" as const };
|
: { sourceType: "api" as const };
|
||||||
|
|
||||||
const requestedBranchMode = getBranchSelectionMode(branchSelection);
|
const requestedBranchMode = getBranchSelectionMode(branchSelection);
|
||||||
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch } =
|
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch, sharedFeatureBranch } =
|
||||||
resolveBranchSelection(branchSelection, branch, baseBranch);
|
resolveBranchSelection(branchSelection, branch, baseBranch);
|
||||||
|
|
||||||
let validatedGithubTracking: { enabled?: boolean; repoOverride?: string } | undefined;
|
let validatedGithubTracking: { enabled?: boolean; repoOverride?: string } | undefined;
|
||||||
@@ -1246,8 +1246,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
})
|
})
|
||||||
: task;
|
: task;
|
||||||
|
|
||||||
|
const taskWithBranchContext = requestedBranchMode === "shared-group" && sharedFeatureBranch
|
||||||
|
? await (async () => {
|
||||||
|
const group = scopedStore.getBranchGroupByBranchName(sharedFeatureBranch)
|
||||||
|
?? scopedStore.ensureBranchGroupForSource("new-task", sharedFeatureBranch, { branchName: sharedFeatureBranch });
|
||||||
|
await scopedStore.setTaskBranchGroup(taskWithAutoBranch.id, group.id);
|
||||||
|
const taskSegment = ((taskWithAutoBranch.title ?? "").trim() || taskWithAutoBranch.description).slice(0, 60);
|
||||||
|
const workingBranch = derivePerTaskBranch(sharedFeatureBranch, taskSegment);
|
||||||
|
return scopedStore.updateTask(taskWithAutoBranch.id, { branch: workingBranch });
|
||||||
|
})()
|
||||||
|
: taskWithAutoBranch;
|
||||||
|
|
||||||
const deterministicReconcile = await reconcileDeterministicDuplicate(scopedStore, {
|
const deterministicReconcile = await reconcileDeterministicDuplicate(scopedStore, {
|
||||||
createdTask: taskWithAutoBranch,
|
createdTask: taskWithBranchContext,
|
||||||
fingerprint: bypassDuplicateCheck === true ? null : contentFingerprint,
|
fingerprint: bypassDuplicateCheck === true ? null : contentFingerprint,
|
||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
logger: runtimeLogger,
|
logger: runtimeLogger,
|
||||||
@@ -1261,8 +1272,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
try {
|
try {
|
||||||
await scopedStore.recordActivity({
|
await scopedStore.recordActivity({
|
||||||
type: "task:duplicate-warning-overridden",
|
type: "task:duplicate-warning-overridden",
|
||||||
taskId: taskWithAutoBranch.id,
|
taskId: taskWithBranchContext.id,
|
||||||
taskTitle: taskWithAutoBranch.title,
|
taskTitle: taskWithBranchContext.title,
|
||||||
details: `Created despite ${acknowledgedDuplicateIds.length} possible duplicate(s): ${acknowledgedDuplicateIds.join(", ")}`,
|
details: `Created despite ${acknowledgedDuplicateIds.length} possible duplicate(s): ${acknowledgedDuplicateIds.join(", ")}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
acknowledgedDuplicateIds,
|
acknowledgedDuplicateIds,
|
||||||
@@ -1277,7 +1288,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json(taskWithAutoBranch);
|
res.status(201).json(taskWithBranchContext);
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
deterministicGuard.releaseLock();
|
deterministicGuard.releaseLock();
|
||||||
|
|||||||
Reference in New Issue
Block a user