refactor(FN-1563): decouple CLI commands from UI dependencies
- Extract task lifecycle helpers (checkForExistingSession, resolveProjectPath) to shared modules - Extract port selection logic to dedicated port-prompt module - Remove direct imports from @fusion/dashboard in serve.ts - Add architectural boundary comments for future maintainability - Update tests to reflect new module structure - Add memory note documenting the architectural decision
This commit is contained in:
@@ -531,3 +531,21 @@ Key learnings from adding integration test coverage for run-audit:
|
||||
- Tests asserting package versions must read dynamically from `package.json` using `JSON.parse(readFileSync(pkgPath, "utf-8"))`
|
||||
- Hardcoded version strings in tests (e.g., `expect(version).toBe("0.1.0")`) break after version bumps
|
||||
- Use `getAppVersion()` for runtime version checks in tests
|
||||
|
||||
## FN-1563: Decoupling CLI Command Dependencies
|
||||
|
||||
**Architectural boundary:**
|
||||
- `serve.ts` (headless node) must NOT import from `./dashboard.js`
|
||||
- Shared task lifecycle helpers live in `./task-lifecycle.js` (no UI/dashboard dependency)
|
||||
- Shared interactive utilities (port prompting) live in `./port-prompt.js`
|
||||
- Both `runDashboard()` and `runServe()` import from these neutral modules
|
||||
|
||||
**Module structure:**
|
||||
- `task-lifecycle.ts`: PR merge helpers (`getMergeStrategy`, `getTaskBranchName`, `cleanupMergedTaskArtifacts`, `processPullRequestMergeTask`)
|
||||
- `port-prompt.ts`: Interactive port selection (`promptForPort`)
|
||||
- `dashboard.ts`: UI-specific logic, re-exports neutral helpers for backward compatibility with tests
|
||||
|
||||
**Test imports:**
|
||||
- When moving functions to new modules, update test imports accordingly
|
||||
- The serve test mocks `./task-lifecycle.js` and `./port-prompt.js` (not dashboard.js)
|
||||
- The dashboard test imports helpers from `./task-lifecycle.js` and `runDashboard` from `./dashboard.js`
|
||||
|
||||
@@ -377,8 +377,11 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
createExtensionRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../dashboard.js", () => ({
|
||||
vi.mock("../port-prompt.js", () => ({
|
||||
promptForPort: vi.fn(async (port: number) => port),
|
||||
}));
|
||||
|
||||
vi.mock("../task-lifecycle.js", () => ({
|
||||
getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"),
|
||||
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
|
||||
}));
|
||||
|
||||
@@ -322,7 +322,8 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
|
||||
// ── Import module under test (after mocks) ──────────────────────────
|
||||
|
||||
const { runDashboard, processPullRequestMergeTask, getMergeStrategy, getTaskBranchName } = await import("./dashboard.js");
|
||||
const { runDashboard } = await import("./dashboard.js");
|
||||
const { processPullRequestMergeTask, getMergeStrategy, getTaskBranchName } = await import("./task-lifecycle.js");
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -404,12 +405,25 @@ describe("processPullRequestMergeTask", () => {
|
||||
log: [],
|
||||
});
|
||||
|
||||
const mockGetTaskMergeBlocker = (task: any) => {
|
||||
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
|
||||
if (task.paused) return "task is paused";
|
||||
if (task.status === "failed") return "task is marked 'failed'";
|
||||
if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) {
|
||||
return "task has incomplete steps";
|
||||
}
|
||||
if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) {
|
||||
return "task has incomplete or failed workflow steps";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const result = await processPullRequestMergeTask(store as any, "/repo", "FN-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
} as any, mockGetTaskMergeBlocker);
|
||||
|
||||
expect(result).toBe("waiting");
|
||||
expect(mockFindPrForBranch).toHaveBeenCalledWith({ head: "fusion/fn-093", state: "all" });
|
||||
@@ -446,12 +460,25 @@ describe("processPullRequestMergeTask", () => {
|
||||
log: [],
|
||||
});
|
||||
|
||||
const mockGetTaskMergeBlocker = (task: any) => {
|
||||
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
|
||||
if (task.paused) return "task is paused";
|
||||
if (task.status === "failed") return "task is marked 'failed'";
|
||||
if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) {
|
||||
return "task has incomplete steps";
|
||||
}
|
||||
if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) {
|
||||
return "task has incomplete or failed workflow steps";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
await processPullRequestMergeTask(store as any, "/repo", "FN-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
} as any, mockGetTaskMergeBlocker);
|
||||
|
||||
expect(mockCreatePr).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
@@ -497,12 +524,25 @@ describe("processPullRequestMergeTask", () => {
|
||||
blockingReasons: [],
|
||||
});
|
||||
|
||||
const mockGetTaskMergeBlocker = (task: any) => {
|
||||
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
|
||||
if (task.paused) return "task is paused";
|
||||
if (task.status === "failed") return "task is marked 'failed'";
|
||||
if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) {
|
||||
return "task has incomplete steps";
|
||||
}
|
||||
if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) {
|
||||
return "task has incomplete or failed workflow steps";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const result = await processPullRequestMergeTask(store as any, "/repo", "FN-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
} as any, mockGetTaskMergeBlocker);
|
||||
|
||||
expect(result).toBe("merged");
|
||||
expect(mockMergePr).toHaveBeenCalledWith({ number: 42, method: "squash" });
|
||||
@@ -546,12 +586,25 @@ describe("processPullRequestMergeTask", () => {
|
||||
blockingReasons: ["changes requested review is active", "required checks not successful: ci (pending)"],
|
||||
});
|
||||
|
||||
const mockGetTaskMergeBlocker = (task: any) => {
|
||||
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
|
||||
if (task.paused) return "task is paused";
|
||||
if (task.status === "failed") return "task is marked 'failed'";
|
||||
if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) {
|
||||
return "task has incomplete steps";
|
||||
}
|
||||
if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) {
|
||||
return "task has incomplete or failed workflow steps";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const result = await processPullRequestMergeTask(store as any, "/repo", "FN-093", {
|
||||
findPrForBranch: mockFindPrForBranch,
|
||||
createPr: mockCreatePr,
|
||||
getPrMergeStatus: mockGetPrMergeStatus,
|
||||
mergePr: mockMergePr,
|
||||
} as any);
|
||||
} as any, mockGetTaskMergeBlocker);
|
||||
|
||||
expect(result).toBe("waiting");
|
||||
expect(mockMergePr).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,117 +1,17 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createInterface } from "node:readline";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, syncInsightExtractionAutomation, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, ScheduledTask, AutomationRunResult } from "@fusion/core";
|
||||
import type { Settings, ScheduledTask, AutomationRunResult } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot, createAiPromptExecutor, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "@fusion/engine";
|
||||
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
getMergeStrategy,
|
||||
processPullRequestMergeTask,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
|
||||
/**
|
||||
* Prompt the user for a port number interactively.
|
||||
* Shows "Port [4040]: " and accepts user input or Enter for default.
|
||||
* Validates input is a valid port number (1-65535).
|
||||
* Re-prompts on invalid input.
|
||||
* Handles SIGINT (Ctrl+C) gracefully.
|
||||
*/
|
||||
export function promptForPort(defaultPort: number = 4040, input: NodeJS.ReadableStream = process.stdin): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = createInterface({
|
||||
input,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Handle Ctrl+C during prompt
|
||||
const sigintHandler = () => {
|
||||
rl.close();
|
||||
console.log("\n");
|
||||
reject(new Error("Interactive prompt cancelled"));
|
||||
};
|
||||
process.on("SIGINT", sigintHandler);
|
||||
|
||||
const ask = () => {
|
||||
rl.question(`Port [${defaultPort}]: `, (answer) => {
|
||||
const trimmed = answer.trim();
|
||||
|
||||
// Empty input: use default
|
||||
if (trimmed === "") {
|
||||
process.removeListener("SIGINT", sigintHandler);
|
||||
rl.close();
|
||||
resolve(defaultPort);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate as number
|
||||
const port = parseInt(trimmed, 10);
|
||||
if (isNaN(port)) {
|
||||
console.log(`Invalid input: "${trimmed}" is not a number`);
|
||||
ask();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
if (port < 1 || port > 65535) {
|
||||
console.log(`Invalid port: ${port} (must be between 1 and 65535)`);
|
||||
ask();
|
||||
return;
|
||||
}
|
||||
|
||||
process.removeListener("SIGINT", sigintHandler);
|
||||
rl.close();
|
||||
resolve(port);
|
||||
});
|
||||
};
|
||||
|
||||
ask();
|
||||
});
|
||||
}
|
||||
|
||||
export function getMergeStrategy(settings: Pick<Settings, "mergeStrategy">): NonNullable<Settings["mergeStrategy"]> {
|
||||
return settings.mergeStrategy ?? "direct";
|
||||
}
|
||||
|
||||
export function getTaskBranchName(taskId: string): string {
|
||||
return `fusion/${taskId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function buildPullRequestTitle(task: Pick<TaskDetail, "id" | "title">): string {
|
||||
return task.title ? `${task.id}: ${task.title}` : task.id;
|
||||
}
|
||||
|
||||
function buildPullRequestBody(task: Pick<TaskDetail, "id" | "description">): string {
|
||||
return [`Automated PR for ${task.id}.`, "", task.description].join("\n");
|
||||
}
|
||||
|
||||
function cleanupMergedTaskArtifacts(cwd: string, task: Pick<TaskDetail, "id" | "worktree">): void {
|
||||
const branch = getTaskBranchName(task.id);
|
||||
|
||||
if (task.worktree) {
|
||||
try {
|
||||
execSync(`git worktree remove \"${task.worktree}\" --force`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — worktree may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`git branch -d \"${branch}\"`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
execSync(`git branch -D \"${branch}\"`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — branch may already be gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-export for backward compatibility with tests
|
||||
export { promptForPort };
|
||||
|
||||
type LoginCallbacks = Parameters<AuthStorage["login"]>[1];
|
||||
|
||||
@@ -189,77 +89,6 @@ function wrapAuthStorageWithApiKeyProviders(
|
||||
};
|
||||
}
|
||||
|
||||
export async function processPullRequestMergeTask(
|
||||
store: TaskStore,
|
||||
cwd: string,
|
||||
taskId: string,
|
||||
github: Pick<GitHubClient, "findPrForBranch" | "createPr" | "getPrMergeStatus" | "mergePr">,
|
||||
): Promise<"waiting" | "merged" | "skipped"> {
|
||||
const task = await store.getTask(taskId);
|
||||
if (getTaskMergeBlocker(task)) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const branch = getTaskBranchName(task.id);
|
||||
let prInfo: PrInfo | undefined = task.prInfo;
|
||||
|
||||
if (!prInfo) {
|
||||
await store.updateTask(task.id, { status: "creating-pr" });
|
||||
|
||||
const existingPr = await github.findPrForBranch({ head: branch, state: "all" });
|
||||
prInfo = existingPr ?? await github.createPr({
|
||||
title: buildPullRequestTitle(task),
|
||||
body: buildPullRequestBody(task),
|
||||
head: branch,
|
||||
});
|
||||
|
||||
await store.updatePrInfo(task.id, prInfo);
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
existingPr ? "Linked existing PR" : "Created PR",
|
||||
`PR #${prInfo.number}: ${prInfo.url}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!prInfo) {
|
||||
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
|
||||
}
|
||||
|
||||
const mergeStatus = await github.getPrMergeStatus(undefined, undefined, prInfo.number);
|
||||
const refreshedPrInfo: PrInfo = {
|
||||
...prInfo,
|
||||
...mergeStatus.prInfo,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
};
|
||||
await store.updatePrInfo(task.id, refreshedPrInfo);
|
||||
|
||||
if (mergeStatus.prInfo.status === "merged") {
|
||||
cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${prInfo.number}: ${prInfo.url}`);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
if (!mergeStatus.mergeReady) {
|
||||
if (mergeStatus.prInfo.status === "open") {
|
||||
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
|
||||
} else {
|
||||
await store.updateTask(task.id, { status: null });
|
||||
}
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
await store.updateTask(task.id, { status: "merging-pr" });
|
||||
const mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" });
|
||||
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
|
||||
cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${mergedPr.number}: ${mergedPr.url}`);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean } = {}) {
|
||||
// Handle interactive port selection
|
||||
let selectedPort = port;
|
||||
@@ -602,7 +431,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const mergeStrategy = getMergeStrategy(settings);
|
||||
if (mergeStrategy === "pull-request") {
|
||||
console.log(`[auto-merge] Processing PR flow for ${taskId}...`);
|
||||
const result = await processPullRequestMergeTask(store, cwd, taskId, githubClient);
|
||||
const result = await processPullRequestMergeTask(store, cwd, taskId, githubClient, getTaskMergeBlocker);
|
||||
if (result === "merged") {
|
||||
console.log(`[auto-merge] ✓ ${taskId} merged via pull request`);
|
||||
} else if (result === "waiting") {
|
||||
|
||||
75
packages/cli/src/commands/port-prompt.ts
Normal file
75
packages/cli/src/commands/port-prompt.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Interactive port prompt utilities for CLI commands.
|
||||
*
|
||||
* This module provides neutral port selection utilities that can be used by
|
||||
* both `runDashboard()` and `runServe()`. It has NO dependency on UI or
|
||||
* dashboard-specific imports.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
/**
|
||||
* Prompt the user for a port number interactively.
|
||||
* Shows "Port [4040]: " and accepts user input or Enter for default.
|
||||
* Validates input is a valid port number (1-65535).
|
||||
* Re-prompts on invalid input.
|
||||
* Handles SIGINT (Ctrl+C) gracefully.
|
||||
*
|
||||
* @param defaultPort - The default port to use if user presses Enter
|
||||
* @param input - The readable stream to read from (defaults to process.stdin)
|
||||
* @returns The selected port number
|
||||
*/
|
||||
export function promptForPort(
|
||||
defaultPort: number = 4040,
|
||||
input: NodeJS.ReadableStream = process.stdin,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = createInterface({
|
||||
input,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Handle Ctrl+C during prompt
|
||||
const sigintHandler = () => {
|
||||
rl.close();
|
||||
console.log("\n");
|
||||
reject(new Error("Interactive prompt cancelled"));
|
||||
};
|
||||
process.on("SIGINT", sigintHandler);
|
||||
|
||||
const ask = () => {
|
||||
rl.question(`Port [${defaultPort}]: `, (answer) => {
|
||||
const trimmed = answer.trim();
|
||||
|
||||
// Empty input: use default
|
||||
if (trimmed === "") {
|
||||
process.removeListener("SIGINT", sigintHandler);
|
||||
rl.close();
|
||||
resolve(defaultPort);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate as number
|
||||
const port = parseInt(trimmed, 10);
|
||||
if (isNaN(port)) {
|
||||
console.log(`Invalid input: "${trimmed}" is not a number`);
|
||||
ask();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
if (port < 1 || port > 65535) {
|
||||
console.log(`Invalid port: ${port} (must be between 1 and 65535)`);
|
||||
ask();
|
||||
return;
|
||||
}
|
||||
|
||||
process.removeListener("SIGINT", sigintHandler);
|
||||
rl.close();
|
||||
resolve(port);
|
||||
});
|
||||
};
|
||||
|
||||
ask();
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Headless Fusion Node server command.
|
||||
*
|
||||
* ⚠️ ARCHITECTURAL BOUNDARY: This module must NOT import from ./dashboard.js.
|
||||
*
|
||||
* The headless command (runServe) runs independently of the dashboard UI.
|
||||
* Shared task lifecycle helpers are imported from ./task-lifecycle.js, and
|
||||
* interactive port prompts from ./port-prompt.js. This ensures clean separation
|
||||
* between the runtime (headless) and UI (dashboard) command paths.
|
||||
*/
|
||||
|
||||
import type { AddressInfo } from "node:net";
|
||||
import {
|
||||
TaskStore,
|
||||
@@ -46,10 +57,10 @@ import {
|
||||
createExtensionRuntime,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
promptForPort,
|
||||
getMergeStrategy,
|
||||
processPullRequestMergeTask,
|
||||
} from "./dashboard.js";
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
|
||||
export async function runServe(
|
||||
port: number,
|
||||
@@ -333,7 +344,7 @@ export async function runServe(
|
||||
const mergeStrategy = getMergeStrategy(settings);
|
||||
if (mergeStrategy === "pull-request") {
|
||||
console.log(`[auto-merge] Processing PR flow for ${taskId}...`);
|
||||
const result = await processPullRequestMergeTask(store, cwd, taskId, githubClient);
|
||||
const result = await processPullRequestMergeTask(store, cwd, taskId, githubClient, getTaskMergeBlocker);
|
||||
if (result === "merged") {
|
||||
console.log(`[auto-merge] ✓ ${taskId} merged via pull request`);
|
||||
} else if (result === "waiting") {
|
||||
|
||||
213
packages/cli/src/commands/task-lifecycle.ts
Normal file
213
packages/cli/src/commands/task-lifecycle.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Shared task lifecycle helpers for PR merge workflows.
|
||||
*
|
||||
* This module contains non-UI task lifecycle utilities that can be used by both
|
||||
* `runDashboard()` and `runServe()`. It has NO dependency on `@fusion/dashboard`
|
||||
* or any dashboard-specific imports.
|
||||
*
|
||||
* The lifecycle helpers handle:
|
||||
* - PR merge strategy resolution
|
||||
* - Branch naming conventions
|
||||
* - PR title/body construction
|
||||
* - Worktree/branch cleanup after merge
|
||||
* - Full PR lifecycle orchestration (create → status check → merge)
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
|
||||
|
||||
/**
|
||||
* Minimal interface for GitHub operations needed by the PR merge workflow.
|
||||
* Defined locally to avoid importing from @fusion/dashboard.
|
||||
*/
|
||||
interface GitHubOperations {
|
||||
findPrForBranch(params: { head: string; state: string }): Promise<PrInfo | null>;
|
||||
createPr(params: { title: string; body: string; head: string }): Promise<PrInfo>;
|
||||
getPrMergeStatus(base?: string, head?: string, number?: number): Promise<{
|
||||
prInfo: PrInfo;
|
||||
reviewDecision: string | null;
|
||||
checks: Array<{ name: string; required: boolean; state: string }>;
|
||||
mergeReady: boolean;
|
||||
blockingReasons: string[];
|
||||
}>;
|
||||
mergePr(params: { number: number; method: string }): Promise<PrInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the merge strategy from settings.
|
||||
* Returns the configured merge strategy or "direct" as default.
|
||||
*/
|
||||
export function getMergeStrategy(settings: Pick<Settings, "mergeStrategy">): NonNullable<Settings["mergeStrategy"]> {
|
||||
return settings.mergeStrategy ?? "direct";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the git branch name for a task.
|
||||
* Format: fusion/{task-id-lowercase}
|
||||
*/
|
||||
export function getTaskBranchName(taskId: string): string {
|
||||
return `fusion/${taskId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PR title for a task.
|
||||
* Format: "{taskId}: {title}" or just "{taskId}" if no title.
|
||||
*/
|
||||
function buildPullRequestTitle(task: Pick<TaskDetail, "id" | "title">): string {
|
||||
return task.title ? `${task.id}: ${task.title}` : task.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PR body/description for a task.
|
||||
* Format:
|
||||
* ```
|
||||
* Automated PR for {taskId}.
|
||||
*
|
||||
* {description}
|
||||
* ```
|
||||
*/
|
||||
function buildPullRequestBody(task: Pick<TaskDetail, "id" | "description">): string {
|
||||
return [`Automated PR for ${task.id}.`, "", task.description].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up worktree and branch artifacts after a successful merge.
|
||||
* Both operations are best-effort; errors are logged but don't propagate.
|
||||
*/
|
||||
export function cleanupMergedTaskArtifacts(cwd: string, task: Pick<TaskDetail, "id" | "worktree">): void {
|
||||
const branch = getTaskBranchName(task.id);
|
||||
|
||||
if (task.worktree) {
|
||||
try {
|
||||
execSync(`git worktree remove "${task.worktree}" --force`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — worktree may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`git branch -d "${branch}"`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
execSync(`git branch -D "${branch}"`, {
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — branch may already be gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing a PR merge task.
|
||||
* - "waiting": PR exists but not ready to merge (checks pending, reviews needed)
|
||||
* - "merged": Successfully merged and cleaned up
|
||||
* - "skipped": Task is blocked and cannot be merged
|
||||
*/
|
||||
export type ProcessPullRequestResult = "waiting" | "merged" | "skipped";
|
||||
|
||||
/**
|
||||
* Type for the task merge blocker function from @fusion/core.
|
||||
* Accepts a task object and returns a reason string if blocked, or undefined if not blocked.
|
||||
*/
|
||||
type TaskMergeBlockerFn = (task: TaskDetail) => string | undefined;
|
||||
|
||||
/**
|
||||
* Process a single task through the PR merge workflow.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if task can be merged (via getTaskMergeBlocker from @fusion/core)
|
||||
* 2. Create or link existing PR if none exists
|
||||
* 3. Check PR merge readiness (checks, reviews)
|
||||
* 4. Merge if ready, otherwise wait
|
||||
* 5. Clean up worktree/branch artifacts on success
|
||||
*
|
||||
* Status transitions during processing:
|
||||
* - "creating-pr" → when creating a new PR
|
||||
* - "awaiting-pr-checks" → when checks/reviews are blocking
|
||||
* - "merging-pr" → when initiating the merge
|
||||
*
|
||||
* On success:
|
||||
* - Moves task to "done"
|
||||
* - Clears status and mergeRetries
|
||||
* - Logs merge completion
|
||||
*/
|
||||
export async function processPullRequestMergeTask(
|
||||
store: TaskStore,
|
||||
cwd: string,
|
||||
taskId: string,
|
||||
github: GitHubOperations,
|
||||
getTaskMergeBlocker: TaskMergeBlockerFn,
|
||||
): Promise<ProcessPullRequestResult> {
|
||||
const task = await store.getTask(taskId);
|
||||
if (getTaskMergeBlocker(task)) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const branch = getTaskBranchName(task.id);
|
||||
let prInfo: PrInfo | undefined = task.prInfo;
|
||||
|
||||
if (!prInfo) {
|
||||
await store.updateTask(task.id, { status: "creating-pr" });
|
||||
|
||||
const existingPr = await github.findPrForBranch({ head: branch, state: "all" });
|
||||
prInfo = existingPr ?? await github.createPr({
|
||||
title: buildPullRequestTitle(task),
|
||||
body: buildPullRequestBody(task),
|
||||
head: branch,
|
||||
});
|
||||
|
||||
await store.updatePrInfo(task.id, prInfo);
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
existingPr ? "Linked existing PR" : "Created PR",
|
||||
`PR #${prInfo.number}: ${prInfo.url}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!prInfo) {
|
||||
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
|
||||
}
|
||||
|
||||
const mergeStatus = await github.getPrMergeStatus(undefined, undefined, prInfo.number);
|
||||
const refreshedPrInfo: PrInfo = {
|
||||
...prInfo,
|
||||
...mergeStatus.prInfo,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
};
|
||||
await store.updatePrInfo(task.id, refreshedPrInfo);
|
||||
|
||||
if (mergeStatus.prInfo.status === "merged") {
|
||||
cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${prInfo.number}: ${prInfo.url}`);
|
||||
return "merged";
|
||||
}
|
||||
|
||||
if (!mergeStatus.mergeReady) {
|
||||
if (mergeStatus.prInfo.status === "open") {
|
||||
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
|
||||
} else {
|
||||
await store.updateTask(task.id, { status: null });
|
||||
}
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
await store.updateTask(task.id, { status: "merging-pr" });
|
||||
const mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" });
|
||||
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
|
||||
cleanupMergedTaskArtifacts(cwd, task);
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
|
||||
await store.logEntry(task.id, "Pull request merged", `PR #${mergedPr.number}: ${mergedPr.url}`);
|
||||
return "merged";
|
||||
}
|
||||
Reference in New Issue
Block a user