FN-5953: hide manual PR actions when effective auto-merge is on

Honor per-task auto-merge overrides when deciding whether to offer manual PR creation.

- resolve effective auto-merge state before showing Create PR actions on task cards and review views
- pass effective auto-merge through the task detail modal into the PR panel
- expand dashboard tests to cover project-default and per-task auto-merge combinations for PR affordances

Files changed:
 packages/dashboard/app/components/TaskCard.tsx     |  6 ++-
 .../dashboard/app/components/TaskDetailModal.tsx   |  4 +-
 .../dashboard/app/components/TaskReviewTab.tsx     |  2 +-
 .../app/components/__tests__/PrPanel.test.tsx      | 41 +++++++++++++--
 .../app/components/__tests__/TaskCard.test.tsx     | 56 ++++++++++++++------
 .../__tests__/TaskDetailModal.create-pr.test.tsx   | 60 ++++++++++++++++++++++
 .../components/__tests__/TaskReviewTab.test.tsx    | 57 ++++++++++++++++----
 7 files changed, 189 insertions(+), 37 deletions(-)

Fusion-Task-Id: FN-5953

Fusion-Task-Lineage: ffa67b87-922a-47ca-8523-1c62e8e6c48b
This commit is contained in:
gsxdsm
2026-06-07 14:13:38 -07:00
parent 294209f64b
commit 645f3c22fe
7 changed files with 189 additions and 37 deletions

View File

@@ -11,6 +11,7 @@ import {
VALID_TRANSITIONS,
getErrorMessage,
} from "@fusion/core";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { PrCreateModal } from "./PrCreateModal";
@@ -402,7 +403,7 @@ interface TaskCardProps {
fanout?: BlockerFanoutEntry;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
/** Whether project-level auto-merge is enabled (hides manual Create PR quick action when true). */
/** Project default auto-merge setting; per-task overrides are applied via resolveEffectiveAutoMerge. */
autoMergeEnabled?: boolean;
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
* Empty/undefined → no field badges render (card byte-identical to today). */
@@ -1264,9 +1265,10 @@ function TaskCardComponent({
}, [liveBadgeData, batchData, task.issueInfo, task.updatedAt]);
const showInReviewMoveControl = task.column === "in-review" && Boolean(onMoveTask);
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled ?? false });
const showCreatePrQuickAction =
task.column === "in-review"
&& autoMergeEnabled !== true
&& !effectiveAutoMerge
&& !livePrInfo
&& prAuthAvailable === true
&& !isPaused

View File

@@ -21,6 +21,7 @@ import {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, api } from "../api";
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { ApiRequestError } from "../api";
@@ -2571,6 +2572,7 @@ export function TaskDetailContent({
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
const mergeStrategy = settings?.mergeStrategy ?? "direct";
const autoMergeEnabled = settings?.autoMerge ?? false;
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled });
const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled;
const isCheckPrStatusAction = isManualPrFlow && !prAutomationLabel && task.prInfo?.status === "open";
@@ -3247,7 +3249,7 @@ export function TaskDetailContent({
prInfos={task.prInfos}
automationStatus={task.status ?? null}
taskColumn={task.column}
autoMerge={settings?.autoMerge ?? false}
autoMerge={effectiveAutoMerge}
isManualPrFlow={isManualPrFlow}
directMergeCommitStrategy={settings?.directMergeCommitStrategy}
prAuthAvailable={prAuthAvailable ?? false}

View File

@@ -346,7 +346,7 @@ export function TaskReviewTab({
: t("taskReview.effective", "Effective: {{label}}", { label: effectiveAutoMergeLabel })}
</div>
</div>
{task.column === "in-review" && !task.prInfo && prAuthAvailable === true && autoMergeEnabled !== true && typeof onRequestCreatePr === "function" ? (
{task.column === "in-review" && !task.prInfo && prAuthAvailable === true && !effectiveAutoMerge && typeof onRequestCreatePr === "function" ? (
<button className="btn btn-sm" onClick={() => onRequestCreatePr?.()} data-testid="task-review-create-pr">
<GitPullRequest />
{t("taskReview.createPr", "Create PR")}

View File

@@ -61,10 +61,22 @@ describe("PrPanel", () => {
});
});
it("renders create button and calls onRequestCreatePr", () => {
it.each([
{
name: "hides the button and shows the auto-merge hint when effective auto-merge is on",
autoMerge: true,
shouldShowCreateButton: false,
},
{
name: "shows the button when effective auto-merge is off",
autoMerge: false,
shouldShowCreateButton: true,
},
])("empty PR state $name", ({ autoMerge, shouldShowCreateButton }) => {
render(
<PrPanel
taskId="FN-001"
autoMerge={autoMerge}
prAuthAvailable={true}
onRequestCreatePr={mockOnRequestCreatePr}
onPrUpdated={mockOnPrUpdated}
@@ -72,7 +84,14 @@ describe("PrPanel", () => {
/>
);
fireEvent.click(screen.getByRole("button", { name: /Create PR/i }));
if (!shouldShowCreateButton) {
expect(screen.getByText(/Auto-merge will handle this task automatically./i)).toBeInTheDocument();
expect(screen.queryByTestId("pr-panel-create-pr")).toBeNull();
expect(mockOnRequestCreatePr).not.toHaveBeenCalled();
return;
}
fireEvent.click(screen.getByTestId("pr-panel-create-pr"));
expect(mockOnRequestCreatePr).toHaveBeenCalledTimes(1);
});
@@ -95,9 +114,21 @@ describe("PrPanel", () => {
expect(screen.queryByRole("button", { name: /Create PR/i })).toBeNull();
});
it("shows autoMerge hint in no-PR state", () => {
render(<PrPanel taskId="FN-001" autoMerge={true} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
expect(screen.getByText(/Auto-merge will handle this task automatically./i)).toBeInTheDocument();
it("shows create button when auto-merge is off even if manual PR flow hint is shown", () => {
render(
<PrPanel
taskId="FN-001"
autoMerge={false}
isManualPrFlow
prAuthAvailable={true}
onRequestCreatePr={mockOnRequestCreatePr}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByTestId("pr-panel-create-pr")).toBeInTheDocument();
expect(screen.getByText(/Use the footer action to run PR-first completion for this task./i)).toBeInTheDocument();
});
it("renders PR details when prInfo exists", () => {

View File

@@ -646,34 +646,56 @@ describe("TaskCard", () => {
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("does not render Create PR quick action when autoMergeEnabled is true", () => {
it.each([
{
name: "hides when task override turns auto-merge on while project default is off",
taskAutoMerge: true,
autoMergeEnabled: false,
shouldShow: false,
},
{
name: "hides when task follows an enabled project default",
taskAutoMerge: undefined,
autoMergeEnabled: true,
shouldShow: false,
},
{
name: "shows when task override turns auto-merge off while project default is on",
taskAutoMerge: false,
autoMergeEnabled: true,
shouldShow: true,
},
{
name: "shows when task follows a disabled project default",
taskAutoMerge: undefined,
autoMergeEnabled: false,
shouldShow: true,
},
])("Create PR quick action $name", ({ taskAutoMerge, autoMergeEnabled, shouldShow }) => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
task={makeTask({
column: "in-review",
paused: false,
userPaused: false,
prInfo: undefined as any,
autoMerge: taskAutoMerge,
})}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
autoMergeEnabled={true}
autoMergeEnabled={autoMergeEnabled}
/>,
);
if (shouldShow) {
expect(screen.getByRole("button", { name: "Create pull request" })).toBeDefined();
return;
}
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("renders Create PR quick action when autoMergeEnabled is false", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
autoMergeEnabled={false}
/>,
);
expect(screen.getByRole("button", { name: "Create pull request" })).toBeDefined();
});
it("does not render Create PR quick action when task already has prInfo", () => {
render(
<TaskCard

View File

@@ -4,6 +4,7 @@ import type { PrInfo } from "@fusion/core";
const prPanelState = vi.hoisted(() => ({
latestPrInfo: undefined as PrInfo | undefined,
latestAutoMerge: undefined as boolean | undefined,
}));
const prCreateModalState = vi.hoisted(() => ({
@@ -17,6 +18,7 @@ const taskReviewTabState = vi.hoisted(() => ({
vi.mock("../PrPanel", () => ({
PrPanel: (props: any) => {
prPanelState.latestPrInfo = props.prInfo;
prPanelState.latestAutoMerge = props.autoMerge;
return (
<div>
<button type="button" onClick={() => props.onRequestCreatePr?.()}>
@@ -80,6 +82,7 @@ import {
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { fetchSettings } from "../../api";
import { TaskDetailModal } from "../TaskDetailModal";
setupTaskDetailModalHooks();
@@ -88,6 +91,7 @@ describe("TaskDetailModal create-PR wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
prPanelState.latestPrInfo = undefined;
prPanelState.latestAutoMerge = undefined;
prCreateModalState.latestProps = null;
taskReviewTabState.latestProps = null;
});
@@ -138,7 +142,63 @@ describe("TaskDetailModal create-PR wiring", () => {
}));
});
it("passes effective auto-merge on to PrPanel for per-task override-on with project default off", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
autoMerge: false,
});
render(
<TaskDetailModal
task={makeTask({ id: "FN-5953-A", prInfo: undefined, column: "in-review", autoMerge: true })}
projectId="project-123"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Pull Request" }));
await waitFor(() => expect(prPanelState.latestAutoMerge).toBe(true));
});
it("passes effective auto-merge off to PrPanel for per-task override-off with project default on", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
autoMerge: true,
});
render(
<TaskDetailModal
task={makeTask({ id: "FN-5953-B", prInfo: undefined, column: "in-review", autoMerge: false })}
projectId="project-123"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Pull Request" }));
await waitFor(() => expect(prPanelState.latestAutoMerge).toBe(false));
});
it("opens the same PrCreateModal from TaskReviewTab without leaving the Review tab and closes via onClose", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
autoMerge: false,
});
const task = makeTask({ id: "FN-5021", prInfo: undefined, column: "in-review" });
render(

View File

@@ -501,10 +501,11 @@ describe("TaskReviewTab", () => {
it("keeps mobile actions wrapping contract, stacks header groups, and prevents body overflow regressions", async () => {
const css = await loadAllAppCss();
const mobileMediaStart = css.indexOf("@media (max-width: 768px)");
const taskReviewCss = css.slice(css.indexOf(".task-review-tab"));
const mobileMediaStart = taskReviewCss.indexOf("@media (max-width: 768px)");
expect(mobileMediaStart).toBeGreaterThanOrEqual(0);
const mobileCss = css.slice(mobileMediaStart);
const baseSummaryWrapRule = css.match(/\.task-review-tab__summary-wrap\s*\{[^}]*\}/)?.[0] ?? "";
const mobileCss = taskReviewCss.slice(mobileMediaStart);
const baseSummaryWrapRule = taskReviewCss.match(/\.task-review-tab__summary-wrap\s*\{[^}]*\}/)?.[0] ?? "";
expect(baseSummaryWrapRule).toMatch(/flex\s*:\s*1\s+1\s+20rem\s*;/);
expect(baseSummaryWrapRule).not.toMatch(/flex\s*:\s*0\s+0\s+auto\s*;/);
@@ -516,8 +517,8 @@ describe("TaskReviewTab", () => {
expect(mobileCss).toMatch(/\.task-review-tab__body\s*\{[^}]*padding\s*:\s*var\(--space-sm\)\s*;[^}]*\}/);
expect(mobileCss).not.toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*flex\s*:\s*1\s*;[^}]*\}/);
expect(css).toMatch(/\.task-review-tab__body\s*\{[^}]*overflow-x\s*:\s*auto\s*;[^}]*overflow-wrap\s*:\s*anywhere\s*;[^}]*\}/);
expect(css).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/);
expect(taskReviewCss).toMatch(/\.task-review-tab__body\s*\{[^}]*overflow-wrap\s*:\s*anywhere\s*;[^}]*overflow-x\s*:\s*auto\s*;[^}]*\}/);
expect(taskReviewCss).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/);
});
it("preserves review header structure across sources and empty or populated states", async () => {
@@ -608,14 +609,39 @@ describe("TaskReviewTab", () => {
}
if (testCase.itemText) {
expect(screen.getByText(testCase.itemText)).toBeInTheDocument();
expect(screen.getAllByText(testCase.itemText).length).toBeGreaterThan(0);
}
}
});
it("shows create PR action when in-review without prInfo and auth is available", async () => {
it.each([
{
name: "shows when task override turns auto-merge off while project default is on",
taskAutoMerge: false,
autoMergeEnabled: true,
shouldShow: true,
},
{
name: "hides when task override turns auto-merge on while project default is off",
taskAutoMerge: true,
autoMergeEnabled: false,
shouldShow: false,
},
{
name: "hides when task follows an enabled project default",
taskAutoMerge: undefined,
autoMergeEnabled: true,
shouldShow: false,
},
{
name: "shows when task follows a disabled project default",
taskAutoMerge: undefined,
autoMergeEnabled: false,
shouldShow: true,
},
])("create PR action $name", async ({ taskAutoMerge, autoMergeEnabled, shouldShow }) => {
const onRequestCreatePr = vi.fn();
const task = makeTask({ column: "in-review", prInfo: undefined });
const task = makeTask({ column: "in-review", prInfo: undefined, autoMerge: taskAutoMerge });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
render(
@@ -624,10 +650,19 @@ describe("TaskReviewTab", () => {
addToast={vi.fn()}
prAuthAvailable
onRequestCreatePr={onRequestCreatePr}
autoMergeEnabled={autoMergeEnabled}
/>,
);
fireEvent.click(await screen.findByTestId("task-review-create-pr"));
await screen.findByRole("button", { name: "Refresh" });
if (!shouldShow) {
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
expect(onRequestCreatePr).not.toHaveBeenCalled();
return;
}
fireEvent.click(screen.getByTestId("task-review-create-pr"));
expect(onRequestCreatePr).toHaveBeenCalledTimes(1);
});
@@ -671,8 +706,8 @@ describe("TaskReviewTab", () => {
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
});
it("hides create PR action when auto-merge is enabled", async () => {
const task = makeTask({ column: "in-review", prInfo: undefined });
it("hides create PR action when task follows an enabled project default", async () => {
const task = makeTask({ column: "in-review", prInfo: undefined, autoMerge: undefined });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} prAuthAvailable onRequestCreatePr={vi.fn()} autoMergeEnabled />);