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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `npx runfusion.ai` failing with `ERR_MODULE_NOT_FOUND: Cannot find package 'dockerode'` by declaring `dockerode` as a runtime dependency of the published CLI package (#33).

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Allow the dashboard task-detail footer action to manually drive PR-first completion when `mergeStrategy` is `pull-request` and `autoMerge` is disabled.

View File

@@ -287,6 +287,10 @@ fn task pr-create FN-120 --title "Fix flaky auth flow" --base main
Manual/non-auto-merge behavior: Manual/non-auto-merge behavior:
- Task PR branches use `fusion/<task-id-lower>`. - Task PR branches use `fusion/<task-id-lower>`.
- In the dashboard task detail modal (`in-review`), the existing primary footer action can manually drive PR-first completion when `mergeStrategy: "pull-request"` and `autoMerge: false`:
- `Start PR Review` (no PR linked yet)
- `Check PR Status` (open PR linked)
- `Finish & Close` (PR already merged)
- Manual PR creation first checks for an existing PR on that branch and links it when found. - Manual PR creation first checks for an existing PR on that branch and links it when found.
- If no PR exists, Fusion pushes the task branch to `origin` before creating the PR. - If no PR exists, Fusion pushes the task branch to `origin` before creating the PR.
- When buffered actionable PR feedback exists on a PR that is already merged/closed and the task leaves `in-review`, Fusion creates a dependency-linked follow-up task in `triage` so feedback is not stranded. - When buffered actionable PR feedback exists on a PR that is already merged/closed and the task leaves `in-review`, Fusion creates a dependency-linked follow-up task in `triage` so feedback is not stranded.

View File

@@ -1095,6 +1095,34 @@ describe("runDashboard — PR-first auto-merge queue", () => {
}); });
expect(aiMergeTask).not.toHaveBeenCalled(); 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", () => { describe("runDashboard — WorktreePool wiring", () => {

View File

@@ -189,6 +189,57 @@ describe("processPullRequestMergeTask", () => {
expect(github.createPr).not.toHaveBeenCalled(); 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", () => { describe("requirePrApproval", () => {
function makeReadyMergeStatus(reviewDecision: string | null) { function makeReadyMergeStatus(reviewDecision: string | null) {
const prInfo = { 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 { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import { import {
getMergeStrategy, getMergeStrategy,
getTaskBranchName,
processPullRequestMergeTask, processPullRequestMergeTask,
} from "./task-lifecycle.js"; } from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.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). // (semaphore-gated via the engine's InProcessRuntime).
// //
const onMergeImpl = async (taskId: string) => { 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( const streamedMergeLog = new StreamedLogBuffer(
(line) => logSink.log(line, "merge"), (line) => logSink.log(line, "merge"),
STREAM_LOG_FLUSH_IDLE_MS, STREAM_LOG_FLUSH_IDLE_MS,

View File

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

View File

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

View File

@@ -204,11 +204,12 @@ describe("PrSection", () => {
expect(screen.queryByRole("button", { name: "Create PR" })).toBeNull(); expect(screen.queryByRole("button", { name: "Create PR" })).toBeNull();
}); });
it("preserves manual PR creation behavior when auto-merge is disabled", () => { it("shows manual PR-footer hint only when manual PR flow is active", () => {
render( const { rerender } = render(
<PrSection <PrSection
taskId="FN-001" taskId="FN-001"
autoMerge={false} autoMerge={false}
isManualPrFlow={true}
prAuthAvailable={false} prAuthAvailable={false}
onPrCreated={mockOnPrCreated} onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated} onPrUpdated={mockOnPrUpdated}
@@ -217,8 +218,23 @@ describe("PrSection", () => {
); );
expect(screen.getByRole("button", { name: "Create PR" })).toBeDefined(); 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.getByText(/gh auth login/i)).toBeDefined();
expect(screen.queryByText("Auto-merge will handle this task automatically.")).toBeNull(); 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(); 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", () => { it("shows PR automation waiting label instead of Merge & Close when awaiting PR checks", () => {
render( render(
<TaskDetailModal <TaskDetailModal