feat(FN-5455): merge fusion/fn-5455
This commit is contained in:
@@ -23,7 +23,11 @@ vi.mock("node:child_process", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { processPullRequestMergeTask, getTaskBranchName } from "../task-lifecycle.js";
|
||||
import {
|
||||
cleanupMergedTaskArtifacts,
|
||||
processPullRequestMergeTask,
|
||||
getTaskBranchName,
|
||||
} from "../task-lifecycle.js";
|
||||
|
||||
interface MockTask {
|
||||
id: string;
|
||||
@@ -915,3 +919,41 @@ describe("processPullRequestMergeTask", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupMergedTaskArtifacts FN-5455", () => {
|
||||
beforeEach(() => {
|
||||
execMock.mockReset();
|
||||
execMock.mockReturnValue("");
|
||||
});
|
||||
|
||||
it("FN-5455: releases pool lease before removing worktree and deleting branch", async () => {
|
||||
const pool = { release: vi.fn() };
|
||||
await cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-A", worktree: "/repo/wt" } as never, { pool } as never);
|
||||
expect(pool.release).toHaveBeenCalledWith("/repo/wt", "FN-5455-A");
|
||||
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git worktree remove "/repo/wt" --force'), expect.any(Object));
|
||||
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-a"'), expect.any(Object));
|
||||
});
|
||||
|
||||
it("FN-5455: pool omitted keeps backward-compatible cleanup behavior", async () => {
|
||||
await cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-B", worktree: "/repo/wt-b" } as never);
|
||||
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git worktree remove "/repo/wt-b" --force'), expect.any(Object));
|
||||
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-b"'), expect.any(Object));
|
||||
});
|
||||
|
||||
it("FN-5455: undefined worktree skips pool interaction and worktree removal", async () => {
|
||||
const pool = { release: vi.fn() };
|
||||
await cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-C", worktree: undefined } as never, { pool } as never);
|
||||
expect(pool.release).not.toHaveBeenCalled();
|
||||
expect(execMock).not.toHaveBeenCalledWith(expect.stringContaining("git worktree remove"), expect.anything());
|
||||
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-c"'), expect.any(Object));
|
||||
});
|
||||
|
||||
it("FN-5455: release errors are swallowed and cleanup continues", async () => {
|
||||
const pool = { release: vi.fn(() => { throw new Error("boom"); }) };
|
||||
await expect(
|
||||
cleanupMergedTaskArtifacts("/repo", { id: "FN-5455-D", worktree: "/repo/wt-d" } as never, { pool } as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git worktree remove "/repo/wt-d" --force'), expect.any(Object));
|
||||
expect(execMock).toHaveBeenCalledWith(expect.stringContaining('git branch -d "fusion/fn-5455-d"'), expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,8 +332,8 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
|
||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
processPullRequestMerge: (s, wd, taskId, pool) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
|
||||
getTaskMergeBlocker,
|
||||
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
|
||||
});
|
||||
|
||||
@@ -1197,7 +1197,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const settings = await store.getSettings();
|
||||
if (getMergeStrategy(settings) === "pull-request") {
|
||||
const githubClient = new GitHubClient();
|
||||
const outcome = await processPullRequestMergeTask(store, cwd, taskId, githubClient, getTaskMergeBlocker);
|
||||
const outcome = await processPullRequestMergeTask(
|
||||
store,
|
||||
cwd,
|
||||
taskId,
|
||||
githubClient,
|
||||
getTaskMergeBlocker,
|
||||
worktreePool,
|
||||
);
|
||||
const task = await store.getTask(taskId);
|
||||
return {
|
||||
task,
|
||||
@@ -1510,8 +1517,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
const engineManager = new ProjectEngineManager(centralCoreForEngine, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
processPullRequestMerge: (s, wd, taskId, pool) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
|
||||
getTaskMergeBlocker,
|
||||
});
|
||||
|
||||
|
||||
@@ -358,8 +358,8 @@ export async function runServe(
|
||||
|
||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
processPullRequestMerge: (s, wd, taskId, pool) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
|
||||
getTaskMergeBlocker,
|
||||
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { TaskStore } from "@fusion/core";
|
||||
import { resolveTaskMergeTarget } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, MergeResult } from "@fusion/core";
|
||||
import { resolveIntegrationBranch } from "@fusion/engine";
|
||||
import type { WorktreePool } from "@fusion/engine";
|
||||
|
||||
/**
|
||||
* Minimal interface for GitHub operations needed by the PR merge workflow.
|
||||
@@ -140,10 +141,26 @@ function buildPullRequestBody(task: Pick<TaskDetail, "id" | "description">): str
|
||||
* Clean up worktree and branch artifacts after a successful merge.
|
||||
* Both operations are best-effort; errors are logged but don't propagate.
|
||||
*/
|
||||
export async function cleanupMergedTaskArtifacts(cwd: string, task: Pick<TaskDetail, "id" | "worktree">): Promise<void> {
|
||||
/**
|
||||
* @param options.pool Optional runtime worktree pool; FN-5455/FN-4954 require best-effort
|
||||
* release before force-removing merged PR worktrees.
|
||||
*/
|
||||
export async function cleanupMergedTaskArtifacts(
|
||||
cwd: string,
|
||||
task: Pick<TaskDetail, "id" | "worktree">,
|
||||
options?: { pool?: WorktreePool },
|
||||
): Promise<void> {
|
||||
const branch = getTaskBranchName(task.id);
|
||||
|
||||
if (task.worktree) {
|
||||
if (options?.pool) {
|
||||
try {
|
||||
options.pool.release(task.worktree, task.id);
|
||||
} catch {
|
||||
// Best-effort cleanup — release may fail if pool state is already divergent.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git worktree remove "${task.worktree}" --force`, {
|
||||
cwd,
|
||||
@@ -177,8 +194,9 @@ async function finalizePullRequestMerge(
|
||||
task: TaskDetail,
|
||||
prInfo: PrInfo,
|
||||
message = "Pull request merged",
|
||||
pool?: WorktreePool,
|
||||
): Promise<void> {
|
||||
await cleanupMergedTaskArtifacts(cwd, task);
|
||||
await cleanupMergedTaskArtifacts(cwd, task, { pool });
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
const movedTask = await store.moveTask(task.id, "done");
|
||||
const mergedTask = movedTask ?? (await store.getTask(task.id));
|
||||
@@ -240,6 +258,7 @@ export async function processPullRequestMergeTask(
|
||||
taskId: string,
|
||||
github: GitHubOperations,
|
||||
getTaskMergeBlocker: TaskMergeBlockerFn,
|
||||
pool?: WorktreePool,
|
||||
): Promise<ProcessPullRequestResult> {
|
||||
const task = await store.getTask(taskId);
|
||||
if (getTaskMergeBlocker(task)) {
|
||||
@@ -304,7 +323,7 @@ export async function processPullRequestMergeTask(
|
||||
await store.updatePrInfo(task.id, refreshedPrInfo);
|
||||
|
||||
if (mergeStatus.prInfo.status === "merged") {
|
||||
await finalizePullRequestMerge(store, cwd, task, prInfo);
|
||||
await finalizePullRequestMerge(store, cwd, task, prInfo, "Pull request merged", pool);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
@@ -359,6 +378,7 @@ export async function processPullRequestMergeTask(
|
||||
task,
|
||||
refreshedAfterFailure,
|
||||
"Pull request already merged after merge command failed; reconciled task state from GitHub",
|
||||
pool,
|
||||
);
|
||||
return "merged";
|
||||
}
|
||||
@@ -366,6 +386,6 @@ export async function processPullRequestMergeTask(
|
||||
throw err;
|
||||
}
|
||||
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
|
||||
await finalizePullRequestMerge(store, cwd, task, mergedPr);
|
||||
await finalizePullRequestMerge(store, cwd, task, mergedPr, "Pull request merged", pool);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user