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