feat(pr): fn pr CLI commands, retire fn task pr-create (U8)

Adds the fn pr noun — create/show/list/respond/approve/retry/merge/close/
automerge — routing to the same store/engine/release paths as the U7
dashboard routes (surface-parity pinned by a consistency test). Retires
fn task pr-create (dispatch removed; deprecated re-export kept for
importers). Changeset: @runfusion/fusion minor. 22 command tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 22:28:50 -07:00
parent 8a6a67a305
commit 34c8ac9cd7
7 changed files with 690 additions and 187 deletions

View File

@@ -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");
});
});

View File

@@ -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 <task-id>"));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Try: fn pr create <task-id>"));
});
it("routes task delete with allow-resurrection flag", async () => {

View File

@@ -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<string, unknown> = {}) {
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<string, ReturnType<typeof vi.fn>>;
function mockStore(store: Record<string, ReturnType<typeof vi.fn>>) {
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":');
});
});

View File

@@ -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 <id> <node-name-or-id> Set a per-task node override
fn task clear-node <id> Clear a per-task node override
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task pr-create <id> [--title <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) {

View File

@@ -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`);
}

View File

@@ -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 ───────────────────────────────────────────────────────────