e69b08ac01
- commit: add --amend, -m/--message, streaming output, auto-stage flow - pr: add clipboard copy on abort, TTY-safe platform selection - config: add get/set/list subcommands for non-interactive use - All modules use dynamic terminal colors and proper TTY detection
210 lines
6.5 KiB
TypeScript
210 lines
6.5 KiB
TypeScript
import * as readline from "node:readline";
|
|
import { loadConfig } from "../config";
|
|
import { isGitRepo, getRepoRoot } from "../git";
|
|
import { collectProjectContext } from "../context";
|
|
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
|
|
import { generatePRMessage } from "../ai";
|
|
import { BACK, selectOne } from "../menu";
|
|
import {
|
|
getDefaultBranch,
|
|
getBranchName,
|
|
getBranchPushStatus,
|
|
pushCurrentBranch,
|
|
getBranchCommits,
|
|
getBranchDiff,
|
|
detectPlatform,
|
|
getRemoteHostname,
|
|
createPR,
|
|
} from "../pr";
|
|
import type { Platform } from "../pr";
|
|
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
|
import { isStdinTTY } from "../tty";
|
|
import { copyToClipboard } from "../clipboard";
|
|
import type { ParsedArgs } from "../cli";
|
|
|
|
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 selectPlatform(hostname: string): Promise<Platform | null | typeof BACK> {
|
|
if (!isStdinTTY()) {
|
|
console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`);
|
|
process.exit(1);
|
|
}
|
|
return selectOne({
|
|
title: "Select remote platform",
|
|
subtitle: `Remote ${hostname} could not be auto-detected`,
|
|
items: [
|
|
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
|
|
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" },
|
|
{ label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" },
|
|
],
|
|
});
|
|
}
|
|
|
|
export async function handlePR(args: ParsedArgs): Promise<number> {
|
|
const config = await loadConfig();
|
|
|
|
if (!config.apiKey) {
|
|
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
|
return 1;
|
|
}
|
|
|
|
if (!(await isGitRepo())) {
|
|
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
|
return 1;
|
|
}
|
|
|
|
const draft = args.flags["draft"] as boolean;
|
|
const verbose = args.flags["verbose"] as boolean;
|
|
|
|
let platform = await detectPlatform();
|
|
if (!platform) {
|
|
const hostname = (await getRemoteHostname()) || "unknown";
|
|
const chosen = await selectPlatform(hostname);
|
|
if (chosen === BACK) return 0;
|
|
if (!chosen) {
|
|
console.log(" Aborted.");
|
|
return 0;
|
|
}
|
|
platform = chosen;
|
|
}
|
|
|
|
const platformLabel = platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea";
|
|
console.log(` Using: ${CYAN()}${platformLabel}${RESET()}`);
|
|
|
|
const baseBranch = await getDefaultBranch();
|
|
const branchName = await getBranchName();
|
|
|
|
if (branchName === baseBranch) {
|
|
console.error(`\n ${RED()}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET()}\n`);
|
|
return 1;
|
|
}
|
|
|
|
console.log(` Branch: ${CYAN()}${branchName}${RESET()} → base: ${CYAN()}${baseBranch}${RESET()}`);
|
|
|
|
const commits = await getBranchCommits(baseBranch);
|
|
|
|
if (commits.length === 0) {
|
|
const choice = await selectOne({
|
|
title: "No commits to compare",
|
|
subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`,
|
|
items: [{ label: "Back", value: "back" as const }],
|
|
});
|
|
if (choice === null) process.exit(0);
|
|
return 0;
|
|
}
|
|
|
|
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
|
|
|
|
if (verbose) {
|
|
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
|
}
|
|
|
|
const pushStatus = await getBranchPushStatus();
|
|
if (!pushStatus.pushed) {
|
|
const target = pushStatus.upstream ?? `origin/${branchName}`;
|
|
const answer = await ask(
|
|
` Branch is not pushed to ${CYAN()}${target}${RESET()}. Push now? [${GREEN()}Y${RESET()}/n] `,
|
|
);
|
|
|
|
if (answer.toLowerCase() === "n") {
|
|
console.log(" Aborted.");
|
|
return 0;
|
|
}
|
|
|
|
console.log(` Pushing ${CYAN()}${branchName}${RESET()}...`);
|
|
try {
|
|
await pushCurrentBranch(branchName, pushStatus.upstream);
|
|
console.log(` ${GREEN()}Pushed ${branchName}.${RESET()}`);
|
|
} catch (err) {
|
|
console.error(`\n ${RED()}Push failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
const diff = await getBranchDiff(baseBranch);
|
|
if (!diff) {
|
|
console.error(`\n ${RED()}Error: No diff from base branch.${RESET()}\n`);
|
|
return 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 and description...");
|
|
|
|
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(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
|
return 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.");
|
|
await copyToClipboard(`${title}\n\n${body}`);
|
|
console.log(` ${DIM()}PR title copied to clipboard.${RESET()}`);
|
|
return 0;
|
|
}
|
|
|
|
if (lower === "e") {
|
|
const newTitle = await ask(" Title: ");
|
|
const newBody = await ask(" Body (optional): ");
|
|
if (!newTitle.trim()) {
|
|
console.log(" Aborted.");
|
|
return 0;
|
|
}
|
|
title = newTitle;
|
|
body = newBody;
|
|
}
|
|
|
|
console.log(`\n Creating PR...`);
|
|
|
|
try {
|
|
const url = await createPR(platform, title, body, baseBranch, draft);
|
|
console.log(`\n ${GREEN()}${BOLD()}✔ PR created!${RESET()}`);
|
|
console.log(` ${CYAN()}${url}${RESET()}`);
|
|
} catch (err) {
|
|
console.error(`\n ${RED()}PR creation failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|