UNPKG

pr-desc-cli

Version:
588 lines (587 loc) 28.1 kB
#!/usr/bin/env node import { Command } from "commander"; import chalk from "chalk"; import ora from "ora"; import { config } from "dotenv"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { input, select, password, confirm } from "@inquirer/prompts"; import { join, dirname } from "path"; import { generatePRDescription } from "./pr-generator.js"; import { generateConventionalCommitMessage } from "./commit-generator.js"; import { getGitChanges, createPR, getPRForCurrentBranch, isGhCliInstalled, updatePR, pushCurrentBranch, runGitCommand, } from "./git-utils.js"; import { getSupportedModels, SUPPORTED_MODELS } from "./models.js"; import { loadConfig, setApiKey, getApiKey, saveConfig } from "./config.js"; import { maskApiKey } from "./utils.js"; import { GhNeedsPushError, } from "./types.js"; import { generateQuickSummary } from "./quick-summary.js"; config(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJsonPath = join(__dirname, "..", "package.json"); let packageJson; try { packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); } catch (error) { packageJson = { name: "pr-desc-cli", version: "1.0.0", description: "AI-powered PR description generator", }; } const program = new Command(); if (process.argv.length <= 2) { const PR_DESC_ASCII = ` ██████╗ ██████╗ ██████╗ ███████╗███████╗ ██████╗ ██╔══██╗██╔══██╗ ██╔═══██╗██╔════╝██╔════╝ ██╔═══╝ ██████╔╝██████╔╝ ██║ ██║█████╗ ███████╗ ██║ ██╔═══╝ ██╔══██╗ ██║ ██║██╔══╝ ╚██║ ██║ ██║ ██║ ██║ ╚██████╔╝███████╗███████║ ██████╗ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝`; console.log(chalk.cyan(PR_DESC_ASCII)); console.log(chalk.bold.cyan(`\n✨ Welcome to ${packageJson.name} v${packageJson.version} ✨\n`)); console.log(chalk.dim(packageJson.description || "")); console.log(chalk.dim("Generate AI-powered PR descriptions from your git changes.\n")); console.log(chalk.dim("Usage:")); console.log(chalk.dim(" $ pr-desc <command> [options]\n")); console.log(chalk.dim("Commands:")); console.log(chalk.dim(" generate (gen) Generate PR description from git changes")); console.log(chalk.dim(" commit Generate an AI conventional commit message")); console.log(chalk.dim(" init Interactive setup wizard")); console.log(chalk.dim(" models List available models for each provider")); console.log(chalk.dim(" config Manage configuration and API keys\n")); console.log(chalk.dim("Options:")); console.log(chalk.dim(" -v, --version Show version number")); console.log(chalk.dim(" -h, --help Show help\n")); console.log(chalk.dim("Run 'pr-desc <command> --help' for command-specific options.\n")); process.exit(0); } // Get program metadata from package.json program .name(packageJson.name) .description(packageJson.description) .version(packageJson.version); program .command("generate") .alias("gen") .description("Generate PR description from git changes") .option("-b, --base <branch>", "Base branch to compare against") .option("-p, --provider <provider>", "AI provider (groq, local)") .option("-m, --model <model>", "AI model to use") .option("--template <template>", "PR template style (standard, detailed, minimal)") .option("--template-file <path>", "Path to a custom Markdown template file") // user-custom template .option("--max-files <number>", "Maximum number of files to analyze", "20") .option("--dry-run", "Display decorative output for interactive review (dry run)", false) .option("--gh-pr", "Create or update a PR on GitHub with the generated description using the GitHub CLI", false) .action(async (options) => { const spinner = ora("Analyzing git changes...").start(); try { // load default config const config = loadConfig(); if (!config.defaultProvider) config.defaultProvider = "groq"; if (!config.defaultTemplate) config.defaultTemplate = "standard"; if (!config.defaultBaseBranch) config.defaultBaseBranch = "main"; // set options options.provider = options.provider || config.defaultProvider; options.template = options.template || config.defaultTemplate; options.base = options.base || config.defaultBaseBranch; options.model = options.model || SUPPORTED_MODELS[options.provider] .default; let mode = "branch"; try { const stagedNameOnly = await runGitCommand([ "diff", "--cached", "--name-only", ]); if (stagedNameOnly && stagedNameOnly.trim().length > 0) { mode = "staged"; spinner.text = "Analyzing staged changes..."; } } catch (e) { } const changes = await getGitChanges(options.base, Number.parseInt(options.maxFiles || "20"), mode); if (!changes.files.length) { spinner.fail("No changes found"); return; } let customTemplateContent; if (options.templateFile) { try { customTemplateContent = readFileSync(options.templateFile, "utf-8"); spinner.text = "Generating PR description with AI using custom template..."; } catch (fileError) { spinner.fail(`Error reading custom template file: ${fileError instanceof Error ? fileError.message : "Unknown error"}`); process.exit(1); } } else { spinner.text = "Generating PR description with AI..."; } let description = await generatePRDescription(changes, { provider: options.provider, model: options.model, template: options.template, customTemplateContent: customTemplateContent, }); spinner.succeed("PR description generated!"); let quickSummary = await generateQuickSummary(changes, { provider: options.provider, model: options.model, template: options.template, maxFiles: Number.parseInt(options.maxFiles || "20"), }); if (options.ghPr) { if (!(await isGhCliInstalled())) { spinner.fail("GitHub CLI ('gh') is not installed. Please install it to create PRs."); return; } let proceed = false; let regenerate = true; while (regenerate) { console.log("\n" + chalk.blue("═".repeat(60))); console.log("\n" + (quickSummary ? quickSummary : "")); console.log("\n" + chalk.blue("═".repeat(60))); console.log(chalk.bold.cyan("🚀 Generated PR Description Preview")); console.log(chalk.blue("═".repeat(60))); console.log("\n" + description + "\n"); console.log(chalk.blue("═".repeat(60))); proceed = await confirm({ message: "Continue with this PR description?", default: true, }); if (proceed) { regenerate = false; } else { const action = await select({ message: "What would you like to do?", choices: [ { name: "Regenerate", value: "regenerate" }, { name: "Cancel", value: "cancel" }, ], }); if (action === "regenerate") { spinner.start("Re-generating PR description..."); description = await generatePRDescription(changes, { provider: options.provider, model: options.model, template: options.template, customTemplateContent: customTemplateContent, refineFrom: description, }); spinner.succeed("PR description re-generated!"); quickSummary = await generateQuickSummary(changes, { provider: options.provider, model: options.model, template: options.template, maxFiles: Number.parseInt(options.maxFiles || "20"), }); } else { spinner.info("PR creation cancelled."); return; } } } if (proceed) { try { spinner.start("Checking for uncommitted changes..."); const gitStatus = await runGitCommand(["status", "--porcelain"]); if (gitStatus.trim().length > 0) { spinner.warn(`Unstaged changes found:\n${gitStatus}.\n`); const action = await select({ message: `What would you like to do?`, choices: [ { name: "Commit changes", value: "commit" }, { name: "Stash changes and continue", value: "stash" }, { name: "Cancel PR creation", value: "cancel" }, ], }); switch (action) { case "commit": const commitOptions = await select({ message: "Commit message mode:", choices: [ { name: "Manual input", value: "manual" }, { name: "AI (Conventional Commit)", value: "ai" }, ], default: "manual", }); let commitMessage; if (commitOptions === "ai") { try { spinner.start("Generating conventional commit message with AI..."); await runGitCommand(["add", "."]); const stagedChanges = await getGitChanges(options.base, Number.parseInt(options.maxFiles || "20"), "staged"); commitMessage = await generateConventionalCommitMessage(stagedChanges, { provider: options.provider, model: options.model, }); spinner.succeed("AI commit message generated."); const accept = await confirm({ message: `Use generated commit message: \n${commitMessage}\n?`, default: true, }); if (!accept) { commitMessage = await input({ message: "Enter a commit message:", default: commitMessage, }); } } catch (aiErr) { spinner.warn(`AI commit generation failed: ${aiErr instanceof Error ? aiErr.message : aiErr}. Falling back to manual input.`); commitMessage = await input({ message: "Enter a commit message:", default: "chore: prepare for PR", }); } } else { commitMessage = await input({ message: "Enter a commit message:", default: "chore: prepare for PR", }); await runGitCommand(["add", "."]); } spinner.start("Committing changes..."); try { await runGitCommand(["commit", "-m", commitMessage]); spinner.succeed("Changes committed. Continuing PR creation..."); } catch (commitError) { spinner.fail(`Failed to commit changes: ${commitError}`); return; } break; case "stash": spinner.start("Stashing uncommitted changes..."); try { await runGitCommand(["stash"]); // stash the changes spinner.succeed("Changes stashed. Continuing PR creation..."); } catch (stashError) { spinner.fail(`Failed to stash changes: ${stashError}`); return; } break; case "cancel": default: spinner.info("PR creation cancelled."); return; } } } catch (error) { spinner.fail(`Error: ${error instanceof Error ? error.message : "Unknown error"}`); process.exit(1); } const existingPr = await getPRForCurrentBranch(changes.currentBranch); if (existingPr) { spinner.start(`Updating PR #${existingPr.number}...`); await updatePR(existingPr.number, description); spinner.succeed(`Successfully updated PR #${existingPr.number}: ${existingPr.url}`); } else { while (true) { spinner.start("Creating PR..."); try { const response = await createPR(description); spinner.succeed(`Successfully created PR: ${response}`); break; } catch (error) { if (error instanceof GhNeedsPushError) { spinner.warn("Your branch is not pushed to origin."); const pushCB = await confirm({ message: `Would you like to push branch '${changes.currentBranch}' to origin?`, default: true, }); if (pushCB) { spinner.start("Pushing branch to origin..."); try { await pushCurrentBranch(changes.currentBranch); spinner.succeed("Successfully pushed to origin! Continuing PR creation..."); } catch (pushError) { spinner.fail(`Failed to push branch: ${pushError instanceof Error ? pushError.message : pushError}`); break; } } else { spinner.info("PR creation cancelled."); break; } } else { spinner.fail(`PR creation failed: ${error instanceof Error ? error.message : error}`); break; } } } } } } else if (options.dryRun) { console.log("\n" + chalk.blue("═".repeat(60))); console.log("\n" + (quickSummary ? quickSummary : "")); console.log("\n" + chalk.blue("═".repeat(60))); console.log(chalk.bold.cyan("🚀 Generated PR Description (Dry Run)")); console.log(chalk.blue("═".repeat(60))); console.log("\n" + description + "\n"); console.log(chalk.blue("═".repeat(60))); } else { // Pure output for piping to gh console.log(description); } } catch (error) { spinner.fail(`Error: ${error instanceof Error ? error.message : "Unknown error"}`); process.exit(1); } }); program .command("init") .description("Start an interactive wizard to configure pr-desc") .action(async () => { console.log(chalk.bold.cyan("\n✨ Welcome to the pr-desc setup wizard! ✨\n")); console.log("Let's configure your preferences for generating PR descriptions.\n"); const currentConfig = loadConfig(true); const defaultProvider = await select({ message: "Which AI provider would you like to use by default?", choices: Object.keys(SUPPORTED_MODELS).map((key) => ({ value: key, name: key, })), default: currentConfig.defaultProvider || "groq", }); let groqApiKey; if (defaultProvider === "groq") { // If we already have a key, set default if (getApiKey("groq")) { groqApiKey = await input({ message: "Enter your Groq API Key (leave blank to skip):", default: maskApiKey(getApiKey("groq")), }); } else { groqApiKey = await password({ message: "Enter your Groq API Key (leave blank to skip):", }); } } const defaultTemplate = await select({ message: "Which PR description template style do you prefer by default?", choices: ["standard", "detailed", "minimal"].map((t) => ({ value: t, name: t, })), default: currentConfig.defaultTemplate || "standard", }); const defaultBaseBranch = await input({ message: "What is your default base branch (e.g., main, develop)?", default: currentConfig.defaultBaseBranch || "main", }); currentConfig.defaultProvider = defaultProvider; currentConfig.defaultTemplate = defaultTemplate; currentConfig.defaultBaseBranch = defaultBaseBranch; saveConfig(currentConfig); // Save API keys if provided if (groqApiKey) { setApiKey("groq", groqApiKey); } console.log(chalk.green("\n✅ pr-desc configuration saved successfully!")); console.log(chalk.gray("You can always review your config with 'pr-desc config show'")); console.log(chalk.gray("And run 'pr-desc generate' to create your first PR description.")); }); program .command("models") .description("List available models for each provider") .option("-p, --provider <provider>", "Show models for specific provider") .action((options) => { if (options.provider) { try { const models = getSupportedModels(options.provider); console.log(chalk.bold.cyan(`Available models for ${options.provider}:`)); models.forEach((model) => { const isDefault = model === SUPPORTED_MODELS[options.provider] .default; console.log(` ${isDefault ? "✓" : " "} ${model}${isDefault ? " (default)" : ""}`); }); } catch (error) { console.error(chalk.red(`Error: ${error instanceof Error ? error.message : "Unknown error"}`)); } } else { console.log(chalk.bold.cyan("Available providers and their default models:\n")); Object.entries(SUPPORTED_MODELS).forEach(([provider, config]) => { console.log(chalk.bold(`${provider}:`)); console.log(` Default: ${config.default}`); console.log(` Options: ${config.options.length} models available`); console.log(); }); console.log(chalk.yellow("Use 'pr-desc models -p <provider>' to see all models for a provider")); } }); program .command("config") .description("Manage configuration and API keys") .argument("<action>", "Action to perform (set, get, show)") .argument("[provider]", "Provider name (groq, local)") .argument("[value]", "API key value (for set action)") .option("-u, --unmask", "Unmask the API key", false) .action((action, provider, value, options) => { const { unmask } = options; switch (action) { case "set": if (!provider || !value) { console.error(chalk.red("Usage: pr-desc config set <provider> <api-key>")); process.exit(1); } setApiKey(provider, value); break; case "get": if (!provider) { console.error(chalk.red("Usage: pr-desc config get <provider>")); process.exit(1); } const apiKey = getApiKey(provider); if (apiKey) { console.log(chalk.dim(`${provider}: ${unmask ? apiKey : maskApiKey(apiKey)}`)); } else { console.log(`${provider}: Not set`); } break; case "show": const config = loadConfig(Boolean(unmask)); console.log(chalk.bold.cyan("Current Configuration:")); console.log(JSON.stringify(config, null, 2)); break; default: console.error(chalk.red("Unknown action. Use: set, get, or show")); process.exit(1); } }); // Separate commit command program .command("commit") .description("Generate an AI conventional commit message (optionally commit immediately)") .option("-b, --base <branch>", "Base branch to compare against") .option("-p, --provider <provider>", "AI provider (groq, local)") .option("-m, --model <model>", "AI model to use") .option("--max-files <number>", "Maximum number of files to analyze", "20") .option("--type-hint <type>", "Hint commit type (feat, fix, chore, etc.)") .option("--no-stage", "Do not auto-stage all changes before generating the message") .option("--commit", "Automatically create the commit after confirmation", false) .action(async (options) => { const spinner = ora("Preparing commit context...").start(); try { const cfg = loadConfig(); options.provider = options.provider || cfg.defaultProvider || "groq"; options.base = options.base || cfg.defaultBaseBranch || "main"; options.model = options.model || SUPPORTED_MODELS[options.provider] .default; // Optionally stage all changes const status = await runGitCommand(["status", "--porcelain"]); if (!status.trim()) { spinner.fail("No changes to commit."); return; } if (options.stage !== false) { spinner.text = "Staging changes..."; await runGitCommand(["add", "."]); } spinner.text = "Analyzing changes..."; const changes = await getGitChanges(options.base, Number.parseInt(options.maxFiles || "20"), "staged"); spinner.text = "Generating commit message with AI..."; let message = await generateConventionalCommitMessage(changes, { provider: options.provider, model: options.model, typeHint: options.typeHint, maxFiles: Number.parseInt(options.maxFiles || "20"), }); spinner.succeed("Commit message generated."); let accept = false; while (!accept) { console.log("\n" + chalk.blue("═".repeat(60))); console.log(chalk.bold.cyan("🤖 Suggested Conventional Commit")); console.log(chalk.blue("═".repeat(60))); console.log("\n" + chalk.bold(message) + "\n"); console.log(chalk.blue("═".repeat(60))); accept = await confirm({ message: "Use this commit message?", default: true, }); if (!accept) { const action = await select({ message: "What next?", choices: [ { name: "Regenerate", value: "regen" }, { name: "Edit manually", value: "edit" }, { name: "Cancel", value: "cancel" }, ], }); if (action === "regen") { spinner.start("Re-generating commit message..."); message = await generateConventionalCommitMessage(changes, { provider: options.provider, model: options.model, typeHint: options.typeHint, maxFiles: Number.parseInt(options.maxFiles || "20"), refineFrom: message, }); spinner.succeed("New commit message generated."); continue; } else if (action === "edit") { message = await input({ message: "Edit commit message:", default: message, }); accept = true; } else { console.log(chalk.yellow("Cancelled.")); return; } } } if (options.commit || (await confirm({ message: "Create commit now?", default: true }))) { spinner.start("Creating commit..."); try { await runGitCommand(["commit", "-m", message]); spinner.succeed("Commit created."); } catch (err) { spinner.fail(`Failed to commit: ${err instanceof Error ? err.message : err}`); return; } } else { console.log(message); // allow piping } } catch (err) { spinner.fail(`Error: ${err instanceof Error ? err.message : "Unknown error"}`); process.exit(1); } }); program.parse();