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
JavaScript
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