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, SKIP_WAIT, 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 { 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 { 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 { 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 SKIP_WAIT as unknown as number; 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 SKIP_WAIT as unknown as number; } 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; }