FN-5677: add per-task auto-merge override controls

Add dashboard and API support to set or clear per-task auto-merge overrides during review.

- add TaskReviewTab UI controls and styles for selecting or clearing an auto-merge override
- wire legacy dashboard API request handling for task auto-merge override updates
- update task workflow routes to persist override operations
- add dashboard and route tests covering set/clear override behavior

Files changed:
 packages/dashboard/app/__tests__/api-tasks.test.ts | 26 +++++++++
 packages/dashboard/app/api/legacy.ts               |  1 +
 packages/dashboard/app/components/TaskReviewTab.css     | 17 ++++++
 packages/dashboard/app/components/TaskReviewTab.tsx     | 55 +++++++++++++++++-
 packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx    | 48 ++++++++++++++++
 packages/dashboard/src/__tests__/routes-tasks-ops.test.ts         | 65 ++++++++++++++++++++++
 packages/dashboard/src/routes/register-task-workflow-routes.ts    |  7 ++-
 7 files changed, 216 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5677

Fusion-Task-Lineage: 4503e605-1190-4a32-8736-dd11eb2756b8
This commit is contained in:
gsxdsm
2026-05-29 10:55:25 -07:00
parent a014c6d42d
commit 91137ecb51
7 changed files with 216 additions and 3 deletions

View File

@@ -474,6 +474,32 @@ describe("updateTask", () => {
});
});
it("sends PATCH with autoMerge boolean when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, autoMerge: true }));
const result = await updateTask("FN-001", { autoMerge: true });
expect(result.autoMerge).toBe(true);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ autoMerge: true }),
});
});
it("sends PATCH with null to clear autoMerge", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, autoMerge: undefined }));
const result = await updateTask("FN-001", { autoMerge: null });
expect(result.autoMerge).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ autoMerge: null }),
});
});
it("omits executionMode key when not provided in update", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, title: "Updated" }));

View File

@@ -454,6 +454,7 @@ export function updateTask(
reviewLevel?: number | null;
executionMode?: "standard" | "fast" | null;
noCommitsExpected?: boolean;
autoMerge?: boolean | null;
priority?: TaskPriority | null;
sourceIssue?: TaskSourceIssue | null;
nodeId?: string | null;

View File

@@ -48,6 +48,14 @@
.task-review-tab__actions {
display: flex;
gap: var(--space-sm);
align-items: flex-start;
}
.task-review-tab__auto-merge-control {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
min-inline-size: max-content;
}
.task-review-tab__list {
@@ -179,6 +187,15 @@
flex-wrap: wrap;
}
.task-review-tab__auto-merge-control {
width: 100%;
min-inline-size: 0;
}
.task-review-tab__auto-merge-control .select {
width: 100%;
}
.task-review-tab__refresh-meta {
align-items: flex-start;
}

View File

@@ -1,11 +1,12 @@
import "./TaskReviewTab.css";
import type { Task, TaskDetail } from "@fusion/core";
import { getErrorMessage, type Task, type TaskDetail } from "@fusion/core";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { GitPullRequest } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api";
import type { SelectedReviewItem } from "../api";
import type { ToastType } from "../hooks/useToast";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
@@ -150,6 +151,10 @@ export function TaskReviewTab({
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
const [review, setReview] = useState(task.reviewState ?? null);
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(() => readBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, true));
const [autoMergePreference, setAutoMergePreference] = useState<"follow-default" | "on" | "off">(
task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default",
);
const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false);
const canRevise = selected.length > 0 && !revising;
const isPrMode = review?.source === "pull-request";
@@ -159,6 +164,10 @@ export function TaskReviewTab({
writeBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
}, [renderMarkdown]);
useEffect(() => {
setAutoMergePreference(task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default");
}, [task.autoMerge]);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -229,6 +238,25 @@ export function TaskReviewTab({
}
};
const onAutoMergePreferenceChange = async (nextPreference: "follow-default" | "on" | "off") => {
const previousPreference = autoMergePreference;
setAutoMergePreference(nextPreference);
setIsSavingAutoMergePreference(true);
try {
const autoMerge = nextPreference === "follow-default" ? null : nextPreference === "on";
const updatedTask = await updateTask(task.id, { autoMerge }, projectId);
setAutoMergePreference(updatedTask.autoMerge === true ? "on" : updatedTask.autoMerge === false ? "off" : "follow-default");
onTaskUpdated?.(updatedTask);
addToast("Per-task auto-merge preference updated", "success");
} catch (updateError) {
setAutoMergePreference(previousPreference);
addToast(`Failed to update ${task.id}: ${getErrorMessage(updateError)}`, "error");
} finally {
setIsSavingAutoMergePreference(false);
}
};
const onRevise = async () => {
try {
if (!review) return;
@@ -279,6 +307,9 @@ export function TaskReviewTab({
}
};
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled });
const effectiveAutoMergeLabel = effectiveAutoMerge ? "Auto-merge on" : "Auto-merge off";
return (
<div className="task-review-tab">
<div className="task-review-tab__header">
@@ -287,6 +318,26 @@ export function TaskReviewTab({
{decisionLabel ? <span className={`task-review-tab__decision task-review-tab__decision--${decisionLabel}`}>{decisionLabel}</span> : null}
</div>
<div className="task-review-tab__actions">
<div className="task-review-tab__auto-merge-control">
<label htmlFor="task-review-auto-merge-select" className="form-label">Per-task auto-merge</label>
<select
id="task-review-auto-merge-select"
className="select"
value={autoMergePreference}
onChange={(event) => void onAutoMergePreferenceChange(event.target.value as "follow-default" | "on" | "off")}
disabled={isSavingAutoMergePreference}
data-testid="task-review-auto-merge-select"
>
<option value="follow-default">Follow default</option>
<option value="on">Auto-merge on</option>
<option value="off">Auto-merge off</option>
</select>
<div className="task-review-tab__meta" data-testid="task-review-auto-merge-effective-hint">
{task.column === "in-review"
? `Effective: ${effectiveAutoMergeLabel} — frozen on entry to review`
: `Effective: ${effectiveAutoMergeLabel}`}
</div>
</div>
{task.column === "in-review" && !task.prInfo && prAuthAvailable === true && autoMergeEnabled !== true && typeof onRequestCreatePr === "function" ? (
<button className="btn btn-sm" onClick={() => onRequestCreatePr?.()} data-testid="task-review-create-pr">
<GitPullRequest />

View File

@@ -10,12 +10,14 @@ const apiMocks = vi.hoisted(() => ({
fetchTaskReview: vi.fn(),
refreshTaskReview: vi.fn(),
reviseTaskReviewItems: vi.fn(),
updateTask: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchTaskReview: apiMocks.fetchTaskReview,
refreshTaskReview: apiMocks.refreshTaskReview,
reviseTaskReviewItems: apiMocks.reviseTaskReviewItems,
updateTask: apiMocks.updateTask,
}));
describe("TaskReviewTab", () => {
@@ -581,4 +583,50 @@ describe("TaskReviewTab", () => {
expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalledWith(task.id, [expect.objectContaining({ id: "reviewer-code-1", source: "reviewer-agent" })], undefined);
});
it("updates per-task auto-merge preference for on/off/follow default", async () => {
const onTaskUpdated = vi.fn();
const task = makeTask({ autoMerge: undefined, reviewState: { source: "pull-request", items: [], addressing: [] } });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
apiMocks.updateTask.mockResolvedValueOnce({ ...task, autoMerge: true });
apiMocks.updateTask.mockResolvedValueOnce({ ...task, autoMerge: false });
apiMocks.updateTask.mockResolvedValueOnce({ ...task, autoMerge: undefined });
render(<TaskReviewTab task={task} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
const select = await screen.findByTestId("task-review-auto-merge-select");
fireEvent.change(select, { target: { value: "on" } });
await waitFor(() => expect(apiMocks.updateTask).toHaveBeenCalledWith(task.id, { autoMerge: true }, undefined));
fireEvent.change(select, { target: { value: "off" } });
await waitFor(() => expect(apiMocks.updateTask).toHaveBeenCalledWith(task.id, { autoMerge: false }, undefined));
fireEvent.change(select, { target: { value: "follow-default" } });
await waitFor(() => expect(apiMocks.updateTask).toHaveBeenCalledWith(task.id, { autoMerge: null }, undefined));
expect(onTaskUpdated).toHaveBeenCalledTimes(3);
});
it("shows effective auto-merge hint for in-review tasks using global default", async () => {
const inReviewTask = makeTask({ column: "in-review", autoMerge: undefined, reviewState: { source: "pull-request", items: [], addressing: [] } });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: inReviewTask.reviewState, automationStatus: null, emptyMessage: null });
const { rerender } = render(<TaskReviewTab task={inReviewTask} addToast={vi.fn()} autoMergeEnabled />);
expect(await screen.findByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent("Effective: Auto-merge on — frozen on entry to review");
rerender(<TaskReviewTab task={inReviewTask} addToast={vi.fn()} autoMergeEnabled={false} />);
await waitFor(() => expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent("Effective: Auto-merge off — frozen on entry to review"));
});
it("reflects current per-task auto-merge selection", async () => {
const task = makeTask({ autoMerge: true, reviewState: { source: "pull-request", items: [], addressing: [] } });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
const { rerender } = render(<TaskReviewTab task={task} addToast={vi.fn()} />);
expect(await screen.findByTestId("task-review-auto-merge-select")).toHaveValue("on");
rerender(<TaskReviewTab task={makeTask({ autoMerge: false, reviewState: { source: "pull-request", items: [], addressing: [] } })} addToast={vi.fn()} />);
await waitFor(() => expect(screen.getByTestId("task-review-auto-merge-select")).toHaveValue("off"));
});
});

View File

@@ -2953,6 +2953,71 @@ describe("PATCH /tasks/:id", () => {
const updateArg = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(updateArg).not.toHaveProperty("executionMode");
});
it("forwards autoMerge to store.updateTask", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
autoMerge: true,
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
autoMerge: true,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
autoMerge: true,
});
});
it("accepts null to clear autoMerge via PATCH", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
autoMerge: undefined,
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
autoMerge: null,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
autoMerge: undefined,
});
});
it("returns 400 for invalid autoMerge value via PATCH", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
autoMerge: "yes",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("autoMerge must be a boolean");
});
it("omission does not overwrite autoMerge via PATCH", async () => {
const existingTask = {
...FAKE_TASK_DETAIL,
autoMerge: false,
};
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(existingTask);
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
title: "Updated Title",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
const updateArg = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(updateArg).not.toHaveProperty("autoMerge");
});
});

View File

@@ -2711,7 +2711,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.patch("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, noCommitsExpected, overlapBlockedBy, status } = req.body;
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
@@ -2767,6 +2767,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw new Error("noCommitsExpected must be a boolean");
}
if (hasBodyField("autoMerge") && autoMerge !== undefined && autoMerge !== null && typeof autoMerge !== "boolean") {
throw new Error("autoMerge must be a boolean");
}
let validatedSourceIssue: import("@fusion/core").TaskSourceIssue | null | undefined;
if (hasBodyField("sourceIssue")) {
if (sourceIssue === null) {
@@ -2904,6 +2908,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (dependencies !== undefined) updates.dependencies = dependencies;
if (enabledWorkflowSteps !== undefined) updates.enabledWorkflowSteps = enabledWorkflowSteps;
if (hasBodyField("noCommitsExpected")) updates.noCommitsExpected = noCommitsExpected;
if (hasBodyField("autoMerge")) updates.autoMerge = autoMerge === null ? undefined : autoMerge;
if (hasBodyField("modelProvider")) updates.modelProvider = validatedModelProvider;
if (hasBodyField("modelId")) updates.modelId = validatedModelId;
if (hasBodyField("validatorModelProvider")) updates.validatorModelProvider = validatedValidatorModelProvider;