UNPKG

git-pr-ai

Version:

A tool to automatically extract JIRA ticket numbers from branch names and create GitHub/GitLab PRs

310 lines (285 loc) 11.9 kB
#!/usr/bin/env node import { checkGitCLI, getCurrentBranch, getDefaultBranch, loadConfig } from "./config-BqoKKiEi.js"; import { getJiraTicketTitle } from "./jira-C8iK4gDe.js"; import { executeAIWithOutput } from "./executor-BD-XEHc7.js"; import { Command } from "commander"; import ora from "ora"; import { $ } from "zx"; import { confirm } from "@inquirer/prompts"; //#region src/cli/create-branch/prompts.ts const createJiraBranchPrompt = (jiraTicket, jiraTitle) => `Based on the following JIRA ticket information, generate a git branch name: JIRA Ticket: ${jiraTicket} JIRA Title: ${jiraTitle || "Not available"} Please analyze the ticket and provide: 1. An appropriate branch type prefix following commitlint conventional types: - feat: new features - fix: bug fixes - docs: documentation changes - style: formatting changes - refactor: code refactoring - perf: performance improvements - test: adding/updating tests - chore: maintenance tasks - ci: CI/CD changes - build: build system changes 2. A descriptive branch name following the format: {prefix}/{ticket-id}-{description} Requirements: - Use kebab-case for the description - Keep the description concise but meaningful (max 30 characters) - Use only lowercase letters, numbers, and hyphens for the description part - IMPORTANT: Keep the ticket-id exactly as provided (do not convert to lowercase) - Choose the branch type based on the ticket content - Prefer 'feat' over 'feature' and 'fix' over 'bugfix' to align with commitlint Please respond with exactly this format: BRANCH_NAME: {your_generated_branch_name} Example: BRANCH_NAME: feat/PROJ-123-add-user-auth`; const createCustomBranchPrompt = (customPrompt) => `Based on the following prompt, generate a git branch name: ${customPrompt} Please analyze the request and provide: 1. An appropriate branch type prefix following commitlint conventional types: - feat: new features - fix: bug fixes - docs: documentation changes - style: formatting changes - refactor: code refactoring - perf: performance improvements - test: adding/updating tests - chore: maintenance tasks - ci: CI/CD changes - build: build system changes 2. A descriptive branch name following the format: {prefix}/{description} Requirements: - Use kebab-case for the description - Keep the description concise but meaningful (max 40 characters) - Use only lowercase letters, numbers, and hyphens - Choose the branch type based on the prompt content - Generate a description that captures the essence of the request Please respond with exactly this format: BRANCH_NAME: {your_generated_branch_name} Example: BRANCH_NAME: feat/add-user-authentication`; const createDiffBranchPrompt = (gitDiff) => `Based on the following git diff, generate a git branch name: ${gitDiff} Please analyze the changes and provide: 1. First, check for any JIRA ticket IDs in the diff content (format: PROJECT-123, KB2CW-456, etc.) 2. An appropriate branch type prefix following commitlint conventional types: - feat: new features - fix: bug fixes - docs: documentation changes - style: formatting changes - refactor: code refactoring - perf: performance improvements - test: adding/updating tests - chore: maintenance tasks - ci: CI/CD changes - build: build system changes 3. A descriptive branch name following the format: - If JIRA ticket found: {prefix}/{ticket-id}-{description} - If no JIRA ticket: {prefix}/{description} Requirements: - Look for JIRA ticket IDs in commit messages, comments, or file paths - If found, include the EXACT ticket ID in the branch name (preserve case) - Use kebab-case for the description - Keep the description concise but meaningful (max 30 characters if ticket included, 40 if not) - Use only lowercase letters, numbers, and hyphens for the description part - Choose the branch type based on the changes shown in the diff - Generate a description that captures the essence of the changes Please respond with exactly this format: BRANCH_NAME: {your_generated_branch_name} Examples: BRANCH_NAME: feat/KB2CW-123-add-user-auth BRANCH_NAME: fix/update-validation-logic`; //#endregion //#region src/cli/create-branch/create-branch.ts async function createBranch(branchName, baseBranch) { console.log(`Creating branch: ${branchName}`); console.log(`Base branch: ${baseBranch}`); try { await $`git show-ref --verify --quiet refs/heads/${branchName}`; console.log(`Branch '${branchName}' already exists`); const switchToExisting = await confirm({ message: `Do you want to switch to the existing branch '${branchName}'?`, default: true }); if (switchToExisting) { await $`git checkout ${branchName}`; console.log(`Switched to existing branch: ${branchName}`); return; } else { console.log("Branch creation cancelled"); process.exit(0); } } catch {} await $`git checkout -b ${branchName} ${baseBranch}`; console.log(`Created and switched to branch: ${branchName}`); } async function moveBranch(currentBranch, newBranchName) { try { await $`git show-ref --verify --quiet refs/heads/${newBranchName}`; console.error(`Branch '${newBranchName}' already exists`); const overwrite = await confirm({ message: `Branch '${newBranchName}' already exists. Overwrite it?`, default: false }); if (!overwrite) { console.log("Branch rename cancelled"); process.exit(0); } else { await $`git branch -M ${newBranchName}`; console.log(`Force renamed branch to: ${newBranchName}`); } } catch { await $`git branch -m ${newBranchName}`; console.log(`Renamed branch to: ${newBranchName}`); } } async function generateBranchNameWithAI(prompt) { const config = await loadConfig(); const spinner = ora(`Using ${config.agent.toUpperCase()} to generate branch name...`).start(); try { const aiOutput = await executeAIWithOutput(prompt); const branchMatch = aiOutput.match(/BRANCH_NAME:\s*(.+)/i); if (branchMatch) { const aiBranchName = branchMatch[1].trim(); spinner.succeed("Branch name generated successfully!"); const confirmAI = await confirm({ message: `Use AI suggestion: ${aiBranchName}?`, default: true }); if (confirmAI) return aiBranchName; else { console.log("Branch creation cancelled"); process.exit(0); } } else { spinner.fail("Could not parse AI output"); process.exit(1); } } catch (error) { spinner.fail(`AI generation failed: ${error}`); process.exit(1); } } async function generateBranchName(jiraTicket, jiraTitle) { const prompt = createJiraBranchPrompt(jiraTicket, jiraTitle); return generateBranchNameWithAI(prompt); } async function generateBranchNameFromPrompt(customPrompt) { const prompt = createCustomBranchPrompt(customPrompt); return generateBranchNameWithAI(prompt); } async function generateBranchNameFromDiff() { const diffSpinner = ora("Analyzing git diff...").start(); let gitDiff; try { const result = await $`git diff HEAD`; gitDiff = result.stdout.trim(); if (!gitDiff) { diffSpinner.warn("No changes detected in git diff against HEAD"); const defaultBranch = await getDefaultBranch(); const currentBranch = await getCurrentBranch(); if (currentBranch === defaultBranch) { diffSpinner.fail("No changes detected and already on default branch"); process.exit(1); } diffSpinner.text = `Comparing with default branch: ${defaultBranch}`; const fallbackResult = await $`git diff ${defaultBranch}...HEAD`; gitDiff = fallbackResult.stdout.trim(); if (!gitDiff) { diffSpinner.fail("No changes detected even when comparing with default branch"); process.exit(1); } diffSpinner.succeed("Found changes when comparing with default branch"); } else diffSpinner.succeed("Git diff analysis completed"); } catch { diffSpinner.fail("Failed to get git diff"); process.exit(1); } const prompt = createDiffBranchPrompt(gitDiff); return generateBranchNameWithAI(prompt); } function setupCommander() { const program = new Command(); program.name("git-create-branch").description("Create a new git branch based on JIRA ticket information, git diff, or custom prompt").option("-j, --jira <ticket>", "specify JIRA ticket ID").option("-g, --git-diff", "generate branch name based on current git diff").option("-p, --prompt <prompt>", "generate branch name based on custom prompt").option("-m, --move", "rename current branch instead of creating a new one").addHelpText("after", ` Examples: $ git create-branch --jira PROJ-123 Create a branch named: feat/PROJ-123-add-login-page $ git create-branch --git-diff Create a branch named: fix/update-user-validation (Based on current git diff changes) $ git create-branch --prompt "Add user authentication system" Create a branch named: feat/add-user-auth-system (Based on custom prompt) $ git create-branch --jira PROJ-123 --move Rename current branch to: feat/PROJ-123-add-login-page $ git create-branch --git-diff --move Rename current branch to: fix/update-user-validation (Based on current git diff changes) $ git create-branch --prompt "Fix memory leak in cache" --move Rename current branch to: fix/memory-leak-cache (Based on custom prompt) Features: - Three modes: JIRA ticket-based, git diff-based, or custom prompt-based branch naming - Create new branches or rename existing ones (--move) - Automatically fetches JIRA ticket title (JIRA mode) - AI-powered branch type detection (feat, fix, docs, etc.) following commitlint conventions - Uses current branch as base branch (simple and intuitive) - Creates descriptive branch names based on ticket title or code changes - Handles existing branches gracefully - No manual configuration needed Prerequisites: - Git provider CLI must be installed and authenticated: GitHub CLI (gh) or GitLab CLI (glab) - For JIRA integration: Configure JIRA credentials in ~/.git-pr-ai/.git-pr-ai.json `); return program; } async function main() { const program = setupCommander(); program.action(async (options) => { try { await checkGitCLI(); const optionCount = [ options.jira, options.gitDiff, options.prompt ].filter(Boolean).length; if (optionCount === 0) { console.error("One of the following options is required: --jira, --git-diff, or --prompt"); console.error("Usage: git create-branch --jira PROJ-123"); console.error(" or: git create-branch --git-diff"); console.error(" or: git create-branch --prompt \"description\""); process.exit(1); } if (optionCount > 1) { console.error("Only one option can be used at a time: --jira, --git-diff, or --prompt"); process.exit(1); } const currentBranch = await getCurrentBranch(); console.log(`Current branch: ${currentBranch}`); let branchName; if (options.gitDiff) branchName = await generateBranchNameFromDiff(); else if (options.prompt) { console.log(`💭 Custom prompt: ${options.prompt}`); branchName = await generateBranchNameFromPrompt(options.prompt); } else if (options.jira) { const jiraTicket = options.jira; console.log(`JIRA Ticket: ${jiraTicket}`); const jiraSpinner = ora("Fetching JIRA ticket title...").start(); const jiraTitle = await getJiraTicketTitle(jiraTicket); if (jiraTitle) jiraSpinner.succeed(`JIRA Title: ${jiraTitle}`); else jiraSpinner.warn("Could not fetch JIRA title, using ticket ID only"); branchName = await generateBranchName(jiraTicket, jiraTitle); } else throw new Error("No valid option provided"); if (options.move) await moveBranch(currentBranch, branchName); else await createBranch(branchName, currentBranch); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error("Error:", errorMessage); process.exit(1); } }); program.parse(); } main(); //#endregion