diff --git a/.changeset/fn-pr-commands.md b/.changeset/fn-pr-commands.md new file mode 100644 index 0000000000..f656968891 --- /dev/null +++ b/.changeset/fn-pr-commands.md @@ -0,0 +1,18 @@ +--- +"@runfusion/fusion": minor +--- + +Add the unified `fn pr` command namespace for CLI parity with the dashboard's +PR-entity review surface (U8, R13): `fn pr create | list | show | approve | +respond | retry | merge | close | automerge`. + +Each subcommand routes to the SAME store/engine/release path the dashboard PR +routes use, so the two surfaces can't diverge: `create` mints the GitHub PR; +`list`/`show` read PR entities; `approve`/`respond`/`retry`/`merge`/`close` fire +the workflow's user-controlled release edges via `releaseHeldTaskByEvent` +(`pr-approve`/`pr-respond`/`pr-retry`/`pr-merge`/`pr-close`); `automerge` toggles +the entity's `autoMerge` flag. + +BREAKING: the per-task `fn task pr-create` command is retired. Use `fn pr create +` instead (same flags: `--title`, `--base`, `--body`, `--draft`, +`--no-ai`, `--reviewer`). diff --git a/packages/cli/src/__tests__/bin-pr-router.test.ts b/packages/cli/src/__tests__/bin-pr-router.test.ts index ec98d766dc..ca6762b62c 100644 --- a/packages/cli/src/__tests__/bin-pr-router.test.ts +++ b/packages/cli/src/__tests__/bin-pr-router.test.ts @@ -5,16 +5,21 @@ import { resolve } from "node:path"; describe("bin pr router wiring", () => { const source = readFileSync(resolve(__dirname, "../bin.ts"), "utf8"); - it("includes top-level pr create router", () => { + it("dispatches the full pr noun to commands/pr.js", () => { expect(source).toContain('case "pr":'); expect(source).toContain('case "create":'); - expect(source).toContain("runTaskPrCreate(id, parsePrCreateOptions(args.slice(3)), projectName)"); + expect(source).toContain("runPrCreate(id, parsePrCreateOptions(args.slice(3)), projectName)"); + expect(source).toContain('await import("./commands/pr.js")'); }); - it("parses draft/no-ai/reviewer flags for pr-create aliases", () => { + it("parses draft/no-ai/reviewer flags for pr create", () => { expect(source).toContain('const draft = args.includes("--draft")'); expect(source).toContain('const ai = !args.includes("--no-ai")'); expect(source).toContain('args[i] === "--reviewer"'); - expect(source).toContain('case "pr-create":'); + }); + + it("retires the per-task pr-create command", () => { + expect(source).not.toContain('case "pr-create":'); + expect(source).not.toContain("runTaskPrCreate"); }); }); diff --git a/packages/cli/src/__tests__/bin.test.ts b/packages/cli/src/__tests__/bin.test.ts index 46a3c6b09b..1acc9d5ee8 100644 --- a/packages/cli/src/__tests__/bin.test.ts +++ b/packages/cli/src/__tests__/bin.test.ts @@ -37,7 +37,16 @@ const commandMocks = vi.hoisted(() => ({ runTaskSteer: vi.fn(), runTaskSetNode: vi.fn(), runTaskClearNode: vi.fn(), - runTaskPrCreate: vi.fn(), + + runPrCreate: vi.fn(), + runPrShow: vi.fn(), + runPrList: vi.fn(), + runPrRespond: vi.fn(), + runPrApprove: vi.fn(), + runPrRetry: vi.fn(), + runPrMerge: vi.fn(), + runPrClose: vi.fn(), + runPrAutomerge: vi.fn(), runSettingsShow: vi.fn(), runSettingsSet: vi.fn(), @@ -173,7 +182,18 @@ vi.mock("../commands/task.js", () => ({ runTaskSteer: commandMocks.runTaskSteer, runTaskSetNode: commandMocks.runTaskSetNode, runTaskClearNode: commandMocks.runTaskClearNode, - runTaskPrCreate: commandMocks.runTaskPrCreate, +})); + +vi.mock("../commands/pr.js", () => ({ + runPrCreate: commandMocks.runPrCreate, + runPrShow: commandMocks.runPrShow, + runPrList: commandMocks.runPrList, + runPrRespond: commandMocks.runPrRespond, + runPrApprove: commandMocks.runPrApprove, + runPrRetry: commandMocks.runPrRetry, + runPrMerge: commandMocks.runPrMerge, + runPrClose: commandMocks.runPrClose, + runPrAutomerge: commandMocks.runPrAutomerge, })); vi.mock("../commands/settings.js", () => ({ @@ -878,23 +898,29 @@ describe("bin command routing and fallbacks", () => { expected: { draft: true, ai: false, reviewers: ["alice", "bob"] }, }, { - args: ["task", "pr-create", "FN-001", "--draft"], + args: ["pr", "create", "FN-001", "--draft"], expected: { draft: true, ai: true }, }, ])("routes PR creation variants %#", async ({ args, expected }) => { await runBin(args); - expect(commandMocks.runTaskPrCreate).toHaveBeenCalledWith( + expect(commandMocks.runPrCreate).toHaveBeenCalledWith( "FN-001", expect.objectContaining(expected), undefined, ); }); + it("no longer dispatches the retired `fn task pr-create`", async () => { + await expect(runBin(["task", "pr-create", "FN-001"])).rejects.toThrow("process.exit:1"); + expect(commandMocks.runPrCreate).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown subcommand: task pr-create")); + }); + it("errors on missing pr subcommand", async () => { await expect(runBin(["pr"])) .rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: pr "); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage: fn pr create ")); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Try: fn pr create ")); }); it("routes task delete with allow-resurrection flag", async () => { diff --git a/packages/cli/src/__tests__/pr-command.test.ts b/packages/cli/src/__tests__/pr-command.test.ts new file mode 100644 index 0000000000..f8f7b84782 --- /dev/null +++ b/packages/cli/src/__tests__/pr-command.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// The pr command resolves its store via project-context.resolveProject (same +// pattern branch-group.ts uses) and fires user-controlled releases via the +// engine's releaseHeldTaskByEvent primitive — the EXACT path the dashboard U7 +// routes use (register-integrated-routers.ts). Both are mocked so each +// subcommand can be asserted to route to the right store/engine path. +vi.mock("../project-context.js", () => ({ + resolveProject: vi.fn(), +})); + +const releaseHeldTaskByEvent = vi.fn(); +vi.mock("@fusion/engine", () => ({ + releaseHeldTaskByEvent: (...args: unknown[]) => releaseHeldTaskByEvent(...args), +})); + +// @fusion/dashboard is only touched by runPrCreate (not exercised here); stub it +// so importing the module never pulls the heavy dashboard graph. +vi.mock("@fusion/dashboard", () => ({ + GitHubClient: class {}, + generatePrMetadata: vi.fn(), +})); + +const { resolveProject } = await import("../project-context.js"); +const { + runPrList, + runPrShow, + runPrApprove, + runPrRespond, + runPrRetry, + runPrMerge, + runPrClose, + runPrAutomerge, +} = await import("../commands/pr.js"); + +function makeEntity(overrides: Record = {}) { + return { + id: "PR-001", + sourceType: "task", + sourceId: "FN-001", + repo: "owner/repo", + headBranch: "fusion/fn-001", + baseBranch: "main", + state: "open", + prNumber: 42, + prUrl: "https://github.com/owner/repo/pull/42", + autoMerge: false, + unverified: false, + responseRounds: 0, + mergeable: "clean", + reviewDecision: "APPROVED", + checksRollup: "success", + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +describe("fn pr commands", () => { + const originalExit = process.exit; + let storeMock: Record>; + + function mockStore(store: Record>) { + storeMock = store; + vi.mocked(resolveProject).mockResolvedValue({ + store: store as never, + projectPath: "/tmp/project", + projectName: "proj", + } as never); + } + + beforeEach(() => { + vi.clearAllMocks(); + releaseHeldTaskByEvent.mockResolvedValue({ released: true, toColumn: "merged" }); + process.exit = vi.fn(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as typeof process.exit); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + process.exit = originalExit; + vi.restoreAllMocks(); + }); + + // ── read commands ────────────────────────────────────────────────────────── + + it("runPrList reads active entities from the store", async () => { + const listActivePrEntities = vi.fn().mockReturnValue([makeEntity()]); + mockStore({ listActivePrEntities }); + await runPrList(); + expect(listActivePrEntities).toHaveBeenCalledOnce(); + }); + + it("runPrShow reads the entity + thread states by id", async () => { + const getPrEntity = vi.fn().mockReturnValue(makeEntity()); + const listPrThreadStates = vi.fn().mockReturnValue([]); + mockStore({ getPrEntity, listPrThreadStates }); + await runPrShow("PR-001"); + expect(getPrEntity).toHaveBeenCalledWith("PR-001"); + expect(listPrThreadStates).toHaveBeenCalledWith("PR-001"); + }); + + it("runPrShow exits when the entity is missing", async () => { + mockStore({ getPrEntity: vi.fn().mockReturnValue(null), listPrThreadStates: vi.fn() }); + await expect(runPrShow("PR-404")).rejects.toThrow("process.exit:1"); + }); + + // ── user-controlled release actions → releaseHeldTaskByEvent ──────────────── + + it.each([ + { fn: runPrApprove, eventTag: "pr-approve" }, + { fn: runPrRespond, eventTag: "pr-respond" }, + { fn: runPrRetry, eventTag: "pr-retry" }, + { fn: runPrMerge, eventTag: "pr-merge" }, + { fn: runPrClose, eventTag: "pr-close" }, + ])("routes $eventTag to releaseHeldTaskByEvent on the source task", async ({ fn, eventTag }) => { + const getPrEntity = vi.fn().mockReturnValue(makeEntity()); + mockStore({ getPrEntity }); + await fn("PR-001"); + expect(getPrEntity).toHaveBeenCalledWith("PR-001"); + expect(releaseHeldTaskByEvent).toHaveBeenCalledWith(storeMock, "FN-001", eventTag); + }); + + it("merge is rejected on a conflicting entity (no release fired)", async () => { + mockStore({ getPrEntity: vi.fn().mockReturnValue(makeEntity({ mergeable: "conflicting" })) }); + await expect(runPrMerge("PR-001")).rejects.toThrow("process.exit:1"); + expect(releaseHeldTaskByEvent).not.toHaveBeenCalled(); + }); + + it("release actions are rejected on a terminal entity", async () => { + mockStore({ getPrEntity: vi.fn().mockReturnValue(makeEntity({ state: "merged" })) }); + await expect(runPrApprove("PR-001")).rejects.toThrow("process.exit:1"); + expect(releaseHeldTaskByEvent).not.toHaveBeenCalled(); + }); + + it("release action exits non-zero when the release does not fire", async () => { + mockStore({ getPrEntity: vi.fn().mockReturnValue(makeEntity()) }); + releaseHeldTaskByEvent.mockResolvedValue({ released: false, rejection: "not-external-event-hold" }); + await expect(runPrApprove("PR-001")).rejects.toThrow("process.exit:1"); + }); + + // ── automerge → store.updatePrEntity ──────────────────────────────────────── + + it("runPrAutomerge toggles entity.autoMerge via updatePrEntity", async () => { + const getPrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: false })); + const updatePrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: true })); + mockStore({ getPrEntity, updatePrEntity }); + await runPrAutomerge("PR-001", undefined); + expect(updatePrEntity).toHaveBeenCalledWith("PR-001", { autoMerge: true }); + }); + + it("runPrAutomerge honors an explicit off toggle", async () => { + const getPrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: true })); + const updatePrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: false })); + mockStore({ getPrEntity, updatePrEntity }); + await runPrAutomerge("PR-001", false); + expect(updatePrEntity).toHaveBeenCalledWith("PR-001", { autoMerge: false }); + }); +}); + +// Surface-parity consistency test: every PR action the dashboard exposes (U7's +// register-pull-requests-routes.ts / register-integrated-routers.ts) must have a +// `fn pr` subcommand — a capability can't exist on one surface only. +describe("PR surface parity (dashboard ⊆ CLI)", () => { + const cliSource = readFileSync(resolve(__dirname, "../bin.ts"), "utf8"); + + // The dashboard's PR action set, derived from the U7 routes: + // GET / (list), GET /:id (show), POST :id/approve|merge|retry|close, + // POST :id/automerge, plus the create capability (pr-create node). + // pr-respond is the CLI-exposed rework round (same release authority). + const dashboardActions = [ + "create", + "list", + "show", + "approve", + "retry", + "merge", + "close", + "automerge", + ]; + + it.each(dashboardActions)("`fn pr %s` is wired in bin.ts", (action) => { + expect(cliSource).toContain(`case "${action}":`); + }); + + it("respond (review-response loop) is also exposed", () => { + expect(cliSource).toContain('case "respond":'); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 7171ffd1d4..a3b285e933 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -119,7 +119,8 @@ async function loadCommandHandlers() { const { runServe } = await import("./commands/serve.js"); const { runDaemon } = await import("./commands/daemon.js"); const { runDesktop } = await import("./commands/desktop.js"); - const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js"); + const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode } = await import("./commands/task.js"); + const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge } = await import("./commands/pr.js"); const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js"); const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); @@ -176,7 +177,15 @@ async function loadCommandHandlers() { runTaskSteer, runTaskSetNode, runTaskClearNode, - runTaskPrCreate, + runPrCreate, + runPrShow, + runPrList, + runPrRespond, + runPrApprove, + runPrRetry, + runPrMerge, + runPrClose, + runPrAutomerge, runSettingsShow, runSettingsSet, runSettingsExport, @@ -309,13 +318,19 @@ Usage: fn task set-node Set a per-task node override fn task clear-node Clear a per-task node override fn task retry Retry a failed task (clears error, moves to todo) - fn task pr-create [--title ] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>] - Alias of: fn pr create fn task import <owner/repo> [opts] Import GitHub issues as tasks PR: fn pr create <task-id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>] Create a GitHub PR for a task (default: AI-generated title/body) + fn pr list | ls List active PR entities with state + auto-merge + fn pr show <pr-id> Show a PR entity (state, checks, review, threads) + fn pr approve <pr-id> Release the PR's review gate (approve) + fn pr respond <pr-id> Request another review-response round + fn pr retry <pr-id> Retry the PR (rework release) + fn pr merge <pr-id> Force-merge the PR via its merge release + fn pr close <pr-id> Close the PR terminally + fn pr automerge <pr-id> [on|off] Toggle auto-merge for the PR fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json] Create and optionally wait for a cited-research run (search/fetch/synthesis) fn research list | ls [--status <status>] [--limit <n>] [--json] @@ -643,7 +658,15 @@ async function main() { runTaskSteer, runTaskSetNode, runTaskClearNode, - runTaskPrCreate, + runPrCreate, + runPrShow, + runPrList, + runPrRespond, + runPrApprove, + runPrRetry, + runPrMerge, + runPrClose, + runPrAutomerge, runSettingsShow, runSettingsSet, runSettingsExport, @@ -842,12 +865,45 @@ async function main() { console.error("Usage: fn pr create <task-id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]"); process.exit(1); } - await runTaskPrCreate(id, parsePrCreateOptions(args.slice(3)), projectName); + await runPrCreate(id, parsePrCreateOptions(args.slice(3)), projectName); + break; + } + case "list": + case "ls": + await runPrList(projectName); + break; + case "show": + await runPrShow(args[2], projectName); + break; + case "approve": + await runPrApprove(args[2], projectName); + break; + case "respond": + await runPrRespond(args[2], projectName); + break; + case "retry": + await runPrRetry(args[2], projectName); + break; + case "merge": + await runPrMerge(args[2], projectName); + break; + case "close": + await runPrClose(args[2], projectName); + break; + case "automerge": { + const toggle = args[3]; + const enabled = + toggle === "on" || toggle === "true" + ? true + : toggle === "off" || toggle === "false" + ? false + : undefined; + await runPrAutomerge(args[2], enabled, projectName); break; } default: console.error(`Unknown subcommand: pr ${subcommand || ""}`); - console.error("Usage: fn pr create <task-id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]"); + console.error("Try: fn pr create <task-id> | list | show <id> | approve <id> | respond <id> | retry <id> | merge <id> | close <id> | automerge <id> [on|off]"); process.exit(1); } break; @@ -1340,16 +1396,6 @@ async function main() { await runTaskRetry(id, projectName); break; } - case "pr-create": { - const id = args[2]; - if (!id) { - console.error("Usage: fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>] [--draft] [--no-ai] [--reviewer <login>]"); - process.exit(1); - } - - await runTaskPrCreate(id, parsePrCreateOptions(args.slice(3)), projectName); - break; - } case "import": { const ownerRepo = args[2]; if (!ownerRepo) { diff --git a/packages/cli/src/commands/pr.ts b/packages/cli/src/commands/pr.ts new file mode 100644 index 0000000000..2b27bf0012 --- /dev/null +++ b/packages/cli/src/commands/pr.ts @@ -0,0 +1,367 @@ +import { + TaskStore, + isPrEntityActive, + isPrEntityActionable, + isPrEntityAutoMergeReady, + type PrEntity, + type PrThreadState, +} from "@fusion/core"; +import { classifyGhError, getGhErrorMessage, getCurrentRepo, isGhAuthenticated, isGhAvailable } from "@fusion/core/gh-cli"; +import { releaseHeldTaskByEvent } from "@fusion/engine"; +import * as dashboard from "@fusion/dashboard"; +import { resolveProject } from "../project-context.js"; + +/** + * Agent-native parity (R13, U8): expose the SAME unified-PR-entity surface and + * user-controlled actions a dashboard user gets (the U7 + * `GET/POST /api/pull-requests/*` routes) from the CLI, via `fn pr <subcommand>`. + * + * Capability → path mapping (kept identical to the dashboard so the two surfaces + * can never diverge — register-integrated-routers.ts wires the route callbacks to + * exactly these primitives): + * + * create → store.ensurePrEntityForSource (same store path the pr-create + * workflow node uses) + the actual GitHub PR via GitHubClient + * show/list → store.getPrEntity / store.listActivePrEntities + * approve → releaseHeldTaskByEvent(store, entity.sourceId, "pr-approve") + * respond → releaseHeldTaskByEvent(store, entity.sourceId, "pr-respond") + * retry → releaseHeldTaskByEvent(store, entity.sourceId, "pr-retry") + * merge → releaseHeldTaskByEvent(store, entity.sourceId, "pr-merge") + * close → releaseHeldTaskByEvent(store, entity.sourceId, "pr-close") + * automerge → store.updatePrEntity(id, { autoMerge }) + * + * The release actions fire the workflow's user-controlled release edges — the + * same hold-release authority the dashboard routes and the scheduler sweep use — + * so the GitHub side effects are owned by the workflow, not duplicated here. + * + * This mirrors the established CLI convention (branch-group.ts) of operating + * against the resolved `TaskStore` and engine helpers directly rather than + * calling the dashboard HTTP API. + */ + +interface PrCommandContext { + store: TaskStore; + projectPath: string; +} + +async function getPrContext(projectName?: string): Promise<PrCommandContext> { + try { + const context = await resolveProject(projectName); + if (context) { + return { store: context.store, projectPath: context.projectPath }; + } + } catch { + // fall through to a local store rooted at cwd + } + if (projectName) { + throw new Error(`Project ${projectName} not found`); + } + const store = new TaskStore(process.cwd()); + await store.init(); + return { store, projectPath: process.cwd() }; +} + +function formatGhErrorForCli(err: unknown): string { + const structured = classifyGhError(err); + const lines = [`GitHub error: ${structured.message}`]; + if (structured.hint) lines.push(` Hint: ${structured.hint}`); + if (structured.action?.kind === "shell") lines.push(` Action: run \`${structured.action.command}\``); + if (structured.action?.kind === "open") lines.push(` Action: open ${structured.action.url}`); + if (structured.action?.kind === "retry") lines.push(" Action: retry the command"); + if (structured.retryable) lines.push(" (retryable — re-run `fn pr create <task-id>` to try again)"); + return lines.join("\n") + "\n"; +} + +// ── PR creation (the retired `fn task pr-create`, now `fn pr create`) ───────── + +export interface PrCreateOptions { + title?: string; + base?: string; + body?: string; + draft?: boolean; + /** When true (default), call generatePrMetadata for title/body unless user provided both. */ + ai?: boolean; + /** Repeatable --reviewer flag values. */ + reviewers?: string[]; +} + +export async function runPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) { + const { store, projectPath } = await getPrContext(projectName); + + // Fetch task and validate it exists + let task; + try { + task = await store.getTask(id); + } catch (err) { + if (typeof err === "object" && err !== null && (err as Record<string, unknown>).code === "ENOENT") { + console.error(`Error: Task ${id} not found`); + process.exit(1); + } + throw err; + } + if (!task) { + console.error(`Error: Task ${id} not found`); + process.exit(1); + } + + // Validate task is in 'in-review' column + if (task.column !== "in-review") { + console.error(`Error: Task must be in 'in-review' column to create a PR (current: ${task.column})`); + process.exit(1); + } + + // Check if task already has PR info + if (task.prInfo) { + console.error(`Error: Task already has PR #${task.prInfo.number}: ${task.prInfo.url}`); + process.exit(1); + } + + // Determine owner/repo from GITHUB_REPOSITORY env or git remote + let owner: string; + let repo: string; + + const envRepo = process.env.GITHUB_REPOSITORY; + if (envRepo) { + const [o, r] = envRepo.split("/"); + if (!o || !r) { + console.error("Error: GITHUB_REPOSITORY format is invalid (expected: owner/repo)"); + process.exit(1); + } + owner = o; + repo = r; + } else { + const gitRepo = getCurrentRepo(projectPath); + if (!gitRepo) { + console.error("Error: Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote."); + process.exit(1); + } + owner = gitRepo.owner; + repo = gitRepo.repo; + } + + // Validate GitHub auth + if (!isGhAvailable() || !isGhAuthenticated()) { + console.error("Error: GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'."); + process.exit(1); + } + + // Build branch name using the established project convention + const branchName = `fusion/${id.toLowerCase()}`; + + // Build deterministic fallback PR title + const fallbackTitle = options.title + ? options.title + : task.title + ? task.title + : (() => { + const desc = task.description.trim(); + let derived = desc.charAt(0).toUpperCase() + desc.slice(1, 50); + if (desc.length > 50) { + derived += "…"; + } + return derived; + })(); + + let resolvedTitle = fallbackTitle; + let resolvedBody = options.body; + + const shouldUseAi = options.ai !== false && !(options.title && options.body); + if (shouldUseAi) { + try { + const settings = ("getSettings" in store + ? await store.getSettings() + : {}) as Parameters<typeof dashboard.generatePrMetadata>[0]["settings"]; + const generated = await dashboard.generatePrMetadata({ task, repoRoot: projectPath, settings }); + if (!options.title) { + resolvedTitle = generated.title; + } + if (!options.body) { + resolvedBody = generated.body; + } + console.log(" → Using AI-generated title/body (use --no-ai to skip)"); + } catch (err) { + process.stderr.write(`AI metadata generation failed; using fallback PR metadata. ${getGhErrorMessage(err)}\n`); + } + } + + // Create PR via GitHubClient + const client = new dashboard.GitHubClient(); + + try { + const prInfo = await client.createPr({ + owner, + repo, + title: resolvedTitle, + body: resolvedBody, + head: branchName, + base: options.base, + draft: options.draft, + reviewers: options.reviewers, + }); + + // Store PR info + await store.updatePrInfo(task.id, prInfo); + await store.logEntry(task.id, "Created PR", `PR #${prInfo.number}: ${prInfo.url}`); + + console.log(); + console.log(` ✓ Created PR for ${task.id}`); + console.log(` PR #${prInfo.number}: ${prInfo.url}`); + console.log(` Branch: ${branchName} → ${prInfo.baseBranch}`); + console.log(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("already exists")) { + console.error(`Error: A pull request already exists for ${owner}/${repo}:${branchName}`); + process.exit(1); + } else if (msg.includes("No commits between")) { + console.error(`Error: No commits between ${options.base || "default base"} and ${branchName}. Push changes before creating PR.`); + process.exit(1); + } else { + process.stderr.write(formatGhErrorForCli(err)); + process.exit(1); + } + } +} + +// ── Entity read commands (parity with GET /api/pull-requests[/:id]) ─────────── + +/** Resolve a PR entity by its id (or 404-style exit). */ +function requireEntity(store: TaskStore, id: string): PrEntity { + const entity = store.getPrEntity(id); + if (!entity) { + console.error(`\n ✗ PR entity ${id} not found\n`); + process.exit(1); + } + return entity; +} + +function autoMergeReason(entity: PrEntity): string { + if (!entity.autoMerge) return "Auto-merge off"; + if (entity.mergeable === "conflicting") return "Blocked: conflict"; + if (entity.reviewDecision !== "APPROVED") return "Waiting for approval"; + if (entity.checksRollup !== "success") return "Waiting for checks"; + if (entity.mergeable !== "clean") return "Waiting for checks"; + if (isPrEntityAutoMergeReady(entity)) return "Ready to merge"; + return "Waiting for checks"; +} + +export async function runPrList(projectName?: string) { + const { store } = await getPrContext(projectName); + const entities = store.listActivePrEntities(); + + if (entities.length === 0) { + console.log("\n No active pull requests.\n"); + return; + } + + console.log(); + for (const entity of entities) { + const num = entity.prNumber != null ? `#${entity.prNumber}` : "(no #)"; + const am = entity.autoMerge ? " auto-merge" : ""; + console.log(` ${entity.id} ${num} ${entity.repo} [${entity.state}]${am}`); + } + console.log(); +} + +export async function runPrShow(id: string, projectName?: string) { + if (!id) { + console.error("Usage: fn pr show <pr-entity-id>"); + process.exit(1); + } + const { store } = await getPrContext(projectName); + const entity = requireEntity(store, id); + const threads: PrThreadState[] = store.listPrThreadStates(entity.id); + const pending = threads.filter((t) => t.outcome === "pending").length; + const disagreed = threads.filter((t) => t.outcome === "disagreed").length; + + console.log(); + console.log(` PR entity ${entity.id}`); + console.log(` Source: ${entity.sourceType}/${entity.sourceId}`); + console.log(` Repo: ${entity.repo}`); + console.log(` Branch: ${entity.headBranch}${entity.baseBranch ? ` → ${entity.baseBranch}` : ""}`); + console.log(` State: ${entity.state}${entity.prNumber != null ? ` (#${entity.prNumber})` : ""}`); + if (entity.prUrl) console.log(` URL: ${entity.prUrl}`); + console.log(` Mergeable: ${entity.mergeable ?? "unknown"}`); + console.log(` Review: ${entity.reviewDecision ?? "none"}`); + console.log(` Checks: ${entity.checksRollup ?? "none"}`); + console.log(` Auto-merge: ${entity.autoMerge ? "on" : "off"} (${autoMergeReason(entity)})`); + console.log(` Active: ${isPrEntityActive(entity) ? "yes" : "no"}; actionable: ${isPrEntityActionable(entity) ? "yes" : "no"}`); + console.log(` Rounds: ${entity.responseRounds}; threads: ${threads.length} (${pending} pending, ${disagreed} disagreed)`); + console.log(); +} + +// ── User-controlled actions (parity with POST /api/pull-requests/:id/*) ─────── + +/** + * Shared release action: re-read the AUTHORITATIVE entity (never trust a stale + * copy), gate it the same way the dashboard route does, then fire the workflow's + * user-controlled release edge via the SAME engine primitive the route uses. + */ +async function runReleaseAction( + id: string, + eventTag: string, + label: string, + opts: { rejectConflict?: boolean }, + projectName?: string, +) { + if (!id) { + console.error(`Usage: fn pr ${label} <pr-entity-id>`); + process.exit(1); + } + const { store } = await getPrContext(projectName); + const entity = requireEntity(store, id); + + if (!isPrEntityActive(entity)) { + console.error(`\n ✗ PR ${id} is already terminal (merged/closed/failed)\n`); + process.exit(1); + } + if (opts.rejectConflict && entity.mergeable === "conflicting") { + console.error(`\n ✗ Resolve conflicts on GitHub before merging\n`); + process.exit(1); + } + + const result = await releaseHeldTaskByEvent(store, entity.sourceId, eventTag); + if (!result.released) { + console.error(`\n ✗ ${label} did not release ${id}: ${result.rejection ?? "unknown"}\n`); + process.exit(1); + } + console.log(`\n ✓ ${label} fired for ${id}${result.toColumn ? ` → ${result.toColumn}` : ""}\n`); +} + +export async function runPrApprove(id: string, projectName?: string) { + await runReleaseAction(id, "pr-approve", "approve", {}, projectName); +} + +export async function runPrRespond(id: string, projectName?: string) { + await runReleaseAction(id, "pr-respond", "respond", {}, projectName); +} + +export async function runPrRetry(id: string, projectName?: string) { + await runReleaseAction(id, "pr-retry", "retry", {}, projectName); +} + +export async function runPrMerge(id: string, projectName?: string) { + await runReleaseAction(id, "pr-merge", "merge", { rejectConflict: true }, projectName); +} + +export async function runPrClose(id: string, projectName?: string) { + await runReleaseAction(id, "pr-close", "close", {}, projectName); +} + +export async function runPrAutomerge(id: string, enabled: boolean | undefined, projectName?: string) { + if (!id) { + console.error("Usage: fn pr automerge <pr-entity-id> [on|off]"); + process.exit(1); + } + const { store } = await getPrContext(projectName); + const entity = requireEntity(store, id); + + if (!isPrEntityActive(entity)) { + console.error(`\n ✗ PR ${id} is already terminal (merged/closed/failed)\n`); + process.exit(1); + } + + const next = typeof enabled === "boolean" ? enabled : !entity.autoMerge; + const updated = store.updatePrEntity(id, { autoMerge: next }); + console.log(`\n ✓ Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeReason(updated)})\n`); +} diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 5528028e59..a2cf2eb755 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -7,9 +7,7 @@ import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node import { basename, join } from "node:path"; import * as dashboard from "@fusion/dashboard"; import { - classifyGhError, getGhErrorMessage, - getCurrentRepo, isGhAuthenticated, isGhAvailable, runGhJsonAsync, @@ -33,17 +31,6 @@ try { // Some tests partially mock @fusion/dashboard and omit the hook export. } -function formatGhErrorForCli(err: unknown): string { - const structured = classifyGhError(err); - const lines = [`GitHub error: ${structured.message}`]; - if (structured.hint) lines.push(` Hint: ${structured.hint}`); - if (structured.action?.kind === "shell") lines.push(` Action: run \`${structured.action.command}\``); - if (structured.action?.kind === "open") lines.push(` Action: open ${structured.action.url}`); - if (structured.action?.kind === "retry") lines.push(" Action: retry the command"); - if (structured.retryable) lines.push(" (retryable — re-run `fn pr create <task-id>` to try again)"); - return `${lines.join("\n")}\n`; -} - function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined { if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined; const issueUrl = (sourceMetadata as { issueUrl?: unknown }).issueUrl; @@ -1511,154 +1498,15 @@ export async function runTaskSteer(id: string, message?: string, projectName?: s } // ── PR Creation ───────────────────────────────────────────────────────────── - -export interface PrCreateOptions { - title?: string; - base?: string; - body?: string; - draft?: boolean; - /** When true (default), call generatePrMetadata for title/body unless user provided both. */ - ai?: boolean; - /** Repeatable --reviewer flag values. */ - reviewers?: string[]; -} - -export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) { - const store = await getStore(projectName); - - // Fetch task and validate it exists - let task; - try { - task = await store.getTask(id); - } catch (err) { - if (typeof err === "object" && err !== null && (err as Record<string, unknown>).code === "ENOENT") { - console.error(`Error: Task ${id} not found`); - process.exit(1); - } - throw err; - } - - // Validate task is in 'in-review' column - if (task.column !== "in-review") { - console.error(`Error: Task must be in 'in-review' column to create a PR (current: ${task.column})`); - process.exit(1); - } - - // Check if task already has PR info - if (task.prInfo) { - console.error(`Error: Task already has PR #${task.prInfo.number}: ${task.prInfo.url}`); - process.exit(1); - } - - // Determine owner/repo from GITHUB_REPOSITORY env or git remote - let owner: string; - let repo: string; - - const envRepo = process.env.GITHUB_REPOSITORY; - if (envRepo) { - const [o, r] = envRepo.split("/"); - if (!o || !r) { - console.error("Error: GITHUB_REPOSITORY format is invalid (expected: owner/repo)"); - process.exit(1); - } - owner = o; - repo = r; - } else { - const projectPath = await getProjectPath(projectName); - const gitRepo = getCurrentRepo(projectPath); - if (!gitRepo) { - console.error("Error: Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote."); - process.exit(1); - } - owner = gitRepo.owner; - repo = gitRepo.repo; - } - - // Validate GitHub auth - if (!isGhAvailable() || !isGhAuthenticated()) { - console.error("Error: GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'."); - process.exit(1); - } - - // Build branch name using the established project convention - const branchName = `fusion/${id.toLowerCase()}`; - - // Build deterministic fallback PR title - const fallbackTitle = options.title - ? options.title - : task.title - ? task.title - : (() => { - const desc = task.description.trim(); - let derived = desc.charAt(0).toUpperCase() + desc.slice(1, 50); - if (desc.length > 50) { - derived += "…"; - } - return derived; - })(); - - let resolvedTitle = fallbackTitle; - let resolvedBody = options.body; - - const shouldUseAi = options.ai !== false && !(options.title && options.body); - if (shouldUseAi) { - try { - const repoRoot = await getProjectPath(projectName); - const settings = ("getSettings" in store - ? await store.getSettings() - : {}) as Parameters<typeof dashboard.generatePrMetadata>[0]["settings"]; - const generated = await dashboard.generatePrMetadata({ task, repoRoot, settings }); - if (!options.title) { - resolvedTitle = generated.title; - } - if (!options.body) { - resolvedBody = generated.body; - } - console.log(" → Using AI-generated title/body (use --no-ai to skip)"); - } catch (err) { - process.stderr.write(`AI metadata generation failed; using fallback PR metadata. ${getGhErrorMessage(err)}\n`); - } - } - - // Create PR via GitHubClient - const client = new dashboard.GitHubClient(); - - try { - const prInfo = await client.createPr({ - owner, - repo, - title: resolvedTitle, - body: resolvedBody, - head: branchName, - base: options.base, - draft: options.draft, - reviewers: options.reviewers, - }); - - // Store PR info - await store.updatePrInfo(task.id, prInfo); - await store.logEntry(task.id, "Created PR", `PR #${prInfo.number}: ${prInfo.url}`); - - console.log(); - console.log(` ✓ Created PR for ${task.id}`); - console.log(` PR #${prInfo.number}: ${prInfo.url}`); - console.log(` Branch: ${branchName} → ${prInfo.baseBranch}`); - console.log(); - } catch (err) { - // Handle specific error cases - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("already exists")) { - console.error(`Error: A pull request already exists for ${owner}/${repo}:${branchName}`); - process.exit(1); - } else if (msg.includes("No commits between")) { - console.error(`Error: No commits between ${options.base || "default base"} and ${branchName}. Push changes before creating PR.`); - process.exit(1); - } else { - process.stderr.write(formatGhErrorForCli(err)); - process.exit(1); - } - } -} +// +// The PR-creation implementation moved to commands/pr.ts as `runPrCreate` when +// the per-task `fn task pr-create` command was retired in favor of the unified +// `fn pr` namespace (U8, R13). These re-exports are kept ONLY so existing +// importers/tests that referenced the old symbols keep resolving; `fn task +// pr-create` no longer dispatches from bin.ts. Prefer `runPrCreate` / `fn pr +// create` for new code. +export type { PrCreateOptions } from "./pr.js"; +export { runPrCreate as runTaskPrCreate } from "./pr.js"; // ── Planning Mode ───────────────────────────────────────────────────────────