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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user