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:
2026-06-16 02:00:59 +08:00
parent c0c3dfce7d
commit e69b08ac01
3 changed files with 959 additions and 0 deletions
+209
View File
@@ -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;
}