Add changeset

This commit is contained in:
gsxdsm
2026-05-04 09:54:22 -07:00
parent 2a4fba505e
commit 89401cd2c9
10 changed files with 249 additions and 4 deletions

View File

@@ -1095,6 +1095,34 @@ describe("runDashboard — PR-first auto-merge queue", () => {
});
expect(aiMergeTask).not.toHaveBeenCalled();
});
it("manual onMerge still uses PR lifecycle when autoMerge is disabled", async () => {
const { aiMergeTask } = await import("@fusion/engine");
const { createServer } = await import("@fusion/dashboard");
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
mergeStrategy: "pull-request",
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
await runDashboard(0, { open: false, dev: true });
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
await serverOpts.onMerge("FN-093");
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Task",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
});
expect(aiMergeTask).not.toHaveBeenCalled();
});
});
describe("runDashboard — WorktreePool wiring", () => {

View File

@@ -189,6 +189,57 @@ describe("processPullRequestMergeTask", () => {
expect(github.createPr).not.toHaveBeenCalled();
});
it("finalizes task cleanup when PR is already merged on status refresh", async () => {
const task: MockTask = {
id: "FN-9004",
title: "test",
description: "desc",
column: "in-review",
worktree: "/tmp/worktree-fn-9004",
prInfo: {
number: 88,
url: "https://github.com/x/y/pull/88",
status: "open",
headBranch: "fusion/fn-9004",
baseBranch: "main",
},
};
const store = makeStore(task);
execMock.mockImplementation(() => "");
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: {
number: 88,
url: "https://github.com/x/y/pull/88",
status: "merged" as const,
headBranch: "fusion/fn-9004",
baseBranch: "main",
},
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("merged");
expect(github.mergePr).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-9004", { status: null, mergeRetries: 0 });
expect(store.moveTask).toHaveBeenCalledWith("FN-9004", "done");
});
describe("requirePrApproval", () => {
function makeReadyMergeStatus(reviewDecision: string | null) {
const prInfo = {

View File

@@ -31,6 +31,7 @@ import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor,
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import {
getMergeStrategy,
getTaskBranchName,
processPullRequestMergeTask,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
@@ -1148,6 +1149,21 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// (semaphore-gated via the engine's InProcessRuntime).
//
const onMergeImpl = async (taskId: string) => {
const settings = await store.getSettings();
if (getMergeStrategy(settings) === "pull-request") {
const githubClient = new GitHubClient();
const outcome = await processPullRequestMergeTask(store, cwd, taskId, githubClient, getTaskMergeBlocker);
const task = await store.getTask(taskId);
return {
task,
branch: getTaskBranchName(taskId),
merged: outcome === "merged",
worktreeRemoved: false,
branchDeleted: false,
error: outcome === "waiting" ? "pull request not ready" : undefined,
};
}
const streamedMergeLog = new StreamedLogBuffer(
(line) => logSink.log(line, "merge"),
STREAM_LOG_FLUSH_IDLE_MS,

View File

@@ -11,6 +11,7 @@ interface PrSectionProps {
prInfo?: PrInfo;
automationStatus?: string | null;
autoMerge?: boolean;
isManualPrFlow?: boolean;
prAuthAvailable: boolean;
onPrCreated: (prInfo: PrInfo) => void;
onPrUpdated: (prInfo: PrInfo) => void;
@@ -29,6 +30,7 @@ export function PrSection({
prInfo,
automationStatus,
autoMerge = false,
isManualPrFlow = false,
prAuthAvailable,
onPrCreated,
onPrUpdated,
@@ -168,6 +170,11 @@ export function PrSection({
<Plus size={14} className="pr-section-icon--sm" />
Create PR
</button>
{isManualPrFlow && (
<div className="pr-hint pr-hint--subtle">
Use the footer action to run PR-first completion for this task.
</div>
)}
{!prAuthAvailable && (
<div className="pr-hint pr-hint--subtle">
Run <code>gh auth login</code> to enable PR creation.

View File

@@ -1558,6 +1558,20 @@ export function TaskDetailContent({
"merging-fix": "Merging fixes…",
};
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
const mergeStrategy = settings?.mergeStrategy ?? "direct";
const autoMergeEnabled = settings?.autoMerge ?? false;
const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled;
let manualReviewActionLabel = "Merge & Close";
if (isManualPrFlow && !prAutomationLabel) {
if (!task.prInfo) {
manualReviewActionLabel = "Start PR Review";
} else if (task.prInfo.status === "open") {
manualReviewActionLabel = "Check PR Status";
} else if (task.prInfo.status === "merged") {
manualReviewActionLabel = "Finish & Close";
}
}
return (
<div
@@ -2374,11 +2388,11 @@ export function TaskDetailContent({
prInfo={task.prInfo}
automationStatus={task.status ?? null}
autoMerge={settings?.autoMerge ?? false}
isManualPrFlow={isManualPrFlow}
prAuthAvailable={prAuthAvailable ?? false}
onPrCreated={(prInfo) => {
// Update task locally to show new PR
(task as TaskDetail).prInfo = prInfo;
addToast(`PR #${prInfo.number} created`, "success");
}}
onPrUpdated={(prInfo) => {
(task as TaskDetail).prInfo = prInfo;
@@ -2592,7 +2606,7 @@ export function TaskDetailContent({
</button>
) : (
<button className="btn btn-primary btn-sm" onClick={handleMergeMenuItemClick}>
Merge &amp; Close
{manualReviewActionLabel}
</button>
)}
</div>

View File

@@ -204,11 +204,12 @@ describe("PrSection", () => {
expect(screen.queryByRole("button", { name: "Create PR" })).toBeNull();
});
it("preserves manual PR creation behavior when auto-merge is disabled", () => {
render(
it("shows manual PR-footer hint only when manual PR flow is active", () => {
const { rerender } = render(
<PrSection
taskId="FN-001"
autoMerge={false}
isManualPrFlow={true}
prAuthAvailable={false}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
@@ -217,8 +218,23 @@ describe("PrSection", () => {
);
expect(screen.getByRole("button", { name: "Create PR" })).toBeDefined();
expect(screen.getByText(/Use the footer action to run PR-first completion/i)).toBeDefined();
expect(screen.getByText(/gh auth login/i)).toBeDefined();
expect(screen.queryByText("Auto-merge will handle this task automatically.")).toBeNull();
rerender(
<PrSection
taskId="FN-001"
autoMerge={false}
isManualPrFlow={false}
prAuthAvailable={false}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.queryByText(/Use the footer action to run PR-first completion/i)).toBeNull();
});
});

View File

@@ -2871,6 +2871,105 @@ describe("TaskDetailModal", () => {
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
});
it("keeps Merge & Close when pull-request strategy has autoMerge enabled", async () => {
const { fetchSettings } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
mergeStrategy: "pull-request",
autoMerge: true,
});
render(
<TaskDetailModal
task={makeTask({ column: "in-review" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(await screen.findByRole("button", { name: "Merge & Close" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Start PR Review" })).toBeNull();
expect(screen.queryByRole("button", { name: "Check PR Status" })).toBeNull();
expect(screen.queryByRole("button", { name: "Finish & Close" })).toBeNull();
});
it("shows Start PR Review and calls onMergeTask for pull-request strategy when autoMerge is off and no PR exists", async () => {
const { fetchSettings } = await import("../../api");
const onMergeTask = vi.fn(async () => ({ merged: false } as MergeResult));
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
mergeStrategy: "pull-request",
autoMerge: false,
});
render(
<TaskDetailModal
task={makeTask({ column: "in-review" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={onMergeTask}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const button = await screen.findByRole("button", { name: "Start PR Review" });
fireEvent.click(button);
await waitFor(() => {
expect(onMergeTask).toHaveBeenCalledWith("FN-099");
});
});
it.each([
[{ status: "open" as const }, "Check PR Status"],
[{ status: "merged" as const }, "Finish & Close"],
])("shows %s footer label in manual PR flow", async (prInfoStatus, expectedLabel) => {
const { fetchSettings } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
mergeStrategy: "pull-request",
autoMerge: false,
});
render(
<TaskDetailModal
task={makeTask({
column: "in-review" as Column,
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: prInfoStatus.status,
title: "Task",
headBranch: "fusion/fn-099",
baseBranch: "main",
commentCount: 0,
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(await screen.findByRole("button", { name: expectedLabel })).toBeTruthy();
expect(screen.queryByText("Merge & Close")).toBeNull();
});
it("shows PR automation waiting label instead of Merge & Close when awaiting PR checks", () => {
render(
<TaskDetailModal