FN-5671: add branch-strategy selection to new task creation
Add branch-strategy based task branch handling from modal through task creation routes. - add branch strategy dropdown + validation in New Task modal and TaskForm, including required branch-name handling for existing/custom-new modes - send structured branchSelection payload from dashboard API client and cover UI/API behavior with new tests - add server-side branch selection mode parsing, auto-new branch derivation (fusion/<task-id>-<slug>), and response updates when auto branch is generated - update engine/worktree and dashboard route tests plus dashboard guide documentation - add a changeset for @runfusion/fusion patch release Files changed: .changeset/fn-5671-branch-strategy-dropdown.md | 7 ++ docs/dashboard-guide.md | 16 +++++ packages/dashboard/app/__tests__/api-tasks.test.ts | 21 ++++++ packages/dashboard/app/api/legacy.ts | 9 +++ packages/dashboard/app/components/NewTaskModal.css | 8 +++ packages/dashboard/app/components/NewTaskModal.tsx | 41 +++++++++--- packages/dashboard/app/components/TaskForm.tsx | 30 ++++++++- .../app/components/__tests__/NewTaskModal.test.tsx | 77 +++++++++++++++++++--- .../src/__tests__/branch-selection.test.ts | 13 ++++ .../dashboard/src/__tests__/routes-tasks.test.ts | 72 ++++++++++++++++++++ packages/dashboard/src/routes/branch-selection.ts | 45 ++++++++----- .../src/routes/register-task-workflow-routes.ts | 22 +++++-- .../engine/src/__tests__/worktree-pool.test.ts | 41 ++++++++++++ packages/engine/src/worktree-pool.ts | 2 +- 14 files changed, 361 insertions(+), 43 deletions(-) Fusion-Task-Id: FN-5671 Fusion-Task-Lineage: 7f2e7b68-050d-4a2c-af0b-cb895de35576
This commit is contained in:
7
.changeset/fn-5671-branch-strategy-dropdown.md
Normal file
7
.changeset/fn-5671-branch-strategy-dropdown.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add a branch-strategy dropdown to the New Task dialog with project-default, auto-new, existing, and custom-new modes.
|
||||
|
||||
New tasks now submit `branchSelection`, and `auto-new` derives a persisted branch name using `fusion/{task-id}-{short-name}`.
|
||||
@@ -111,6 +111,22 @@ Planning Mode now includes branch controls on the summary screen before you crea
|
||||
|
||||
These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows.
|
||||
|
||||
## New Task Modal Branch Strategy
|
||||
|
||||
The **New Task** dialog uses the same four-option **Branch strategy** selector and `branchSelection` payload as Planning Mode:
|
||||
|
||||
- `Use project/default branch`
|
||||
- `Create auto-named branch per task`
|
||||
- `Use existing branch`
|
||||
- `Create custom new branch`
|
||||
|
||||
Rules:
|
||||
|
||||
- `existing` and `custom-new` require a branch name.
|
||||
- `project-default` leaves `branch` unset.
|
||||
- `auto-new` creates a branch after task creation using `fusion/{task-id}-{short-name}` (for example `fusion/fn-5671-branch-strategy-dropdown`).
|
||||
- `Merge target / base branch` stays optional for all modes.
|
||||
|
||||
## Chat View
|
||||
|
||||
Chat view provides project-scoped conversations with agents.
|
||||
|
||||
@@ -653,6 +653,27 @@ describe("createTask", () => {
|
||||
expect(body).not.toHaveProperty("baseBranch");
|
||||
});
|
||||
|
||||
it("serializes branchSelection in create payload when provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_CREATED_TASK));
|
||||
|
||||
await createTask({
|
||||
description: "Task with branch strategy",
|
||||
branchSelection: {
|
||||
mode: "custom-new",
|
||||
branchName: "feature/new-task-flow",
|
||||
baseBranch: "main",
|
||||
},
|
||||
});
|
||||
|
||||
const call = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
const body = JSON.parse((call[1] as RequestInit).body as string);
|
||||
expect(body.branchSelection).toEqual({
|
||||
mode: "custom-new",
|
||||
branchName: "feature/new-task-flow",
|
||||
baseBranch: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes nodeId in create payload when execution target is specified", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
|
||||
...FAKE_CREATED_TASK,
|
||||
|
||||
@@ -330,7 +330,14 @@ export interface CreateTaskRequestOptions {
|
||||
localNodeId?: string;
|
||||
}
|
||||
|
||||
export type BranchSelectionInput = {
|
||||
mode: "project-default" | "auto-new" | "existing" | "custom-new";
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
};
|
||||
|
||||
export type CreateTaskInput = TaskCreateInput & {
|
||||
branchSelection?: BranchSelectionInput;
|
||||
acknowledgedDuplicates?: string[];
|
||||
bypassDuplicateCheck?: boolean;
|
||||
};
|
||||
@@ -375,6 +382,7 @@ export async function createTask(
|
||||
nodeId,
|
||||
branch,
|
||||
baseBranch,
|
||||
branchSelection,
|
||||
githubTracking,
|
||||
acknowledgedDuplicates,
|
||||
bypassDuplicateCheck,
|
||||
@@ -409,6 +417,7 @@ export async function createTask(
|
||||
nodeId,
|
||||
branch,
|
||||
baseBranch,
|
||||
branchSelection,
|
||||
githubTracking,
|
||||
acknowledgedDuplicates,
|
||||
bypassDuplicateCheck,
|
||||
|
||||
@@ -179,6 +179,10 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.new-task-branch-error {
|
||||
margin: 0 var(--space-xl) var(--space-sm);
|
||||
}
|
||||
|
||||
.new-task-modal textarea {
|
||||
min-height: calc(var(--space-xl) * 3 + var(--space-md));
|
||||
transition: height var(--transition-instant);
|
||||
@@ -493,6 +497,10 @@
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.new-task-branch-error {
|
||||
margin: 0 var(--space-md) var(--space-sm);
|
||||
}
|
||||
|
||||
.new-task-quick-fields .dep-trigger {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { uploadAttachment } from "../api";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
||||
import { SetupWarningBanner } from "./SetupWarningBanner";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm";
|
||||
import { REPO_OVERRIDE_RE } from "./githubTracking";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
@@ -41,6 +41,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
: {};
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [branchMode, setBranchMode] = useState<BranchSelectionMode>("project-default");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
@@ -144,6 +145,8 @@ 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 hasInvalidBranchSelection = isBranchNameRequired && !branch.trim();
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
@@ -160,12 +163,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
reviewLevel !== undefined ||
|
||||
priority !== DEFAULT_TASK_PRIORITY ||
|
||||
nodeId !== undefined ||
|
||||
branchMode !== "project-default" ||
|
||||
branch !== "" ||
|
||||
baseBranch !== "" ||
|
||||
githubTrackingEnabled ||
|
||||
githubRepoOverrideTrimmed !== "";
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (hasDirtyState) {
|
||||
@@ -195,6 +199,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setReviewLevel(undefined);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setNodeId(undefined);
|
||||
setBranchMode("project-default");
|
||||
setBranch("");
|
||||
setBaseBranch("");
|
||||
setHasDirtyState(false);
|
||||
@@ -205,7 +210,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmedDesc = description.trim();
|
||||
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid) return;
|
||||
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
@@ -213,7 +218,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
const planningSlashIdx = planningModel.indexOf("/");
|
||||
|
||||
const task = await onCreateTask({
|
||||
const createInput: TaskCreateInput & {
|
||||
branchSelection?: {
|
||||
mode: BranchSelectionMode;
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
};
|
||||
} = {
|
||||
title: undefined,
|
||||
description: trimmedDesc,
|
||||
column: "triage",
|
||||
@@ -233,8 +244,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
reviewLevel,
|
||||
priority,
|
||||
nodeId,
|
||||
branch: branch.trim() === "" ? undefined : branch.trim(),
|
||||
baseBranch: baseBranch.trim() === "" ? undefined : baseBranch.trim(),
|
||||
branchSelection: {
|
||||
mode: branchMode,
|
||||
...(isBranchNameRequired && branch.trim() ? { branchName: branch.trim() } : {}),
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
...(githubTrackingEnabled || githubRepoOverrideTrimmed !== ""
|
||||
? {
|
||||
githubTracking: {
|
||||
@@ -243,7 +257,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
const task = await onCreateTask(createInput);
|
||||
|
||||
// Upload pending images as attachments
|
||||
if (pendingImages.length > 0) {
|
||||
@@ -278,6 +294,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setReviewLevel(undefined);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setNodeId(undefined);
|
||||
setBranchMode("project-default");
|
||||
setBranch("");
|
||||
setBaseBranch("");
|
||||
|
||||
@@ -288,7 +305,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -492,6 +509,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onPriorityChange={setPriority}
|
||||
branch={branch}
|
||||
onBranchChange={setBranch}
|
||||
branchMode={branchMode}
|
||||
onBranchModeChange={setBranchMode}
|
||||
baseBranch={baseBranch}
|
||||
onBaseBranchChange={setBaseBranch}
|
||||
nodeId={nodeId}
|
||||
@@ -508,6 +527,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
|
||||
</div>
|
||||
|
||||
{hasInvalidBranchSelection && (
|
||||
<div className="form-error new-task-branch-error">Branch name is required for this branch strategy.</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
@@ -515,7 +538,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid}
|
||||
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Task"}
|
||||
</button>
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface PendingImage {
|
||||
}
|
||||
|
||||
type TaskExecutionModeSelection = "standard" | "fast";
|
||||
export type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new";
|
||||
|
||||
export interface TaskFormProps {
|
||||
mode: "create" | "edit";
|
||||
@@ -51,6 +52,8 @@ export interface TaskFormProps {
|
||||
onDependenciesChange: (deps: string[]) => void;
|
||||
branch?: string;
|
||||
onBranchChange?: (value: string) => void;
|
||||
branchMode?: BranchSelectionMode;
|
||||
onBranchModeChange?: (value: BranchSelectionMode) => void;
|
||||
baseBranch?: string;
|
||||
onBaseBranchChange?: (value: string) => void;
|
||||
nodeId?: string;
|
||||
@@ -130,6 +133,8 @@ export function TaskForm({
|
||||
onDependenciesChange,
|
||||
branch,
|
||||
onBranchChange,
|
||||
branchMode,
|
||||
onBranchModeChange,
|
||||
baseBranch,
|
||||
onBaseBranchChange,
|
||||
nodeId,
|
||||
@@ -967,12 +972,31 @@ export function TaskForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(onBranchChange || onBaseBranchChange) && (
|
||||
{(onBranchChange || onBaseBranchChange || onBranchModeChange) && (
|
||||
<div className="form-group">
|
||||
<label>Branch Settings</label>
|
||||
{onBranchChange && (
|
||||
{onBranchModeChange ? (
|
||||
<>
|
||||
<label htmlFor="task-working-branch" className="model-select-label">Working branch</label>
|
||||
<label htmlFor="task-branch-mode" className="model-select-label">Branch strategy</label>
|
||||
<select
|
||||
id="task-branch-mode"
|
||||
className="input"
|
||||
value={branchMode ?? "project-default"}
|
||||
onChange={(event) => onBranchModeChange(event.target.value as BranchSelectionMode)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="project-default">Use project/default branch</option>
|
||||
<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>
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
{onBranchChange && (!onBranchModeChange || branchMode === "existing" || branchMode === "custom-new") && (
|
||||
<>
|
||||
<label htmlFor="task-working-branch" className="model-select-label">
|
||||
{onBranchModeChange ? "Branch name" : "Working branch"}
|
||||
</label>
|
||||
<input
|
||||
id="task-working-branch"
|
||||
className="input"
|
||||
|
||||
@@ -215,27 +215,7 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes branch and baseBranch when provided", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: " feature/fn-3422 " } });
|
||||
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: "feature/fn-3422",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits branch and baseBranch when left blank", async () => {
|
||||
it("submits project-default branch selection by default", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task without branches" } });
|
||||
@@ -244,8 +224,89 @@ describe("NewTaskModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: undefined,
|
||||
baseBranch: undefined,
|
||||
branchSelection: { mode: "project-default" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits existing branch selection with trimmed names", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "existing" } });
|
||||
fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/fn-3422 " } });
|
||||
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branchSelection: {
|
||||
mode: "existing",
|
||||
branchName: "feature/fn-3422",
|
||||
baseBranch: "main",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits auto-new branch selection", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with auto new" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "auto-new" } });
|
||||
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branchSelection: {
|
||||
mode: "auto-new",
|
||||
baseBranch: "main",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("requires branch name for custom-new mode", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
|
||||
|
||||
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 custom-new branch selection when branch name exists", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with custom new" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
|
||||
fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/custom " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branchSelection: {
|
||||
mode: "custom-new",
|
||||
branchName: "feature/custom",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveAutoTaskBranch,
|
||||
derivePerTaskBranch,
|
||||
getBranchSelectionMode,
|
||||
resolveBranchAssignmentContext,
|
||||
resolveBranchSelection,
|
||||
} from "../routes/branch-selection.js";
|
||||
@@ -33,4 +35,15 @@ describe("branch-selection", () => {
|
||||
expect(derivePerTaskBranch("feature/planning", "FN-123 add parser")).toBe("feature/planning/fn-123-add-parser");
|
||||
expect(derivePerTaskBranch(undefined, "FN-123")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives auto task branches from id + short name", () => {
|
||||
expect(deriveAutoTaskBranch("FN-5671", "Branch Strategy Dropdown")).toBe("fusion/fn-5671-branch-strategy-dropdown");
|
||||
expect(deriveAutoTaskBranch("FN-5671", " ")).toBe("fusion/fn-5671");
|
||||
expect(deriveAutoTaskBranch("FN-5671", "!!!")).toBe("fusion/fn-5671");
|
||||
});
|
||||
|
||||
it("reads requested branch mode", () => {
|
||||
expect(getBranchSelectionMode(undefined)).toBeUndefined();
|
||||
expect(getBranchSelectionMode({ mode: "auto-new" })).toBe("auto-new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -867,6 +867,78 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("applies branchSelection on create when supplied", async () => {
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage",
|
||||
branch: "feature/existing",
|
||||
baseBranch: "main",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Task with branch selection",
|
||||
branchSelection: {
|
||||
mode: "existing",
|
||||
branchName: " feature/existing ",
|
||||
baseBranch: " main ",
|
||||
},
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: "feature/existing",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists an auto-derived branch for auto-new branchSelection", async () => {
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-5671",
|
||||
title: "Branch Strategy Dropdown",
|
||||
description: "Task with auto-new strategy",
|
||||
column: "triage",
|
||||
branch: undefined,
|
||||
});
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-5671",
|
||||
title: "Branch Strategy Dropdown",
|
||||
description: "Task with auto-new strategy",
|
||||
column: "triage",
|
||||
branch: "fusion/fn-5671-branch-strategy-dropdown",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({
|
||||
description: "Task with auto-new strategy",
|
||||
title: "Branch Strategy Dropdown",
|
||||
branchSelection: {
|
||||
mode: "auto-new",
|
||||
},
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-5671", {
|
||||
branch: "fusion/fn-5671-branch-strategy-dropdown",
|
||||
});
|
||||
expect(res.body.branch).toBe("fusion/fn-5671-branch-strategy-dropdown");
|
||||
});
|
||||
|
||||
it("returns 400 when create branch payload is not a string", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
|
||||
@@ -12,6 +12,27 @@ export interface BranchSelectionPayload {
|
||||
baseBranch?: unknown;
|
||||
}
|
||||
|
||||
export function getBranchSelectionMode(selectionInput: unknown): BranchSelectionMode | undefined {
|
||||
if (selectionInput === undefined || selectionInput === null) return undefined;
|
||||
if (typeof selectionInput !== "object" || Array.isArray(selectionInput)) {
|
||||
throw badRequest("branchSelection must be an object");
|
||||
}
|
||||
const selection = selectionInput as BranchSelectionPayload;
|
||||
const mode = typeof selection.mode === "string" ? selection.mode : undefined;
|
||||
if (!mode) {
|
||||
throw badRequest("branchSelection.mode is required");
|
||||
}
|
||||
if (![
|
||||
"project-default",
|
||||
"auto-new",
|
||||
"existing",
|
||||
"custom-new",
|
||||
].includes(mode)) {
|
||||
throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new");
|
||||
}
|
||||
return mode as BranchSelectionMode;
|
||||
}
|
||||
|
||||
export type PlanningBranchMode = "shared" | "per-task-derived";
|
||||
|
||||
export interface ResolvedBranchSelection {
|
||||
@@ -50,24 +71,8 @@ export function resolveBranchSelection(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof selectionInput !== "object" || Array.isArray(selectionInput)) {
|
||||
throw badRequest("branchSelection must be an object");
|
||||
}
|
||||
|
||||
const mode = getBranchSelectionMode(selectionInput);
|
||||
const selection = selectionInput as BranchSelectionPayload;
|
||||
const mode = typeof selection.mode === "string" ? selection.mode : undefined;
|
||||
if (!mode) {
|
||||
throw badRequest("branchSelection.mode is required");
|
||||
}
|
||||
|
||||
if (![
|
||||
"project-default",
|
||||
"auto-new",
|
||||
"existing",
|
||||
"custom-new",
|
||||
].includes(mode)) {
|
||||
throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new");
|
||||
}
|
||||
|
||||
const baseBranch = normalizeOptionalBranch(selection.baseBranch, "branchSelection.baseBranch");
|
||||
|
||||
@@ -118,6 +123,12 @@ function sanitizeSegment(input: string): string {
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
export function deriveAutoTaskBranch(taskId: string, shortName: string): string {
|
||||
const base = `fusion/${taskId.toLowerCase()}`;
|
||||
const segment = sanitizeSegment(shortName ?? "");
|
||||
return segment ? `${base}-${segment}` : base;
|
||||
}
|
||||
|
||||
export function derivePerTaskBranch(sharedBranch: string | undefined, taskSegment: string): string | undefined {
|
||||
const base = normalizeOptionalBranch(sharedBranch, "sharedBranch");
|
||||
if (!base) return undefined;
|
||||
|
||||
@@ -43,7 +43,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 { resolveBranchSelection } from "./branch-selection.js";
|
||||
import { deriveAutoTaskBranch, 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;
|
||||
@@ -940,6 +940,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
? source
|
||||
: { sourceType: "api" as const };
|
||||
|
||||
const requestedBranchMode = getBranchSelectionMode(branchSelection);
|
||||
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch } =
|
||||
resolveBranchSelection(branchSelection, branch, baseBranch);
|
||||
|
||||
@@ -1222,8 +1223,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
|
||||
);
|
||||
|
||||
// Newly created tasks are still triage/todo and cannot be worktree-acquired until
|
||||
// moved to in-progress, so this in-request branch update is safe.
|
||||
const taskWithAutoBranch = requestedBranchMode === "auto-new"
|
||||
? await scopedStore.updateTask(task.id, {
|
||||
branch: deriveAutoTaskBranch(
|
||||
task.id,
|
||||
(((task.title ?? "").trim() || task.description).slice(0, 60)),
|
||||
),
|
||||
})
|
||||
: task;
|
||||
|
||||
const deterministicReconcile = await reconcileDeterministicDuplicate(scopedStore, {
|
||||
createdTask: task,
|
||||
createdTask: taskWithAutoBranch,
|
||||
fingerprint: bypassDuplicateCheck === true ? null : contentFingerprint,
|
||||
windowMs: 60_000,
|
||||
logger: runtimeLogger,
|
||||
@@ -1237,8 +1249,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
try {
|
||||
await scopedStore.recordActivity({
|
||||
type: "task:duplicate-warning-overridden",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
taskId: taskWithAutoBranch.id,
|
||||
taskTitle: taskWithAutoBranch.title,
|
||||
details: `Created despite ${acknowledgedDuplicateIds.length} possible duplicate(s): ${acknowledgedDuplicateIds.join(", ")}`,
|
||||
metadata: {
|
||||
acknowledgedDuplicateIds,
|
||||
@@ -1253,7 +1265,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(task);
|
||||
res.status(201).json(taskWithAutoBranch);
|
||||
return;
|
||||
} finally {
|
||||
deterministicGuard.releaseLock();
|
||||
|
||||
@@ -363,6 +363,46 @@ describe("WorktreePool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps slugged fusion branches to canonical task IDs", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git checkout -B "fusion/fn-5671-add-dropdown" main') {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from("fatal: 'fusion/fn-5671-add-dropdown' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-5671-add-dropdown", ""].join("\n"));
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-5671-add-dropdown^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "live-foreign",
|
||||
livePath: "/other/wt",
|
||||
error: new BranchConflictError({
|
||||
branchName: "fusion/fn-5671-add-dropdown",
|
||||
conflictingWorktreePath: "/other/wt",
|
||||
existingTipSha: "abc123def456",
|
||||
strandedCommits: [],
|
||||
startPoint: "main",
|
||||
recommendedAction: "Inspect/reclaim.",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-5671-add-dropdown")).rejects.toBeInstanceOf(BranchConflictError);
|
||||
expect(inspectSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
branchName: "fusion/fn-5671-add-dropdown",
|
||||
ownerTaskId: "FN-5671",
|
||||
requestingTaskId: "FN-5671",
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws BranchConflictError for cross-task live-foreign conflicts", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
@@ -897,6 +937,7 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
expect(removeCalls[0][0]).not.toContain("active-wt");
|
||||
});
|
||||
|
||||
|
||||
it("handles git worktree remove failures gracefully (non-fatal)", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry("fail-wt"),
|
||||
|
||||
@@ -252,7 +252,7 @@ export function isInsideWorktreesDir(
|
||||
* up via {@link cleanupOrphanedWorktrees}.
|
||||
*/
|
||||
function deriveTaskIdFromBranch(branchName: string): string {
|
||||
const match = branchName.match(/^fusion\/(fn-\d+)(?:-\d+)?$/i);
|
||||
const match = branchName.match(/^fusion\/(fn-\d+)(?:-\d+)?(?:-[a-z0-9._-]+)*$/i);
|
||||
return match ? match[1].toUpperCase() : branchName.toUpperCase();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user