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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
193
packages/cli/src/__tests__/pr-command.test.ts
Normal file
193
packages/cli/src/__tests__/pr-command.test.ts
Normal 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":');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
367
packages/cli/src/commands/pr.ts
Normal file
367
packages/cli/src/commands/pr.ts
Normal 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`);
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user