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

@@ -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 */