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:
gsxdsm
2026-04-10 12:47:46 -07:00
parent e54867d746
commit e685745aa3
7 changed files with 391 additions and 189 deletions

View 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";
}