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