refactor: extract commit, pr, config into command modules
- 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
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user