Add changeset
This commit is contained in:
5
.changeset/fix-issue-33-dockerode-missing.md
Normal file
5
.changeset/fix-issue-33-dockerode-missing.md
Normal 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).
|
||||||
5
.changeset/fn-3286-manual-pr-flow.md
Normal file
5
.changeset/fn-3286-manual-pr-flow.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 & Close
|
{manualReviewActionLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user