UNPKG

git-pr-ai

Version:

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

585 lines (579 loc) 18.1 kB
#!/usr/bin/env node import ora from "ora"; import { $ } from "zx"; import fs from "fs/promises"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; //#region src/providers/github.ts var GitHubProvider = class { name = "GitHub"; async checkCLI() { try { await $`gh --version`.quiet(); } catch { console.error("❌ Please install GitHub CLI (gh) first"); console.error("Installation: https://cli.github.com/"); process.exit(1); } try { const result = await $`gh auth status`.quiet(); if (!result.stdout.includes("✓ Logged in to")) throw new Error("No authenticated accounts found"); } catch { try { await $`gh api user`.quiet(); } catch { console.error("❌ Please authenticate with GitHub CLI first"); console.error("Run: gh auth login"); process.exit(1); } } } async getDefaultBranch() { try { const result = await $`gh repo view --json defaultBranchRef`; const json = JSON.parse(result.stdout); if (json.defaultBranchRef && json.defaultBranchRef.name) return json.defaultBranchRef.name; return "main"; } catch { console.warn("⚠️ Could not determine default branch via gh, falling back to 'main'"); return "main"; } } async checkExistingPR() { try { const currentBranch = await $`git rev-parse --abbrev-ref HEAD`; const branchName = currentBranch.stdout.trim(); const result = await $`gh pr list --state open --head ${branchName} --json url --limit 1`; const prs = JSON.parse(result.stdout); if (prs.length > 0) return prs[0].url; return null; } catch { return null; } } async openPR() { const spinner = ora("Opening existing Pull Request...").start(); await $`gh pr view --web`; const result = await $`gh pr view --json url`; const { url } = JSON.parse(result.stdout); spinner.succeed(`Opened PR: ${url}`); } async createPR(title, branch, baseBranch) { const spinner = ora("Creating Pull Request...").start(); await $`gh pr create --title ${title} --base ${baseBranch} --head ${branch} --web`; spinner.succeed("Pull Request created successfully!"); } async updatePRDescription(prNumber) { const spinner = ora("Updating PR description...").start(); try { if (prNumber) await $`gh pr edit ${prNumber} --body-file -`; else await $`gh pr edit --body-file -`; spinner.succeed("PR description updated successfully!"); } catch (error) { spinner.fail("Failed to update PR description"); throw error; } } async reviewPR(prNumber, options) { const spinner = ora("Submitting PR review...").start(); try { let cmd = `gh pr review ${prNumber}`; if (options.approve) cmd += " --approve"; else if (options.requestChanges) cmd += " --request-changes"; else cmd += " --comment"; if (options.comment) cmd += ` --body "${options.comment}"`; await $`${cmd}`.quiet(); spinner.succeed("PR review submitted successfully!"); } catch (error) { spinner.fail("Failed to submit PR review"); throw error; } } async listPRs() { try { const result = await $`gh pr list --json number,title,url,state,author`; const prs = JSON.parse(result.stdout); return prs.map((pr) => ({ number: pr.number.toString(), title: pr.title, url: pr.url, state: pr.state.toLowerCase(), author: pr.author.login })); } catch { return []; } } async getPRDetails(prNumberOrUrl) { let prNumber = prNumberOrUrl; if (prNumberOrUrl && prNumberOrUrl.startsWith("http")) { const githubPattern = /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/; const match = prNumberOrUrl.match(githubPattern); if (!match) throw new Error("Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123"); prNumber = match[1]; } const prResult = prNumber ? await $`gh pr view ${prNumber} --json number,title,url,baseRefName,headRefName,state,author` : await $`gh pr view --json number,title,url,baseRefName,headRefName,state,author`; const repoResult = await $`gh repo view --json owner,name`; const prData = JSON.parse(prResult.stdout); const repoData = JSON.parse(repoResult.stdout); return { number: prData.number.toString(), title: prData.title, url: prData.url, baseBranch: prData.baseRefName, headBranch: prData.headRefName, owner: repoData.owner.login, repo: repoData.name, state: prData.state.toLowerCase(), author: prData.author.login }; } async getPRDiff(prNumber) { const cmd = prNumber ? `gh pr diff ${prNumber}` : `gh pr diff`; const result = await $`${cmd}`; return result.stdout; } async findPRTemplate() { const possiblePaths = [ ".github/pull_request_template.md", ".github/PULL_REQUEST_TEMPLATE.md", ".github/pull_request_template/default.md" ]; for (const templatePath of possiblePaths) try { const content = await fs.readFile(templatePath, "utf-8"); return { exists: true, content, path: templatePath }; } catch {} return { exists: false }; } async postComment(content, prNumber) { const tempFile = "temp_comment.md"; await fs.writeFile(tempFile, content); try { const cmd = prNumber ? `gh pr comment ${prNumber} --body-file ${tempFile}` : `gh pr comment --body-file ${tempFile}`; await $`${cmd}`; } finally { try { await fs.unlink(tempFile); } catch {} } } async updateDescription(content, prNumber) { const tempFile = "temp_description.md"; await fs.writeFile(tempFile, content); try { const cmd = prNumber ? `gh pr edit ${prNumber} --body-file ${tempFile}` : `gh pr edit --body-file ${tempFile}`; await $`${cmd}`; } finally { try { await fs.unlink(tempFile); } catch {} } } async getCurrentBranchPR() { try { const prUrl = await this.checkExistingPR(); if (!prUrl) return null; return await this.getPRDetails(); } catch { return null; } } async getIssue(issueNumber) { try { const result = await $`gh issue view ${issueNumber} --json number,title,body,labels,assignees,milestone`; const issue = JSON.parse(result.stdout); return { number: issue.number, title: issue.title, body: issue.body || "", labels: issue.labels?.map((label) => label.name) || [], assignee: issue.assignees?.[0]?.login, milestone: issue.milestone?.title }; } catch { throw new Error(`Could not fetch issue #${issueNumber}. Make sure it exists and you have access to it.`); } } async updateIssue(issueNumber, title, body) { const spinner = ora("Updating issue...").start(); try { const args = [ "gh", "issue", "edit", issueNumber.toString() ]; if (title) args.push("--title", title); if (body) args.push("--body", body); await $`${args}`; spinner.succeed(`Updated issue #${issueNumber}`); } catch (error) { spinner.fail("Failed to update issue"); throw new Error(`Could not update issue: ${error instanceof Error ? error.message : String(error)}`); } } async addIssueComment(issueNumber, comment) { const spinner = ora("Adding comment to issue...").start(); try { await $`gh issue comment ${issueNumber} --body ${comment}`; spinner.succeed(`Added comment to issue #${issueNumber}`); } catch (error) { spinner.fail("Failed to add comment"); throw new Error(`Could not add comment: ${error instanceof Error ? error.message : String(error)}`); } } async createIssue(title, body, labels) { const spinner = ora("Creating new issue...").start(); try { const args = [ "gh", "issue", "create", "--title", title, "--body", body ]; if (labels && labels.length > 0) args.push("--label", labels.join(",")); const result = await $`${args}`; spinner.succeed("New issue created successfully"); console.log(result.stdout.trim()); } catch (error) { spinner.fail("Failed to create issue"); throw new Error(`Could not create issue: ${error instanceof Error ? error.message : String(error)}`); } } }; //#endregion //#region src/providers/gitlab.ts var GitLabProvider = class { name = "GitLab"; async checkCLI() { try { await $`glab --version`.quiet(); } catch { console.error("❌ Please install GitLab CLI (glab) first"); console.error("Installation: https://gitlab.com/gitlab-org/cli"); process.exit(1); } try { await $`glab auth status`.quiet(); } catch { console.error("❌ Please authenticate with GitLab CLI first"); console.error("Run: glab auth login"); process.exit(1); } } async getDefaultBranch() { try { const result = await $`glab repo view -F json`; const json = JSON.parse(result.stdout); return json.default_branch || "main"; } catch { console.warn("⚠️ Could not determine default branch via glab, falling back to 'main'"); return "main"; } } async checkExistingPR() { try { const currentBranch = await $`git rev-parse --abbrev-ref HEAD`; const branchName = currentBranch.stdout.trim(); const result = await $`glab mr list -s opened --source-branch ${branchName} -F json | head -1`; const output = result.stdout.trim(); if (!output || output === "[]") return null; let mrs; try { mrs = JSON.parse(output); } catch { return null; } const mr = Array.isArray(mrs) ? mrs[0] : mrs; if (mr && mr.web_url) return mr.web_url; return null; } catch { return null; } } async openPR() { const spinner = ora("Opening existing Merge Request...").start(); await $`glab mr view --web`; const result = await $`glab mr view -F json`; const json = JSON.parse(result.stdout); spinner.succeed(`Opened MR: ${json.web_url}`); } async createPR(title, branch, baseBranch) { const spinner = ora("Creating Merge Request...").start(); await $`glab mr create --title ${title} --target-branch ${baseBranch} --source-branch ${branch} --description "" --web`; spinner.succeed("Merge Request created successfully!"); } async updatePRDescription(prNumber) { const spinner = ora("Updating MR description...").start(); try { if (prNumber) await $`glab mr update ${prNumber} --description-file -`; else await $`glab mr update --description-file -`; spinner.succeed("MR description updated successfully!"); } catch (error) { spinner.fail("Failed to update MR description"); throw error; } } async reviewPR(prNumber, options) { const spinner = ora("Submitting MR review...").start(); try { let cmd = `glab mr review ${prNumber}`; if (options.approve) cmd = `glab mr approve ${prNumber}`; else if (options.requestChanges) cmd = `glab mr unapprove ${prNumber}`; if (options.comment) await $`glab mr note ${prNumber} --message ${options.comment}`; if (cmd.includes("review") || cmd.includes("approve") || cmd.includes("unapprove")) await $`${cmd}`.quiet(); spinner.succeed("MR review submitted successfully!"); } catch (error) { spinner.fail("Failed to submit MR review"); throw error; } } async listPRs() { try { const result = await $`glab mr list -F json`; const mrs = JSON.parse(result.stdout); return mrs.map((mr) => ({ number: mr.iid.toString(), title: mr.title, url: mr.web_url, state: mr.state.toLowerCase(), author: mr.author.username })); } catch { return []; } } async getPRDetails() { const mrResult = await $`glab mr view -F json`; const repoResult = await $`glab repo view -F json`; const mrData = JSON.parse(mrResult.stdout); const repoData = JSON.parse(repoResult.stdout); return { number: mrData.iid.toString(), title: mrData.title, url: mrData.web_url, baseBranch: mrData.target_branch, headBranch: mrData.source_branch, owner: repoData.owner?.username || repoData.namespace?.path, repo: repoData.name, state: mrData.state.toLowerCase(), author: mrData.author.username }; } async getPRDiff(prNumber) { const cmd = prNumber ? `glab mr diff ${prNumber}` : `glab mr diff`; const result = await $`${cmd}`; return result.stdout; } async findPRTemplate() { const possiblePaths = [ ".gitlab/merge_request_templates/default.md", ".gitlab/merge_request_templates/Default.md", ".gitlab/merge_request_templates/merge_request_template.md" ]; for (const templatePath of possiblePaths) try { const content = await fs.readFile(templatePath, "utf-8"); return { exists: true, content, path: templatePath }; } catch {} return { exists: false }; } async postComment(content, prNumber) { const tempFile = "temp_comment.md"; await fs.writeFile(tempFile, content); try { const cmd = prNumber ? `glab mr note ${prNumber} --message-file ${tempFile}` : `glab mr note --message-file ${tempFile}`; await $`${cmd}`; } finally { try { await fs.unlink(tempFile); } catch {} } } async updateDescription(content, prNumber) { const tempFile = "temp_description.md"; await fs.writeFile(tempFile, content); try { const cmd = prNumber ? `glab mr update ${prNumber} --description-file ${tempFile}` : `glab mr update --description-file ${tempFile}`; await $`${cmd}`; } finally { try { await fs.unlink(tempFile); } catch {} } } async getCurrentBranchPR() { try { const mrUrl = await this.checkExistingPR(); if (!mrUrl) return null; return await this.getPRDetails(); } catch { return null; } } async getIssue(issueNumber) { try { const result = await $`glab issue view ${issueNumber} -F json`; const issue = JSON.parse(result.stdout); return { number: issue.iid, title: issue.title, body: issue.description || "", labels: issue.labels || [], assignee: issue.assignee?.username, milestone: issue.milestone?.title }; } catch { throw new Error(`Could not fetch issue #${issueNumber}. Make sure it exists and you have access to it.`); } } async updateIssue(issueNumber, title, body) { const spinner = ora("Updating issue...").start(); try { const args = [ "glab", "issue", "update", issueNumber.toString() ]; if (title) args.push("--title", title); if (body) args.push("--description", body); await $`${args}`; spinner.succeed(`Updated issue #${issueNumber}`); } catch (error) { spinner.fail("Failed to update issue"); throw new Error(`Could not update issue: ${error instanceof Error ? error.message : String(error)}`); } } async addIssueComment(issueNumber, comment) { const spinner = ora("Adding comment to issue...").start(); try { await $`glab issue note ${issueNumber} --message ${comment}`; spinner.succeed(`Added comment to issue #${issueNumber}`); } catch (error) { spinner.fail("Failed to add comment"); throw new Error(`Could not add comment: ${error instanceof Error ? error.message : String(error)}`); } } async createIssue(title, body, labels) { const spinner = ora("Creating new issue...").start(); try { const args = [ "glab", "issue", "create", "--title", title, "--description", body ]; if (labels && labels.length > 0) args.push("--label", labels.join(",")); const result = await $`${args}`; spinner.succeed("New issue created successfully"); console.log(result.stdout.trim()); } catch (error) { spinner.fail("Failed to create issue"); throw new Error(`Could not create issue: ${error instanceof Error ? error.message : String(error)}`); } } }; //#endregion //#region src/providers/factory.ts let cachedProviderType = null; let cachedProvider = null; async function detectProvider() { if (cachedProviderType) return cachedProviderType; try { const result = await $`git remote get-url origin`; const remoteUrl = result.stdout.trim().toLowerCase(); if (remoteUrl.includes("gitlab.com") || remoteUrl.includes("gitlab") || remoteUrl.match(/gitlab\./)) cachedProviderType = "gitlab"; else cachedProviderType = "github"; return cachedProviderType; } catch { throw new Error("Failed to detect provider"); } } async function getCurrentProvider(type) { const providerType = type || await detectProvider(); if (cachedProvider && cachedProviderType === providerType) return cachedProvider; switch (providerType) { case "gitlab": cachedProvider = new GitLabProvider(); break; case "github": default: cachedProvider = new GitHubProvider(); break; } cachedProviderType = providerType; return cachedProvider; } //#endregion //#region src/git-helpers.ts async function getCurrentBranch() { const result = await $`git branch --show-current`; return result.stdout.trim(); } async function getDefaultBranch() { const provider = await getCurrentProvider(); return provider.getDefaultBranch(); } async function checkGitCLI() { const provider = await getCurrentProvider(); return provider.checkCLI(); } const SUPPORTED_LANGUAGES = { en: "English", "zh-TW": "繁體中文 (Traditional Chinese)" }; const DEFAULT_LANGUAGE = "en"; async function getLanguage() { try { const result = await $`git config pr-ai.language`; const language = result.stdout.trim(); return Object.keys(SUPPORTED_LANGUAGES).includes(language) ? language : DEFAULT_LANGUAGE; } catch { return DEFAULT_LANGUAGE; } } async function setLanguage(language) { await $`git config pr-ai.language ${language}`; } //#endregion //#region src/config.ts const DEFAULT_CONFIG = { agent: "claude" }; const CONFIG_FILENAME = ".git-pr-ai.json"; function getConfigPath() { return join(homedir(), ".git-pr-ai", CONFIG_FILENAME); } function getConfigDir() { return join(homedir(), ".git-pr-ai"); } async function loadConfig() { const configPath = getConfigPath(); let config = { ...DEFAULT_CONFIG }; if (existsSync(configPath)) try { const configContent = readFileSync(configPath, "utf8"); const fileConfig = JSON.parse(configContent); config = { ...config, ...fileConfig }; } catch { console.warn("⚠️ Failed to parse .git-pr-ai.json, using default configuration"); } return config; } //#endregion export { SUPPORTED_LANGUAGES, checkGitCLI, getConfigDir, getConfigPath, getCurrentBranch, getCurrentProvider, getDefaultBranch, getLanguage, loadConfig, setLanguage };