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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user