Files
gai/index.ts
T
Mplan 6ff541284e feat(cli): add pull request creation with AI-generated messages (#2)
Add a new `gai pr` subcommand that generates pull request titles and descriptions using AI, then creates the PR via GitHub CLI (`gh`) or Gitea CLI (`tea`). This extends the existing commit-generation system by reusing retry logic and prompt infrastructure, and introduces a `callAI` function that returns raw output (instead of pre-cleaned messages) to support structured PR responses.

Reviewed-on: #2
2026-06-11 00:39:20 +08:00

858 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
import * as readline from "node:readline";
import { loadConfig, saveConfig } from "./src/config";
import {
isGitRepo,
getRepoRoot,
getStagedFiles,
getUnstagedFiles,
getStagedDiff,
getRecentCommits,
stageFiles,
commit,
} from "./src/git";
import { selectFiles } from "./src/selector";
import { collectProjectContext } from "./src/context";
import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt";
import { generateCommitMessage } from "./src/ai";
import { copyToClipboard } from "./src/clipboard";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "./src/terminal";
import type { Config } from "./src/types";
import {
getDefaultBranch,
getBranchName,
getBranchCommits,
getBranchDiff,
detectPlatform,
getRemoteHostname,
checkCLI,
checkAuth,
createPR,
} from "./src/pr";
import type { Platform } from "./src/pr";
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "./src/prompt";
import { generatePRMessage } from "./src/ai";
const args = process.argv.slice(2);
function showHelp() {
console.log(`
${BOLD}gai${RESET} — AI-powered git commit message generator
${BOLD}Usage:${RESET}
gai Open interactive menu
gai commit Generate commit message for staged/changed files
gai commit --auto Auto-stage all changed files
gai commit -d Generate message without committing
gai pr Create a PR with AI-generated title and body
gai pr --draft Create a draft PR
gai config Configure API settings
gai --help Show this help message
gai --version Show version
${BOLD}Configuration:${RESET}
Set via ${CYAN}gai config${RESET} or environment variables:
GAI_API_KEY OpenAI-compatible API key
GAI_API_BASE API base URL (default: https://api.deepseek.com/v1)
GAI_MODEL Model name (default: deepseek-chat)
GAI_MAX_TOKENS Max tokens (default: 500)
GAI_TEMPERATURE Temperature (default: 0.7)
`);
}
function ask(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
async function handleConfig() {
const config = await loadConfig();
console.log(`\n ${BOLD}Current configuration:${RESET}`);
console.log(
` API Key: ${config.apiKey ? "****" + config.apiKey.slice(-4) : `${YELLOW}(not set)${RESET}`}`,
);
console.log(` API Base: ${config.apiBase}`);
console.log(` Model: ${config.model}`);
console.log(` Max Tokens: ${config.maxTokens}`);
console.log(` Temperature: ${config.temperature}`);
console.log(
`\n ${BOLD}Enter new values (leave empty to keep current):${RESET}`,
);
const apiKey = await ask(" API Key: ");
const apiBase = await ask(` API Base [${config.apiBase}]: `);
const model = await ask(` Model [${config.model}]: `);
const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `);
const temperature = await ask(` Temperature [${config.temperature}]: `);
const updates: Partial<Config> = {};
if (apiKey) updates.apiKey = apiKey;
if (apiBase) updates.apiBase = apiBase;
if (model) updates.model = model;
if (maxTokens) updates.maxTokens = parseInt(maxTokens);
if (temperature) updates.temperature = parseFloat(temperature);
if (Object.keys(updates).length > 0) {
await saveConfig(updates);
console.log(`\n ${GREEN}Configuration saved!${RESET}`);
} else {
console.log("\n No changes.");
}
}
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
console.log(`\n ${BOLD}Generated commit message:${RESET}`);
console.log(` ${GREEN}${message}${RESET}\n`);
const answer = await ask(` Use this message? [${GREEN}Y${RESET}/n/e] `);
const lower = answer.toLowerCase();
if (lower === "n") return "n";
if (lower === "e") return "e";
return "y";
}
async function editMessage(current: string): Promise<string | null> {
if (!process.stdin.isTTY) return null;
process.stdout.write(` ${DIM}Edit message (Enter to confirm, Esc to abort):${RESET}\n`);
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
let buffer = current;
let cursor = current.length;
const ESC = "\x1b";
const ENTER = "\r";
const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
function render() {
process.stdout.write("\x1b[2K\r > " + buffer);
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
}
}
process.stdout.write(" > ");
process.stdout.write(buffer);
return new Promise((resolve) => {
let escapeBuf = "";
function handleSeq(seq: string) {
if (seq === "\x1b[D" || seq === "\x1bOD") {
if (cursor > 0) {
cursor--;
process.stdout.write("\x1b[D");
}
} else if (seq === "\x1b[C" || seq === "\x1bOC") {
if (cursor < buffer.length) {
cursor++;
process.stdout.write("\x1b[C");
}
} else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") {
if (cursor > 0) {
process.stdout.write(`\x1b[${cursor}D`);
cursor = 0;
}
} else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") {
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}C`);
cursor = buffer.length;
}
} else if (seq === "\x1b[3~") {
if (cursor < buffer.length) {
buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
render();
}
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === CTRL_C) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
resolve(null);
return;
}
if (key === ESC || key.startsWith("\x1b[")) {
escapeBuf = key;
if (key.length >= 3) {
handleSeq(key);
escapeBuf = "";
}
return;
}
if (escapeBuf) {
escapeBuf += key;
if (/^[A-Za-z~]$/.test(key)) {
handleSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 8) {
escapeBuf = "";
}
return;
}
if (key === ENTER) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
const result = buffer.trim();
resolve(result || null);
return;
}
if (key === BACKSPACE) {
if (cursor > 0) {
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
cursor--;
render();
}
return;
}
if (key === "\x01") {
if (cursor > 0) {
process.stdout.write(`\x1b[${cursor}D`);
cursor = 0;
}
return;
}
if (key === "\x05") {
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}C`);
cursor = buffer.length;
}
return;
}
if (key === "\x0b") {
buffer = buffer.slice(0, cursor);
render();
return;
}
if (key === "\x15") {
buffer = buffer.slice(cursor);
cursor = 0;
render();
return;
}
if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
cursor++;
render();
}
});
});
}
function printCommitResult(
result: { branch: string; hash: string; files: number; insertions: number; deletions: number },
msg: string,
) {
console.log(`\n ${GREEN}${BOLD}✔ Committed successfully!${RESET}`);
const id = result.branch && result.hash
? `${YELLOW}[${result.branch} ${result.hash}]${RESET}`
: result.hash
? `${YELLOW}${result.hash}${RESET}`
: "";
console.log(` ${id} ${msg}`);
const parts: string[] = [];
if (result.files > 0) parts.push(`${YELLOW}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET}`);
if (result.insertions > 0) parts.push(`${GREEN}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET}`);
if (result.deletions > 0) parts.push(`${RED}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET}`);
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
}
interface MenuAction {
key: string;
label: string;
description: string;
}
const MENU_ACTIONS: MenuAction[] = [
{ key: "commit", label: "commit", description: "Generate AI commit message" },
{ key: "pr", label: "pr", description: "Create a PR with AI-generated title" },
{ key: "config", label: "config", description: "Configure API settings" },
];
async function showMenu(): Promise<void> {
const actions = MENU_ACTIONS;
let cursor = 0;
const headerLines = 4;
process.stdout.write(`\n ${BOLD}gai${RESET}\n`);
process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`);
const totalLines = headerLines + actions.length;
function render() {
for (let i = 0; i < actions.length; i++) {
process.stdout.write("\x1b[2K\r");
const a = actions[i]!;
const pointer = i === cursor ? `${CYAN}${RESET} ` : " ";
const dot = i === cursor ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const name = i === cursor ? `${BOLD}${a.label}${RESET}` : a.label;
const desc = i === cursor ? a.description : `${DIM}${a.description}${RESET}`;
process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 14 - a.label.length))}${desc}\n`);
}
process.stdout.write(`\x1b[${actions.length}A`);
}
function clearMenu() {
process.stdout.write(`\x1b[${headerLines}A`);
for (let i = 0; i < totalLines; i++) {
process.stdout.write("\r\x1b[2K\n");
}
process.stdout.write(`\x1b[${totalLines}A`);
}
if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`);
process.exit(1);
}
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?25l");
render();
return new Promise((resolve) => {
let escapeBuf = "";
function handleSeq(seq: string) {
if (seq === "\x1b[A" || seq === "\x1bOA") {
if (cursor > 0) {
cursor--;
render();
}
} else if (seq === "\x1b[B" || seq === "\x1bOB") {
if (cursor < actions.length - 1) {
cursor++;
render();
}
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === "\x03") {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
clearMenu();
process.stdout.write("\x1b[?25h");
resolve();
return;
}
if (key === "\x1b" || key.startsWith("\x1b[")) {
escapeBuf = key;
if (key.length >= 3) {
handleSeq(key);
escapeBuf = "";
}
return;
}
if (escapeBuf) {
escapeBuf += key;
if (/^[A-Za-z~]$/.test(key)) {
handleSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 8) {
escapeBuf = "";
}
return;
}
if (key === " " || key === "\r") {
const selected = actions[cursor]!;
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
clearMenu();
process.stdout.write("\x1b[?25h");
if (selected.key === "commit") {
handleCommit(false, false).then(resolve);
} else if (selected.key === "pr") {
handlePR(false).then(resolve);
} else if (selected.key === "config") {
handleConfig().then(resolve);
} else {
resolve();
}
return;
}
});
});
}
async function selectPlatform(hostname: string): Promise<Platform | null> {
const options = [
{ platform: "github" as Platform, label: "GitHub", desc: "gh CLI" },
{ platform: "gitea" as Platform, label: "Gitea", desc: "tea CLI" },
];
let cursor = 0;
const headerLines = 4;
process.stdout.write(`\n Remote: ${CYAN}${hostname}${RESET} — could not auto-detect platform.\n`);
process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`);
const totalLines = headerLines + options.length;
function render() {
for (let i = 0; i < options.length; i++) {
process.stdout.write("\x1b[2K\r");
const opt = options[i]!;
const pointer = i === cursor ? `${CYAN}${RESET} ` : " ";
const dot = i === cursor ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const name = i === cursor ? `${BOLD}${opt.label}${RESET}` : opt.label;
const desc = i === cursor ? opt.desc : `${DIM}${opt.desc}${RESET}`;
process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 10 - opt.label.length))}${desc}\n`);
}
process.stdout.write(`\x1b[${options.length}A`);
}
function clearMenu() {
process.stdout.write(`\x1b[${headerLines}A`);
for (let i = 0; i < totalLines; i++) {
process.stdout.write("\r\x1b[2K\n");
}
process.stdout.write(`\x1b[${totalLines}A`);
}
if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`);
process.exit(1);
}
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?25l");
render();
return new Promise((resolve) => {
let escapeBuf = "";
function handleSeq(seq: string) {
if (seq === "\x1b[A" || seq === "\x1bOA") {
if (cursor > 0) {
cursor--;
render();
}
} else if (seq === "\x1b[B" || seq === "\x1bOB") {
if (cursor < options.length - 1) {
cursor++;
render();
}
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === "\x03") {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
clearMenu();
process.stdout.write("\x1b[?25h");
resolve(null);
return;
}
if (key === "\x1b" || key.startsWith("\x1b[")) {
escapeBuf = key;
if (key.length >= 3) {
handleSeq(key);
escapeBuf = "";
}
return;
}
if (escapeBuf) {
escapeBuf += key;
if (/^[A-Za-z~]$/.test(key)) {
handleSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 8) {
escapeBuf = "";
}
return;
}
if (key === " " || key === "\r") {
const selected = options[cursor]!;
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
clearMenu();
process.stdout.write("\x1b[?25h");
resolve(selected.platform);
return;
}
});
});
}
async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
);
process.exit(1);
}
if (!(await isGitRepo())) {
console.error(` ${RED}Error: Not a git repository.${RESET}`);
process.exit(1);
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.log(" Nothing to commit.");
return;
}
if (unstagedFiles.length > 0) {
if (autoMode) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(
` ${GREEN}Auto-staged ${unstagedFiles.length} file(s).${RESET}`,
);
} else {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected.length > 0) {
await stageFiles(selected);
console.log(
` ${GREEN}Staged ${selected.length} file(s).${RESET}`,
);
}
}
}
const diff = await getStagedDiff();
if (!diff) {
console.log(" No staged changes to commit.");
return;
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff =
diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const repoRoot = await getRepoRoot();
const projectCtx = await collectProjectContext(repoRoot);
const recentCommits = await getRecentCommits(10);
const userPrompt = buildPrompt({
readme: projectCtx.readme,
packageDescription: projectCtx.packageDescription,
structure: projectCtx.structure,
recentCommits,
diff: truncatedDiff,
});
console.log("\n Generating commit message...");
let message: string;
try {
message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt);
} catch (err) {
console.error(
` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
if (dryRun) {
console.log(`\n ${BOLD}Generated commit message:${RESET}`);
console.log(` ${GREEN}${message}${RESET}`);
await copyToClipboard(message);
console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`);
return;
}
const action = await confirmCommit(message);
if (action === "y") {
try {
const result = await commit(message);
printCommitResult(result, message);
} catch (err) {
console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
} else if (action === "e") {
const edited = await editMessage(message);
if (edited) {
try {
const result = await commit(edited);
printCommitResult(result, edited);
} catch (err) {
console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
} else {
console.log(" Aborted.");
}
} else {
const copied = await copyToClipboard(message);
console.log(
copied
? ` Aborted. Message copied to clipboard.`
: ` Aborted. Message: ${message}`,
);
}
}
async function handlePR(draft: boolean): Promise<void> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
);
process.exit(1);
}
if (!(await isGitRepo())) {
console.error(` ${RED}Error: Not a git repository.${RESET}`);
process.exit(1);
}
let platform = await detectPlatform();
if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname);
if (!chosen) {
console.log(" Aborted.");
process.exit(0);
}
platform = chosen;
}
const platformLabel = platform === "github" ? "GitHub" : "Gitea";
console.log(` Using: ${CYAN}${platformLabel}${RESET}`);
const cliError = checkCLI(platform);
if (cliError) {
console.error(` ${RED}Error: ${cliError}${RESET}`);
process.exit(1);
}
const authError = await checkAuth(platform);
if (authError) {
console.error(` ${RED}Error: ${authError}${RESET}`);
process.exit(1);
}
const baseBranch = await getDefaultBranch();
const branchName = await getBranchName();
if (branchName === baseBranch) {
console.error(
` ${RED}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET}`,
);
process.exit(1);
}
console.log(
` Branch: ${CYAN}${branchName}${RESET} → base: ${CYAN}${baseBranch}${RESET}`,
);
const commits = await getBranchCommits(baseBranch);
if (commits.length === 0) {
console.error(
` ${RED}Error: No commits on ${branchName} compared to ${baseBranch}. Commit something first.${RESET}`,
);
process.exit(1);
}
console.log(
` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`,
);
const diff = await getBranchDiff(baseBranch);
if (!diff) {
console.error(` ${RED}Error: No diff from base branch.${RESET}`);
process.exit(1);
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff =
diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const repoRoot = await getRepoRoot();
const projectCtx = await collectProjectContext(repoRoot);
const userPrompt = buildPRPrompt({
readme: projectCtx.readme,
packageDescription: projectCtx.packageDescription,
structure: projectCtx.structure,
branchName,
baseBranch,
branchCommits: commits,
diff: truncatedDiff,
});
console.log("\n Generating PR title...");
let title: string;
let body: string;
try {
const result = await generatePRMessage(config, PR_SYSTEM_PROMPT, userPrompt);
title = result.title;
body = result.body;
} catch (err) {
console.error(
` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
console.log(`\n ${BOLD}Generated PR:${RESET}`);
console.log(` Title: ${GREEN}${title}${RESET}`);
if (body) {
console.log(
` Body: ${DIM}${body.replace(/\n/g, "\n ")}${RESET}`,
);
}
console.log("");
const answer = await ask(` Create this PR? [${GREEN}Y${RESET}/n/e] `);
const lower = answer.toLowerCase();
if (lower === "n") {
console.log(" Aborted.");
return;
}
if (lower === "e") {
const newTitle = await ask(" Title: ");
const newBody = await ask(" Body (optional): ");
if (!newTitle.trim()) {
console.log(" Aborted.");
return;
}
title = newTitle;
body = newBody;
}
console.log(`\n Creating PR...`);
try {
const url = await createPR(platform, title, body, baseBranch, draft);
console.log(` ${GREEN}${BOLD}✔ PR created!${RESET}`);
console.log(` ${CYAN}${url}${RESET}`);
} catch (err) {
console.error(
` ${RED}PR creation failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
}
async function main() {
if (args.includes("--help") || args.includes("-h")) {
showHelp();
return;
}
if (args.includes("--version") || args.includes("-v")) {
console.log("gai v0.1.0");
return;
}
const subcommand = args[0];
if (subcommand === "config") {
await handleConfig();
return;
}
if (subcommand === "help") {
showHelp();
return;
}
if (subcommand === "commit") {
const autoMode = args.includes("--auto") || args.includes("-a");
const dryRun = args.includes("--dry-run") || args.includes("-d");
await handleCommit(autoMode, dryRun);
return;
}
if (subcommand === "pr") {
const draft = args.includes("--draft");
await handlePR(draft);
return;
}
if (!subcommand) {
await showMenu();
return;
}
console.error(` ${RED}Unknown command: ${subcommand}${RESET}`);
showHelp();
process.exit(1);
}
process.on("SIGINT", () => {
process.stdout.write("\x1b[?25h");
process.stdout.write("\n");
process.exit(130);
});
main().catch((err) => {
console.error(` ${RED}Unexpected error: ${err}${RESET}`);
process.exit(1);
});