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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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