FN-5717: add per-task auto-merge override in new task form

Add a New Task form option to override auto-merge behavior per task and wire it through task creation APIs.

- add TaskForm UI state/prop support for enabling or disabling per-task auto-merge override
- pass override values through NewTaskModal and legacy task creation API payloads
- persist and validate settings.autoMerge overrides in task workflow route handling
- add dashboard and route tests covering override defaults and explicit true/false behavior
- document per-task auto-merge override behavior in dashboard and settings docs

Files changed:
 docs/dashboard-guide.md                            |  1 +
 docs/settings-reference.md                         |  2 +-
 packages/dashboard/app/api/legacy.ts               |  2 +
 packages/dashboard/app/components/NewTaskModal.tsx | 11 ++++-
 packages/dashboard/app/components/TaskForm.tsx     | 27 ++++++++++++
 .../app/components/__tests__/NewTaskModal.test.tsx | 51 ++++++++++++++++++++++
 .../dashboard/src/__tests__/routes-tasks.test.ts   | 33 ++++++++++++++
 .../src/routes/register-task-workflow-routes.ts    |  6 +++
 8 files changed, 130 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5717

Fusion-Task-Lineage: 3bdbb561-9c6d-4f64-ad4b-ae723e73223e
This commit is contained in:
gsxdsm
2026-05-30 10:11:23 -07:00
parent cf95d3636f
commit da3f4aa967
8 changed files with 130 additions and 3 deletions

View File

@@ -377,6 +377,7 @@ export async function createTask(
summarize,
reviewLevel,
executionMode,
autoMerge,
priority,
source,
nodeId,
@@ -412,6 +413,7 @@ export async function createTask(
summarize,
reviewLevel,
executionMode,
autoMerge,
priority,
source,
nodeId,

View File

@@ -56,6 +56,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false);
@@ -161,6 +162,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
selectedWorkflowSteps.length > 0 ||
selectedAgentId !== null ||
reviewLevel !== undefined ||
autoMerge !== undefined ||
priority !== DEFAULT_TASK_PRIORITY ||
nodeId !== undefined ||
branchMode !== "project-default" ||
@@ -169,7 +171,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
githubTrackingEnabled ||
githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
const handleClose = useCallback(async () => {
if (hasDirtyState) {
@@ -197,6 +199,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
setAutoMerge(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setBranchMode("project-default");
@@ -242,6 +245,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
reviewLevel,
...(autoMerge !== undefined ? { autoMerge } : {}),
priority,
nodeId,
branchSelection: {
@@ -292,6 +296,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
setAutoMerge(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setBranchMode("project-default");
@@ -305,7 +310,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
} finally {
setIsSubmitting(false);
}
}, [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]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -505,6 +510,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onThinkingLevelChange={setThinkingLevel}
reviewLevel={reviewLevel}
onReviewLevelChange={setReviewLevel}
autoMerge={autoMerge}
onAutoMergeChange={setAutoMerge}
priority={priority}
onPriorityChange={setPriority}
branch={branch}

View File

@@ -121,6 +121,8 @@ export interface TaskFormProps {
// Review level (0=None, 1=Plan Only, 2=Plan and Code, 3=Full)
reviewLevel?: number;
onReviewLevelChange?: (value: number | undefined) => void;
autoMerge?: boolean | undefined;
onAutoMergeChange?: (value: boolean | undefined) => void;
executionMode?: TaskExecutionModeSelection;
onExecutionModeChange?: (value: TaskExecutionModeSelection) => void;
githubTrackingEnabled?: boolean;
@@ -196,6 +198,8 @@ export function TaskForm({
autoExpandMoreOptionsOnSelection = true,
reviewLevel,
onReviewLevelChange,
autoMerge,
onAutoMergeChange,
executionMode,
onExecutionModeChange,
githubTrackingEnabled,
@@ -214,6 +218,7 @@ export function TaskForm({
(planningModel || "") !== "" ||
(thinkingLevel || "") !== "" ||
reviewLevel !== undefined ||
autoMerge !== undefined ||
executionMode === "fast" ||
(branch || "") !== "" ||
(baseBranch || "") !== "" ||
@@ -293,6 +298,7 @@ export function TaskForm({
(planningModel || "") !== "" ||
(thinkingLevel || "") !== "" ||
reviewLevel !== undefined ||
autoMerge !== undefined ||
executionMode === "fast" ||
(branch || "") !== "" ||
(baseBranch || "") !== "" ||
@@ -1290,6 +1296,27 @@ export function TaskForm({
</select>
</div>
)}
{onAutoMergeChange && (
<div className="model-select-row">
<label htmlFor="task-automerge-select" className="model-select-label">Auto-merge</label>
<select
id="task-automerge-select"
data-testid="task-automerge-select"
value={autoMerge === undefined ? "" : autoMerge ? "on" : "off"}
onChange={(e) => {
if (e.target.value === "on") return onAutoMergeChange(true);
if (e.target.value === "off") return onAutoMergeChange(false);
return onAutoMergeChange(undefined);
}}
disabled={disabled}
>
<option value="">Default (Follow project setting)</option>
<option value="on">Enabled</option>
<option value="off">Disabled</option>
</select>
<small>Default follows the project auto-merge setting.</small>
</div>
)}
</>
)}
</div>

View File

@@ -851,6 +851,57 @@ describe("NewTaskModal", () => {
});
});
describe("auto-merge selection payload", () => {
it("omits autoMerge from payload when default is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task default auto-merge" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.not.objectContaining({ autoMerge: expect.anything() }),
);
});
});
it("includes autoMerge true when Enabled is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByTestId("task-automerge-select")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("task-automerge-select"), { target: { value: "on" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task auto-merge on" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ autoMerge: true }),
);
});
});
it("includes autoMerge false when Disabled is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByTestId("task-automerge-select")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("task-automerge-select"), { target: { value: "off" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task auto-merge off" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ autoMerge: false }),
);
});
});
});
describe("priority selection payload", () => {
it("includes default normal priority in create payload", async () => {
const { props } = renderNewTaskModal();

View File

@@ -1232,6 +1232,39 @@ describe("POST /tasks", () => {
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards autoMerge when provided", async () => {
const createdTask = { ...FAKE_TASK_DETAIL, column: "triage", autoMerge: true };
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", autoMerge: true }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ autoMerge: true }),
expect.any(Object),
);
});
it("returns 400 for invalid autoMerge value via POST", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({ description: "Test task", autoMerge: "true" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("autoMerge must be a boolean");
expect(store.createTask).not.toHaveBeenCalled();
});
it("forwards priority when provided", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,

View File

@@ -834,6 +834,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
thinkingLevel,
reviewLevel,
executionMode,
autoMerge,
priority,
source,
branch,
@@ -887,6 +888,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw badRequest(`executionMode must be one of: ${validExecutionModes.join(", ")}`);
}
if (autoMerge !== undefined && typeof autoMerge !== "boolean") {
throw badRequest("autoMerge must be a boolean");
}
// Validate priority if provided.
if (priority !== undefined && priority !== null && !isTaskPriority(priority)) {
throw badRequest(`priority must be one of: ${TASK_PRIORITIES.join(", ")}`);
@@ -1201,6 +1206,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
summarize,
reviewLevel: reviewLevel ?? undefined,
executionMode: executionMode || undefined,
...(typeof autoMerge === "boolean" ? { autoMerge } : {}),
priority: priority ?? undefined,
source: {
...normalizedTaskSource,