diff --git a/extensions/cli/src/commands/pr.test.ts b/extensions/cli/src/commands/pr.test.ts new file mode 100644 index 00000000000..048e0f950c4 --- /dev/null +++ b/extensions/cli/src/commands/pr.test.ts @@ -0,0 +1,364 @@ +import * as nodeUtil from "util"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as gitUtil from "../util/git.js"; + +// Create mock execFile with promisify.custom support inside the factory +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + + const execFileMockFn: any = vi.fn(); + // Add promisify.custom to handle promisify(execFile) + (execFileMockFn as any)[(nodeUtil as any).promisify.custom] = ( + file: string, + args?: string[], + options?: any, + ) => + new Promise((resolve, reject) => { + // Call the mock with all arguments + execFileMockFn( + file, + args, + options, + (err: any, stdout: any, stderr: any) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }, + ); + }); + return { + ...actual, + execFile: execFileMockFn, + }; +}); + +// Import after mocking to get the mocked version +const childProcess = await import("child_process"); +const execFileMock = vi.mocked(childProcess.execFile); + +// Mock logger +vi.mock("../util/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Import after mocks +const { createPullRequest } = await import("./pr.js"); + +describe("pr endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + execFileMock.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("validation", () => { + it("should fail if not in a git repository", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(false); + + const result = await createPullRequest({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("Not in a git repository"); + }); + + it("should fail if current branch cannot be determined", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue(null); + + const result = await createPullRequest({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("Could not determine current branch"); + }); + + it("should fail if on main branch", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("main"); + + const result = await createPullRequest({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("You're currently on the main branch"); + }); + + it("should fail if on master branch", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("master"); + + const result = await createPullRequest({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("You're currently on the master branch"); + }); + + it("should fail if no remote URL found", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); + vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue(null); + + const result = await createPullRequest({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("Could not find git remote URL"); + }); + + it("should fail if remote is not a GitHub repository", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); + vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( + "https://gitlab.com/owner/repo.git", + ); + + const result = await createPullRequest({}); + + expect(result.success).toBe(false); + expect(result.error).toContain( + "doesn't appear to be a GitHub repository", + ); + }); + + it("should fail if GitHub CLI is not installed", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); + vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( + "https://github.com/owner/repo.git", + ); + + // Mock execFile to fail for gh --version + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + callback(new Error("Command not found"), "", ""); + return {} as any; + }, + ); + + const result = await createPullRequest({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("GitHub CLI (gh) is not installed"); + }); + }); + + describe("GitHub URL parsing", () => { + it("should handle HTTPS GitHub URLs", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); + vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( + "https://github.com/owner/repo.git", + ); + + // Mock execFile to handle different commands + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + if (file === "gh" && args?.[0] === "--version") { + callback(null, "gh version 2.0.0", ""); + } else if (file === "gh" && args?.[0] === "pr") { + callback( + null, + "https://github.com/owner/repo/pull/123\nPR created successfully", + "", + ); + } else if (file === "git" && args?.[0] === "log") { + callback(null, "- feat: add new feature", ""); + } + return {} as any; + }, + ); + + const result = await createPullRequest({}); + + expect(result.success).toBe(true); + expect(result.message).toContain("Pull request created successfully"); + }); + + it("should handle SSH GitHub URLs", async () => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature-branch"); + vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( + "git@github.com:owner/repo.git", + ); + + // Mock execFile to handle different commands + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + if (file === "gh" && args?.[0] === "--version") { + callback(null, "gh version 2.0.0", ""); + } else if (file === "gh" && args?.[0] === "pr") { + callback( + null, + "https://github.com/owner/repo/pull/456\nPR created successfully", + "", + ); + } else if (file === "git" && args?.[0] === "log") { + callback(null, "- feat: add new feature", ""); + } + return {} as any; + }, + ); + + const result = await createPullRequest({}); + + expect(result.success).toBe(true); + expect(result.message).toContain("Pull request created successfully"); + }); + }); + + describe("PR creation", () => { + beforeEach(() => { + vi.spyOn(gitUtil, "isGitRepo").mockReturnValue(true); + vi.spyOn(gitUtil, "getGitBranch").mockReturnValue("feature/new-endpoint"); + vi.spyOn(gitUtil, "getGitRemoteUrl").mockReturnValue( + "https://github.com/owner/repo.git", + ); + }); + + it("should create a PR with custom title and body", async () => { + let ghArgs: string[] = []; + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + if (file === "gh" && args?.[0] === "--version") { + callback(null, "gh version 2.0.0", ""); + } else if (file === "gh" && args?.[0] === "pr") { + ghArgs = args; + callback( + null, + "https://github.com/owner/repo/pull/1\nPR created successfully", + "", + ); + } else if (file === "git" && args?.[0] === "log") { + callback(null, "- feat: add new feature", ""); + } + return {} as any; + }, + ); + + const result = await createPullRequest({ + title: "Custom Title", + body: "Custom Body", + }); + + expect(result.success).toBe(true); + expect(result.prUrl).toContain("github.com/owner/repo/pull"); + expect(ghArgs).toContain("--title"); + expect(ghArgs).toContain("Custom Title"); + expect(ghArgs).toContain("--body"); + expect(ghArgs).toContain("Custom Body"); + }); + + it("should create a draft PR when draft option is true", async () => { + let ghArgs: string[] = []; + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + if (file === "gh" && args?.[0] === "--version") { + callback(null, "gh version 2.0.0", ""); + } else if (file === "gh" && args?.[0] === "pr") { + ghArgs = args; + callback( + null, + "https://github.com/owner/repo/pull/2\nPR created successfully", + "", + ); + } else if (file === "git" && args?.[0] === "log") { + callback(null, "- feat: add new feature", ""); + } + return {} as any; + }, + ); + + const result = await createPullRequest({ draft: true }); + + expect(result.success).toBe(true); + expect(ghArgs).toContain("--draft"); + }); + + it("should use custom base branch", async () => { + let ghArgs: string[] = []; + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + if (file === "gh" && args?.[0] === "--version") { + callback(null, "gh version 2.0.0", ""); + } else if (file === "gh" && args?.[0] === "pr") { + ghArgs = args; + callback( + null, + "https://github.com/owner/repo/pull/3\nPR created successfully", + "", + ); + } else if (file === "git" && args?.[0] === "log") { + callback(null, "- feat: add new feature", ""); + } + return {} as any; + }, + ); + + const result = await createPullRequest({ base: "develop" }); + + expect(result.success).toBe(true); + expect(ghArgs).toContain("--base"); + expect(ghArgs).toContain("develop"); + }); + + it("should open in browser when web option is true", async () => { + let ghArgs: string[] = []; + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + if (file === "gh" && args?.[0] === "--version") { + callback(null, "gh version 2.0.0", ""); + } else if (file === "gh" && args?.[0] === "pr") { + ghArgs = args; + callback( + null, + "https://github.com/owner/repo/pull/4\nPR created successfully", + "", + ); + } else if (file === "git" && args?.[0] === "log") { + callback(null, "- feat: add new feature", ""); + } + return {} as any; + }, + ); + + const result = await createPullRequest({ web: true }); + + expect(result.success).toBe(true); + expect(ghArgs).toContain("--web"); + }); + + it("should generate title from branch name", async () => { + let ghArgs: string[] = []; + execFileMock.mockImplementation( + (file: any, args: any, options: any, callback: any) => { + if (file === "gh" && args?.[0] === "--version") { + callback(null, "gh version 2.0.0", ""); + } else if (file === "gh" && args?.[0] === "pr") { + ghArgs = args; + callback( + null, + "https://github.com/owner/repo/pull/5\nPR created successfully", + "", + ); + } else if (file === "git" && args?.[0] === "log") { + callback(null, "- feat: add new feature", ""); + } + return {} as any; + }, + ); + + const result = await createPullRequest({}); + + expect(result.success).toBe(true); + // Should convert "feature/new-endpoint" to "New Endpoint" + expect(ghArgs).toContain("--title"); + expect(ghArgs).toContain("New Endpoint"); + }); + }); +}); diff --git a/extensions/cli/src/commands/pr.ts b/extensions/cli/src/commands/pr.ts new file mode 100644 index 00000000000..a3c83f36346 --- /dev/null +++ b/extensions/cli/src/commands/pr.ts @@ -0,0 +1,307 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; + +import { getGitBranch, getGitRemoteUrl, isGitRepo } from "../util/git.js"; +import { logger } from "../util/logger.js"; + +const execFileAsync = promisify(execFile); + +export interface PrOptions { + title?: string; + body?: string; + base?: string; + draft?: boolean; + web?: boolean; +} + +export interface PrResult { + success: boolean; + message: string; + error?: string; + prUrl?: string; +} + +/** + * Check if GitHub CLI is installed + */ +async function isGhInstalled(): Promise { + try { + await execFileAsync("gh", ["--version"]); + return true; + } catch { + return false; + } +} + +/** + * Parse GitHub repository owner and name from remote URL + */ +function parseGitHubRepo(remoteUrl: string): { + owner: string; + repo: string; +} | null { + // Handle various GitHub URL formats: + // - https://github.com/owner/repo.git + // - git@github.com:owner/repo.git + // - https://github.com/owner/repo + // - git@github.com:owner/repo + + const match = remoteUrl.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/); + + if (!match) { + return null; + } + + const [, owner, repo] = match; + return { owner, repo: repo.replace(/\.git$/, "") }; +} + +/** + * Create a pull request using GitHub CLI + */ +async function createPrWithGh(options: PrOptions): Promise<{ prUrl?: string }> { + const args = ["pr", "create"]; + + if (options.title) { + args.push("--title", options.title); + } + + if (options.body) { + args.push("--body", options.body); + } + + if (options.base) { + args.push("--base", options.base); + } + + if (options.draft) { + args.push("--draft"); + } + + if (options.web) { + args.push("--web"); + } else { + // Fill in title and body interactively if not provided + if (!options.title || !options.body) { + args.push("--fill"); + } + } + + try { + const { stdout, stderr } = await execFileAsync("gh", args, { + cwd: process.cwd(), + }); + + if (stderr && !stderr.includes("Creating pull request")) { + logger.warn(`GitHub CLI stderr: ${stderr}`); + } + + // Extract PR URL from output (gh outputs the URL on success) + const urlMatch = stdout.match(/https:\/\/github\.com\/[^\s]+/); + const prUrl = urlMatch ? urlMatch[0] : undefined; + + logger.info(`GitHub CLI output: ${stdout}`); + + return { prUrl }; + } catch (error: any) { + throw new Error(`Failed to create pull request: ${error.message}`); + } +} + +/** + * Get commit messages for the current branch + */ +async function getCommitMessages(base: string = "main"): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["log", `${base}..HEAD`, "--pretty=format:- %s"], + { + cwd: process.cwd(), + }, + ); + return stdout.trim(); + } catch { + return ""; + } +} + +/** + * Generate a default PR title from the branch name + */ +function getTitleFromBranch(branch: string): string { + // Convert branch name to a readable title + // e.g., "feature/add-new-endpoint" -> "Add new endpoint" + return branch + .replace(/^(feature|fix|bugfix|hotfix|chore|docs|refactor|test)\//i, "") + .split(/[-_]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * Validate git repository and branch requirements + */ +function validateGitEnvironment(): { + currentBranch: string; + remoteUrl: string; + repoInfo: { owner: string; repo: string }; + error?: string; +} { + // Check if we're in a git repository + if (!isGitRepo()) { + return { + currentBranch: "", + remoteUrl: "", + repoInfo: { owner: "", repo: "" }, + error: + "Not in a git repository. Please run this command from a git repository.", + }; + } + + // Get current branch + const currentBranch = getGitBranch(); + if (!currentBranch) { + return { + currentBranch: "", + remoteUrl: "", + repoInfo: { owner: "", repo: "" }, + error: "Could not determine current branch.", + }; + } + + // Check if we're on main/master branch + const baseBranches = ["main", "master"]; + if (baseBranches.includes(currentBranch)) { + return { + currentBranch, + remoteUrl: "", + repoInfo: { owner: "", repo: "" }, + error: `You're currently on the ${currentBranch} branch. Please create a feature branch first.`, + }; + } + + // Get remote URL + const remoteUrl = getGitRemoteUrl(); + if (!remoteUrl) { + return { + currentBranch, + remoteUrl: "", + repoInfo: { owner: "", repo: "" }, + error: + "Could not find git remote URL. Make sure you have a remote configured.", + }; + } + + // Verify it's a GitHub repository + const repoInfo = parseGitHubRepo(remoteUrl); + if (!repoInfo) { + return { + currentBranch, + remoteUrl, + repoInfo: { owner: "", repo: "" }, + error: + "This doesn't appear to be a GitHub repository. Pull request creation is only supported for GitHub repositories.", + }; + } + + return { currentBranch, remoteUrl, repoInfo }; +} + +/** + * Check GitHub CLI installation + */ +async function ensureGitHubCliInstalled(): Promise<{ + installed: boolean; + error?: string; +}> { + const ghInstalled = await isGhInstalled(); + + if (!ghInstalled) { + return { + installed: false, + error: + "GitHub CLI (gh) is not installed. Please install it to create pull requests.\n" + + "To install:\n" + + " macOS: brew install gh\n" + + " Linux: https://github.com/cli/cli#installation\n" + + " Windows: winget install --id GitHub.cli\n" + + "After installation, authenticate with: gh auth login", + }; + } + + return { installed: true }; +} + +/** + * Main function to create a pull request + */ +export async function createPullRequest( + options: PrOptions = {}, +): Promise { + logger.debug("Creating pull request", { options }); + + // Validate git environment + const gitEnv = validateGitEnvironment(); + if (gitEnv.error) { + return { + success: false, + message: "Validation failed", + error: gitEnv.error, + }; + } + + const { currentBranch, repoInfo } = gitEnv; + + logger.info(`Creating pull request for branch: ${currentBranch}`); + logger.info(`Repository: ${repoInfo.owner}/${repoInfo.repo}`); + + // Check if GitHub CLI is installed + const ghCheck = await ensureGitHubCliInstalled(); + if (!ghCheck.installed) { + return { + success: false, + message: "GitHub CLI not installed", + error: ghCheck.error, + }; + } + + // Generate defaults if not provided + const base = options.base || "main"; + let title = options.title; + let body = options.body; + + if (!title) { + title = getTitleFromBranch(currentBranch); + } + + if (!body) { + const commits = await getCommitMessages(base); + if (commits) { + body = `## Changes\n\n${commits}`; + } + } + + // Create the pull request + try { + const result = await createPrWithGh({ + ...options, + title, + body, + base, + }); + + return { + success: true, + message: "Pull request created successfully", + prUrl: result.prUrl, + }; + } catch (error: any) { + logger.error("Failed to create pull request", { error }); + return { + success: false, + message: "Failed to create pull request", + error: error.message, + }; + } +} diff --git a/extensions/cli/src/commands/serve.ts b/extensions/cli/src/commands/serve.ts index 2b371846221..934540ae31b 100644 --- a/extensions/cli/src/commands/serve.ts +++ b/extensions/cli/src/commands/serve.ts @@ -34,6 +34,7 @@ import { logger } from "../util/logger.js"; import { readStdinSync } from "../util/stdin.js"; import { ExtendedCommandOptions } from "./BaseCommandOptions.js"; +import { createPullRequest, type PrOptions } from "./pr.js"; import { streamChatResponseWithInterruption, type ServerState, @@ -320,6 +321,40 @@ export async function serve(prompt?: string, options: ServeOptions = {}) { } }); + // POST /pr - Create a pull request + app.post("/pr", async (req: Request, res: Response) => { + state.lastActivity = Date.now(); + + try { + const options: PrOptions = req.body; + logger.info("Creating pull request", { options }); + + const result = await createPullRequest(options); + + if (!result.success) { + res.status(400).json({ + success: false, + message: result.message, + error: result.error, + }); + return; + } + + res.json({ + success: true, + message: result.message, + prUrl: result.prUrl, + }); + } catch (error) { + logger.error(`Pull request creation error: ${formatError(error)}`); + res.status(500).json({ + success: false, + message: "Internal server error", + error: formatError(error), + }); + } + }); + // Track intervals for cleanup let inactivityChecker: NodeJS.Timeout | null = null; @@ -381,6 +416,11 @@ export async function serve(prompt?: string, options: ServeOptions = {}) { console.log( chalk.dim(" GET /diff - Get git diff against main branch"), ); + logger.info( + chalk.dim( + " POST /pr - Create a pull request (body: { title?, body?, base?, draft?, web? })", + ), + ); console.log( chalk.dim(" POST /exit - Gracefully shut down the server"), );