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;
|
||||
branchContext?: {
|
||||
groupId: string;
|
||||
source: "planning" | "mission";
|
||||
source: "planning" | "mission" | "new-task";
|
||||
assignmentMode: "shared" | "per-task-derived";
|
||||
inheritedBaseBranch?: string;
|
||||
};
|
||||
|
||||
@@ -67,6 +67,16 @@ describe("TaskStore branch groups", () => {
|
||||
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", () => {
|
||||
store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
|
||||
expect(() =>
|
||||
@@ -74,6 +84,22 @@ describe("TaskStore branch groups", () => {
|
||||
).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", () => {
|
||||
const now = Date.now();
|
||||
(store as any).db
|
||||
|
||||
@@ -787,7 +787,7 @@ CREATE TABLE IF NOT EXISTS missions (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS branch_groups (
|
||||
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,
|
||||
branchName TEXT NOT NULL UNIQUE,
|
||||
worktreePath TEXT,
|
||||
@@ -3705,7 +3705,7 @@ export class Database {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS branch_groups (
|
||||
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,
|
||||
branchName TEXT NOT NULL UNIQUE,
|
||||
worktreePath TEXT,
|
||||
|
||||
@@ -191,7 +191,7 @@ function parseTaskBranchContextFromSourceMetadata(sourceMetadata: Record<string,
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
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;
|
||||
const inheritedBaseBranch = typeof candidate.inheritedBaseBranch === "string" && candidate.inheritedBaseBranch.trim().length > 0
|
||||
? candidate.inheritedBaseBranch.trim()
|
||||
@@ -222,7 +222,7 @@ function withTaskBranchContextInSourceMetadata(
|
||||
|
||||
interface BranchGroupRow {
|
||||
id: string;
|
||||
sourceType: "mission" | "planning";
|
||||
sourceType: "mission" | "planning" | "new-task";
|
||||
sourceId: string;
|
||||
branchName: string;
|
||||
worktreePath: string | null;
|
||||
@@ -4345,6 +4345,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
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(
|
||||
sourceType: BranchGroup["sourceType"],
|
||||
sourceId: string,
|
||||
|
||||
@@ -1676,7 +1676,7 @@ export interface TaskSource {
|
||||
sourceMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TaskBranchGroupSource = "planning" | "mission";
|
||||
export type TaskBranchGroupSource = "planning" | "mission" | "new-task";
|
||||
|
||||
export type TaskBranchAssignmentMode = "shared" | "per-task-derived";
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
|
||||
const githubRepoOverrideTrimmed = githubRepoOverride.trim();
|
||||
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();
|
||||
|
||||
// Track dirty state
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface PendingImage {
|
||||
}
|
||||
|
||||
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 {
|
||||
mode: "create" | "edit";
|
||||
@@ -1029,20 +1029,21 @@ export function TaskForm({
|
||||
<option value="auto-new">Create auto-named branch per task</option>
|
||||
<option value="existing">Use existing branch</option>
|
||||
<option value="custom-new">Create custom new branch</option>
|
||||
<option value="shared-group">Merge into a shared feature branch</option>
|
||||
</select>
|
||||
</>
|
||||
) : 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">
|
||||
{onBranchModeChange ? "Branch name" : "Working branch"}
|
||||
{branchMode === "shared-group" ? "Shared feature branch" : (onBranchModeChange ? "Branch name" : "Working branch")}
|
||||
</label>
|
||||
<input
|
||||
id="task-working-branch"
|
||||
className="input"
|
||||
value={branch || ""}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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 () => {
|
||||
const { fetchAuthStatus } = await import("../../api");
|
||||
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", () => {
|
||||
expect(resolveBranchAssignmentContext(undefined)).toEqual({ mode: "shared" });
|
||||
expect(resolveBranchAssignmentContext({ mode: "per-task-derived" })).toEqual({ mode: "per-task-derived" });
|
||||
@@ -46,6 +57,7 @@ describe("branch-selection", () => {
|
||||
it("reads requested branch mode", () => {
|
||||
expect(getBranchSelectionMode(undefined)).toBeUndefined();
|
||||
expect(getBranchSelectionMode({ mode: "auto-new" })).toBe("auto-new");
|
||||
expect(getBranchSelectionMode({ mode: "shared-group" })).toBe("shared-group");
|
||||
});
|
||||
|
||||
it("re-exports entry-point branch assignment helper", () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ import * as projectStoreResolver from "../project-store-resolver.js";
|
||||
|
||||
// Mock MissionStore factory
|
||||
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;
|
||||
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 branchGroups = new Map<string, {
|
||||
id: string;
|
||||
sourceType: "planning" | "mission";
|
||||
sourceType: "planning" | "mission" | "new-task";
|
||||
sourceId: string;
|
||||
branchName: string;
|
||||
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 existing = branchGroups.get(key);
|
||||
if (existing) return existing;
|
||||
@@ -737,7 +737,7 @@ function createMockStore(): TaskStore {
|
||||
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,
|
||||
);
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ function createMockGlobalSettingsStore() {
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
const branchGroups = new Map<string, {
|
||||
id: string;
|
||||
sourceType: "planning" | "mission";
|
||||
sourceType: "planning" | "mission" | "new-task";
|
||||
sourceId: string;
|
||||
branchName: string;
|
||||
autoMerge: boolean;
|
||||
@@ -223,7 +223,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
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);
|
||||
if (existing) {
|
||||
return existing;
|
||||
@@ -242,7 +242,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
branchGroups.set(`${sourceType}:${sourceId}`, 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
|
||||
),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -183,6 +183,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
createTaskWithReservedId: undefined,
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
getBranchGroupByBranchName: vi.fn(),
|
||||
ensureBranchGroupForSource: vi.fn(),
|
||||
setTaskBranchGroup: vi.fn().mockResolvedValue(undefined),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
@@ -975,6 +978,105 @@ describe("POST /tasks", () => {
|
||||
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 () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
|
||||
@@ -24,7 +24,8 @@ export type BranchSelectionMode =
|
||||
| "project-default"
|
||||
| "auto-new"
|
||||
| "existing"
|
||||
| "custom-new";
|
||||
| "custom-new"
|
||||
| "shared-group";
|
||||
|
||||
export interface BranchSelectionPayload {
|
||||
mode?: unknown;
|
||||
@@ -47,8 +48,9 @@ export function getBranchSelectionMode(selectionInput: unknown): BranchSelection
|
||||
"auto-new",
|
||||
"existing",
|
||||
"custom-new",
|
||||
"shared-group",
|
||||
].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;
|
||||
}
|
||||
@@ -58,6 +60,7 @@ export type PlanningBranchMode = "shared" | "per-task-derived";
|
||||
export interface ResolvedBranchSelection {
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
sharedFeatureBranch?: string;
|
||||
}
|
||||
|
||||
export interface BranchAssignmentContext {
|
||||
@@ -106,6 +109,18 @@ export function resolveBranchSelection(
|
||||
}
|
||||
|
||||
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) {
|
||||
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 { ApiError, badRequest, conflict, notFound } from "../api-error.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_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 };
|
||||
|
||||
const requestedBranchMode = getBranchSelectionMode(branchSelection);
|
||||
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch } =
|
||||
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch, sharedFeatureBranch } =
|
||||
resolveBranchSelection(branchSelection, branch, baseBranch);
|
||||
|
||||
let validatedGithubTracking: { enabled?: boolean; repoOverride?: string } | undefined;
|
||||
@@ -1246,8 +1246,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
})
|
||||
: 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, {
|
||||
createdTask: taskWithAutoBranch,
|
||||
createdTask: taskWithBranchContext,
|
||||
fingerprint: bypassDuplicateCheck === true ? null : contentFingerprint,
|
||||
windowMs: 60_000,
|
||||
logger: runtimeLogger,
|
||||
@@ -1261,8 +1272,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
try {
|
||||
await scopedStore.recordActivity({
|
||||
type: "task:duplicate-warning-overridden",
|
||||
taskId: taskWithAutoBranch.id,
|
||||
taskTitle: taskWithAutoBranch.title,
|
||||
taskId: taskWithBranchContext.id,
|
||||
taskTitle: taskWithBranchContext.title,
|
||||
details: `Created despite ${acknowledgedDuplicateIds.length} possible duplicate(s): ${acknowledgedDuplicateIds.join(", ")}`,
|
||||
metadata: {
|
||||
acknowledgedDuplicateIds,
|
||||
@@ -1277,7 +1288,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(taskWithAutoBranch);
|
||||
res.status(201).json(taskWithBranchContext);
|
||||
return;
|
||||
} finally {
|
||||
deterministicGuard.releaseLock();
|
||||
|
||||
Reference in New Issue
Block a user