pr-desc-cli
Version:
AI-powered PR description generator
277 lines (276 loc) • 9.9 kB
JavaScript
import { spawn } from "child_process";
import simpleGit from "simple-git";
import { GhError, GhNeedsPushError } from "./types.js";
const git = simpleGit();
export async function getGitChanges(baseBranch, maxFiles, mode = "branch") {
try {
await git.fetch();
const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
// mode base
let diffRangeArg = `${baseBranch}...HEAD`;
let log = await git.log({
from: baseBranch,
to: "HEAD",
maxCount: 10,
});
const files = [];
if (mode === "branch") {
const diffSummary = await git.diffSummary([diffRangeArg]);
const numstatArgs = ["diff", diffRangeArg, "--numstat"];
const numstatOutput = await git.raw(numstatArgs);
const numstatMap = {};
numstatOutput.split("\n").forEach((line) => {
if (!line.trim())
return;
const [add, del, path] = line.split("\t");
numstatMap[path] = {
additions: add === "-" ? 0 : parseInt(add, 10),
deletions: del === "-" ? 0 : parseInt(del, 10),
};
});
const diffFiles = diffSummary.files.slice(0, maxFiles);
for (const file of diffFiles) {
const stats = numstatMap[file.file] || { additions: 0, deletions: 0 };
let patch = null;
try {
patch = await git.diff([diffRangeArg, "--", file.file]);
}
catch {
patch = null;
}
files.push({
path: file.file,
status: getFileStatus(stats),
additions: stats.additions,
deletions: stats.deletions,
patch,
});
}
return {
baseBranch,
currentBranch,
files,
commits: (log?.all || []).map((commit) => ({
hash: commit.hash,
message: commit.message,
author: commit.author_name || "Unknown",
date: commit.date,
})),
stats: {
insertions: diffSummary.insertions,
deletions: diffSummary.deletions,
filesChanged: diffSummary.files.length,
},
mode,
};
}
// mode === "staged" -> merge branch + staged
const branchSummary = await git.diffSummary([diffRangeArg]);
const stagedSummary = await git.diffSummary(["--cached"]);
const branchNumstat = await git.raw(["diff", diffRangeArg, "--numstat"]);
const stagedNumstat = await git.raw(["diff", "--cached", "--numstat"]);
const branchMap = {};
branchNumstat.split("\n").forEach((line) => {
if (!line.trim())
return;
const [add, del, path] = line.split("\t");
branchMap[path] = {
additions: add === "-" ? 0 : parseInt(add, 10),
deletions: del === "-" ? 0 : parseInt(del, 10),
};
});
const stagedMap = {};
stagedNumstat.split("\n").forEach((line) => {
if (!line.trim())
return;
const [add, del, path] = line.split("\t");
stagedMap[path] = {
additions: add === "-" ? 0 : parseInt(add, 10),
deletions: del === "-" ? 0 : parseInt(del, 10),
};
});
const branchFiles = (branchSummary.files || []).map((f) => f.file);
const stagedFiles = (stagedSummary.files || []).map((f) => f.file);
const allFiles = Array.from(new Set([...branchFiles, ...stagedFiles])).slice(0, maxFiles);
for (const filePath of allFiles) {
const b = branchMap[filePath] || { additions: 0, deletions: 0 };
const s = stagedMap[filePath] || { additions: 0, deletions: 0 };
const combined = {
additions: (b.additions || 0) + (s.additions || 0),
deletions: (b.deletions || 0) + (s.deletions || 0),
};
let committedPatch = null;
let stagedPatch = null;
try {
if (branchFiles.includes(filePath)) {
committedPatch = await git.diff([diffRangeArg, "--", filePath]);
}
}
catch {
committedPatch = null;
}
try {
if (stagedFiles.includes(filePath)) {
stagedPatch = await git.diff(["--cached", "--", filePath]);
}
}
catch {
stagedPatch = null;
}
const parts = [];
if (committedPatch) {
parts.push("--- COMMITTED PATCH (base...HEAD) ---\n" + committedPatch);
}
if (stagedPatch) {
parts.push("--- STAGED PATCH (index vs HEAD) ---\n" + stagedPatch);
}
const patch = parts.length ? parts.join("\n\n") : null;
files.push({
path: filePath,
status: getFileStatus(combined),
additions: combined.additions,
deletions: combined.deletions,
patch,
});
}
const combinedInsertions = (branchSummary.insertions || 0) + (stagedSummary.insertions || 0);
const combinedDeletions = (branchSummary.deletions || 0) + (stagedSummary.deletions || 0);
const combinedFilesChanged = Array.from(new Set([...branchFiles, ...stagedFiles])).length;
return {
baseBranch,
currentBranch,
files,
commits: (log?.all || []).map((commit) => ({
hash: commit.hash,
message: commit.message,
author: commit.author_name || "Unknown",
date: commit.date,
})),
stats: {
insertions: combinedInsertions,
deletions: combinedDeletions,
filesChanged: combinedFilesChanged,
},
mode,
};
}
catch (error) {
throw new Error(`Failed to get git changes: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
function getFileStatus(file) {
if (file.additions > 0 && file.deletions === 0)
return "added";
if (file.additions === 0 && file.deletions > 0)
return "deleted";
if (file.additions > 0 && file.deletions > 0)
return "modified";
return "unknown";
}
// Using gh cli
export async function isGhCliInstalled() {
try {
await runGhCommand(["--version"]);
return true;
}
catch (error) {
return false;
}
}
export async function getPRForCurrentBranch(currentBranch) {
try {
const output = await runGhCommand([
"pr",
"list",
"--head",
currentBranch,
"--json",
"number,url",
]);
const prs = JSON.parse(output);
if (prs.length > 0) {
return prs[0];
}
return null;
}
catch (error) {
if (error instanceof Error &&
error.message.includes("Could not find a repository for")) {
throw new Error("Not a GitHub repository or no remote configured.");
}
return null;
}
}
export async function createPR(body) {
const args = ["pr", "create", "--fill", "--body-file", "-"];
return runGhCommand(args, body);
}
export async function updatePR(prNumber, body) {
const args = ["pr", "edit", String(prNumber), "--body-file", "-"];
await runGhCommand(args, body);
}
function runGhCommand(args, body) {
return new Promise((resolve, reject) => {
const gh = spawn("gh", args);
let stdout = "";
let stderr = "";
if (body) {
gh.stdin.write(body);
gh.stdin.end();
}
gh.stdout.on("data", (data) => {
stdout += data.toString();
});
gh.stderr.on("data", (data) => {
stderr += data.toString();
});
gh.on("close", (code) => {
if (code === 0) {
resolve(stdout.trim());
}
else {
if (stderr.includes("must first push the current branch")) {
reject(new GhNeedsPushError());
}
else {
reject(new GhError(`gh command failed with code ${code}: ${stderr}`));
}
}
});
gh.on("error", (err) => {
reject(err);
});
});
}
export function runGitCommand(args) {
return new Promise((resolve, reject) => {
const git = spawn("git", args);
let stdout = "";
let stderr = "";
git.stdout.on("data", (data) => {
stdout += data.toString();
});
git.stderr.on("data", (data) => {
stderr += data.toString();
});
git.on("close", (code) => {
if (code === 0) {
resolve(stdout.trim());
}
else {
reject(new Error(`git command failed with code ${code}: ${stderr}`));
}
});
git.on("error", (err) => {
reject(err);
});
});
}
export async function pushCurrentBranch(branchName) {
try {
await runGitCommand(["push", "--set-upstream", "origin", branchName]);
}
catch (error) {
throw new Error(`Failed to push current branch '${branchName}': ${error instanceof Error ? error.message : "Unknown error"}`);
}
}