feat(KB-200): add pr-create CLI command for task PR creation

- Implement kb task pr-create command to open GitHub PRs from tasks\n- Add draft PR support and title/description customization options\n- Integrate routing in CLI entry point with proper argument parsing\n- Add comprehensive test coverage for the new command\n- Update STANDALONE.md documentation and add changeset for release tracking
This commit is contained in:
gsxdsm
2026-03-31 03:27:11 -07:00
parent d34951a913
commit 3fe8dd3c89
5 changed files with 522 additions and 2 deletions

View File

@@ -93,6 +93,8 @@ fn task list # List all tasks
fn task show KB-001 # Show task details, steps, and log
fn task move KB-001 todo # Move a task to a column
fn task merge KB-001 # Merge an in-review task and close it
fn task pr-create KB-001 # Create a GitHub PR for an in-review task
fn task pr-create KB-001 --title "Custom PR title" --base develop --body "PR description"
fn task log KB-001 "Added context" # Add a log entry
fn task pause KB-001 # Pause a task (stops automation)
fn task unpause KB-001 # Resume a paused task

View File

@@ -39,7 +39,7 @@ if (isBunBinary) {
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
const { runDashboard } = await import("./commands/dashboard.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskSteer } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
@@ -71,6 +71,8 @@ Usage:
fn task unpause <id> Unpause a task (resumes automation)
fn task steer <id> [message] Add steering comment (prompts if message omitted)
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task
fn task import <owner/repo> [opts] Import GitHub issues as tasks
fn settings Show current Fusion configuration
fn settings set <key> <value> Update a configuration setting
@@ -310,6 +312,36 @@ async function main() {
await runTaskRetry(id);
break;
}
case "pr-create": {
const id = args[2];
if (!id) {
console.error("Usage: fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]");
process.exit(1);
}
// Parse optional flags
let title: string | undefined;
let base: string | undefined;
let body: string | undefined;
const titleIdx = args.indexOf("--title");
if (titleIdx !== -1 && titleIdx + 1 < args.length) {
title = args[titleIdx + 1];
}
const baseIdx = args.indexOf("--base");
if (baseIdx !== -1 && baseIdx + 1 < args.length) {
base = args[baseIdx + 1];
}
const bodyIdx = args.indexOf("--body");
if (bodyIdx !== -1 && bodyIdx + 1 < args.length) {
body = args[bodyIdx + 1];
}
await runTaskPrCreate(id, { title, base, body });
break;
}
case "import": {
const ownerRepo = args[2];
if (!ownerRepo) {

View File

@@ -35,10 +35,26 @@ vi.mock("@kb/core", () => {
// Mock @kb/engine
vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
// Mock @kb/dashboard
vi.mock("@kb/dashboard", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
createPr: vi.fn(),
})),
}));
// Mock @kb/core/gh-cli
vi.mock("@kb/core/gh-cli", () => ({
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
getCurrentRepo: vi.fn(),
}));
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@kb/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, type LogsOptions } from "./task.js";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskPrCreate, type LogsOptions } from "./task.js";
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@kb/core/gh-cli";
import { GitHubClient } from "@kb/dashboard";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -1580,3 +1596,335 @@ describe("runTaskLogs", () => {
sigintHandlers.forEach((handler) => handler());
});
});
// ── PR Create Tests ───────────────────────────────────────────────────────────
import type { PrInfo } from "@kb/core";
describe("runTaskPrCreate", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let mockGetTask: ReturnType<typeof vi.fn>;
let mockUpdatePrInfo: ReturnType<typeof vi.fn>;
let mockLogEntry: ReturnType<typeof vi.fn>;
let mockCreatePr: ReturnType<typeof vi.fn>;
const originalEnv = { ...process.env };
function makeInReviewTask(overrides: Record<string, unknown> = {}) {
return {
id: "KB-001",
title: "Test Task Title",
description: "Test task description for PR creation",
column: "in-review",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prInfo: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function makePrInfo(overrides: Partial<PrInfo> = {}): PrInfo {
return {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Test Task Title",
headBranch: "kb/kb-001",
baseBranch: "main",
commentCount: 0,
...overrides,
};
}
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Reset process.env
process.env = { ...originalEnv };
delete process.env.GITHUB_REPOSITORY;
delete process.env.GITHUB_TOKEN;
// Setup GitHubClient mock
mockCreatePr = vi.fn();
vi.mocked(GitHubClient).mockImplementation(() => ({
createPr: mockCreatePr,
} as unknown as GitHubClient));
// Setup gh-cli mocks
vi.mocked(isGhAvailable).mockReturnValue(true);
vi.mocked(isGhAuthenticated).mockReturnValue(true);
vi.mocked(getCurrentRepo).mockReturnValue({ owner: "owner", repo: "repo" });
// Setup TaskStore mocks
mockGetTask = vi.fn();
mockUpdatePrInfo = vi.fn();
mockLogEntry = vi.fn();
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: mockGetTask,
updatePrInfo: mockUpdatePrInfo,
logEntry: mockLogEntry,
}));
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
it("creates PR successfully with all options", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo({
title: "Custom PR Title",
number: 42,
url: "https://github.com/owner/repo/pull/42",
}));
await runTaskPrCreate("KB-001", {
title: "Custom PR Title",
base: "develop",
body: "PR description body"
});
expect(mockGetTask).toHaveBeenCalledWith("KB-001");
expect(mockCreatePr).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
title: "Custom PR Title",
body: "PR description body",
head: "kb/kb-001",
base: "develop",
});
expect(mockUpdatePrInfo).toHaveBeenCalledWith("KB-001", expect.objectContaining({
number: 42,
url: "https://github.com/owner/repo/pull/42",
}));
expect(mockLogEntry).toHaveBeenCalledWith("KB-001", "Created PR", "PR #42: https://github.com/owner/repo/pull/42");
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Created PR")
);
expect(successLine).toBeDefined();
});
it("creates PR with minimal options using task title", async () => {
const task = makeInReviewTask({ title: "My Task Title" });
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo({ title: "My Task Title" }));
await runTaskPrCreate("KB-001", {});
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({
title: "My Task Title",
head: "kb/kb-001",
}));
});
it("generates title from description when task has no title", async () => {
const task = makeInReviewTask({ title: undefined, description: "this is a very long description that will be truncated for the pr title" });
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo());
await runTaskPrCreate("KB-001", {});
// Title should be first 50 chars of description, sentence-cased, with ellipsis if truncated
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({
title: "This is a very long description that will be trunc…",
}));
});
it("exits with error when task not found", async () => {
const err = new Error("Task not found") as Error & { code: string };
err.code = "ENOENT";
mockGetTask.mockRejectedValueOnce(err);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-999", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith("Error: Task KB-999 not found");
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("exits with error when task not in in-review column", async () => {
const task = makeInReviewTask({ column: "todo" });
mockGetTask.mockResolvedValueOnce(task);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("must be in 'in-review' column"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("exits with error when task already has PR", async () => {
const task = makeInReviewTask({
prInfo: {
url: "https://github.com/owner/repo/pull/10",
number: 10,
status: "open",
title: "Existing PR",
headBranch: "kb/kb-001",
baseBranch: "main",
commentCount: 0,
},
});
mockGetTask.mockResolvedValueOnce(task);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("already has PR #10"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("exits with error when no GitHub auth available", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
vi.mocked(isGhAvailable).mockReturnValue(false);
vi.mocked(isGhAuthenticated).mockReturnValue(false);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Not authenticated with GitHub"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("uses GITHUB_TOKEN when gh CLI not available", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
vi.mocked(isGhAvailable).mockReturnValue(false);
vi.mocked(isGhAuthenticated).mockReturnValue(false);
process.env.GITHUB_TOKEN = "test-token";
mockCreatePr.mockResolvedValueOnce(makePrInfo());
await runTaskPrCreate("KB-001", {});
expect(GitHubClient).toHaveBeenCalledWith("test-token");
expect(mockUpdatePrInfo).toHaveBeenCalled();
});
it("exits with error when no repository detected", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
vi.mocked(getCurrentRepo).mockReturnValue(null);
delete process.env.GITHUB_REPOSITORY;
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Could not determine GitHub repository"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("uses GITHUB_REPOSITORY env var when available", async () => {
process.env.GITHUB_REPOSITORY = "custom-owner/custom-repo";
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockResolvedValueOnce(makePrInfo());
await runTaskPrCreate("KB-001", {});
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({
owner: "custom-owner",
repo: "custom-repo",
}));
});
it("exits with error for invalid GITHUB_REPOSITORY format", async () => {
process.env.GITHUB_REPOSITORY = "invalid-format";
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("GITHUB_REPOSITORY format is invalid"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("exits with error when PR already exists for branch", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockRejectedValueOnce(new Error("A pull request already exists for owner/repo:kb/kb-001"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("already exists for owner/repo:kb/kb-001"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("exits with error when branch has no commits", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockRejectedValueOnce(new Error("No commits between main and kb/kb-001"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("No commits between"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it("exits with generic error for unexpected failures", async () => {
const task = makeInReviewTask();
mockGetTask.mockResolvedValueOnce(task);
mockCreatePr.mockRejectedValueOnce(new Error("Network error"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as (code?: number) => never);
await expect(runTaskPrCreate("KB-001", {})).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Network error"));
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
});

View File

@@ -5,6 +5,8 @@ import type { PlanningQuestion, PlanningSummary } from "@kb/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@kb/dashboard/planning";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { GitHubClient } from "@kb/dashboard";
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@kb/core/gh-cli";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
@@ -936,6 +938,128 @@ export async function runTaskSteer(id: string, message?: string) {
console.log();
}
// ── PR Creation ─────────────────────────────────────────────────────────────
export interface PrCreateOptions {
title?: string;
base?: string;
body?: string;
}
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}) {
const store = await getStore();
// Fetch task and validate it exists
let task;
try {
task = await store.getTask(id);
} catch (err: any) {
if (err.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 gitRepo = getCurrentRepo(process.cwd());
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
const hasGhAuth = isGhAvailable() && isGhAuthenticated();
const hasToken = !!process.env.GITHUB_TOKEN;
if (!hasGhAuth && !hasToken) {
console.error("Error: Not authenticated with GitHub. Run 'gh auth login' or set GITHUB_TOKEN.");
process.exit(1);
}
// Build branch name
const branchName = `kb/${id.toLowerCase()}`;
// Build PR title
let title: string;
if (options.title) {
title = options.title;
} else if (task.title) {
title = task.title;
} else {
// Generate from description (first 50 chars, sentence case)
const desc = task.description.trim();
title = desc.charAt(0).toUpperCase() + desc.slice(1, 50);
if (desc.length > 50) {
title += "…";
}
}
// Create PR via GitHubClient
const githubToken = process.env.GITHUB_TOKEN;
const client = new GitHubClient(githubToken);
try {
const prInfo = await client.createPr({
owner,
repo,
title,
body: options.body,
head: branchName,
base: options.base,
});
// 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: any) {
// Handle specific error cases
if (err.message?.includes("already exists")) {
console.error(`Error: A pull request already exists for ${owner}/${repo}:${branchName}`);
process.exit(1);
} else if (err.message?.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 {
console.error(`Error: ${err.message || "Failed to create PR"}`);
process.exit(1);
}
}
}
// ── Planning Mode ───────────────────────────────────────────────────────────
/** Helper to display thinking indicator */