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