diff --git a/CHANGELOG.md b/CHANGELOG.md index d6eac3e..0bb0b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -## 1.0.7 — 2026-07-10 +## Unreleased -- Automated release from main. +- **PR workflow:** `gg pr [base]` and `gg cnp pr [base]` — push the current branch, generate an AI title + description from the branch diff, and create a GitHub pull request via `gh`. Prompts for the merge target (base) branch; `-y` skips confirm and uses the default base. +- **PR AI:** title and body are two separate plain-text model calls (no JSON), which is more reliable on smaller models. +- **PR reuse:** if the head branch already has an open PR, push only and print that URL (skip AI create). +- **CLI UX:** `cnp pr` prints a single `done` after the full flow (no early done after commit). ## 1.0.6 — 2026-07-09 diff --git a/README.md b/README.md index f341b2f..95feefb 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ No browser required. No accounts beyond an optional OpenRouter key. Works in any | You type | What runs | |----------|-----------| | `gg cnp` | `git add .` → AI commit → `git push` | +| `gg cnp pr` | commit → push → AI PR title/body → `gh pr create` | +| `gg pr [base]` | commit if dirty → push → AI PR → open PR into `base` (default: main) | | `gg b feature/login` | new branch → add → commit → `push -u` | | `gg m feature/login` | commit work → merge into `main` → push | | `gg s` | commit work → checkout `main` | @@ -52,7 +54,8 @@ Messages follow [Conventional Commits](https://www.conventionalcommits.org) (`fe ## Install -**Requirements:** [Node.js 18+](https://nodejs.org) and [Git](https://git-scm.com) on your `PATH`. +**Requirements:** [Node.js 18+](https://nodejs.org) and [Git](https://git-scm.com) on your `PATH`. +For pull requests: [GitHub CLI](https://cli.github.com) (`gh`) authenticated with `gh auth login`. ```bash npm install -g git-command-generator @@ -121,6 +124,11 @@ All of these work with **`gg`**, **`gitgen`**, or **`git-gen`**. | `gg c` | `gg commit` | Stage all → commit (no push) | | `gg c p` | `gg commit push` | Stage all → commit → push | | `gg cnp` | `gg commit-and-push` | Same as commit + push (one token) | +| `gg cnp pr` | `gg commit-and-push pr` | Commit → push → AI PR title/body → create PR (asks base) | +| `gg cnp pr develop` | same | Same, base branch = `develop` (no prompt for base) | +| `gg pr` | `gg pr` | Commit if dirty → push → AI PR → `gh pr create` (asks base) | +| `gg pr main` | `gg pr main` | Same, merge target = `main` | +| `gg pr -y` | `gg pr -y` | PR into default base (no base prompt) | | `gg b ` | `gg branch ` | Create branch → add → commit → `push -u origin ` | | `gg m [dst]` | `gg merge [dst]` | Commit → checkout `dst` (default `main`) → merge `src` → push | | `gg s` | `gg save` | Commit current work → checkout `main` | @@ -151,7 +159,7 @@ All of these work with **`gg`**, **`gitgen`**, or **`git-gen`**. | Flag | Commands | Effect | |------|----------|--------| | `-m "msg"` / `--message "msg"` | Any command that commits | Use this message instead of AI / default | -| `-y` / `--yes` | `restore` / `rs` | Skip the destructive confirmation | +| `-y` / `--yes` | `restore` / `rs` / `pr` | Skip restore confirm; on PR, use default base without prompting | | `-v` / `-V` / `--version` | — | Same as `version` | | `-h` / `--help` | — | Same as `help` | @@ -226,12 +234,21 @@ gg c # Explicit message (no AI) gg c p -m "chore: bump dependencies" -# Feature branch end-to-end +# Feature branch end-to-end (merge locally) gg b feature/checkout # …work… gg cnp gg m feature/checkout # merge into main and push +# Feature branch → open a GitHub PR instead of merging locally +gg b feature/checkout +# …work… +gg cnp pr # commit + push + PR (asks which base branch) +# or, after work is already committed: +gg pr # asks base (default: main / origin HEAD) +gg pr develop # PR into develop +gg pr -y # default base, no base prompt + # Merge into a non-main branch gg m feature/checkout develop @@ -280,6 +297,34 @@ First AI use without a key launches the setup wizard automatically. --- +## How AI pull requests work + +`gg pr` / `gg cnp pr` need the [GitHub CLI](https://cli.github.com) on your PATH and a logged-in account: + +```bash +gh auth login +``` + +Flow: + +1. Commit dirty work (if any) with the same AI commit rules as `cnp` +2. `git push` (or `push -u origin ` when there is no upstream) +3. Ask for the **base branch** (merge target) — default is `origin/HEAD`, else `main` / `master` +4. Two OpenRouter calls on the same commit log + diff: one for the **title** (one line), one for the markdown **body** (no JSON) +5. If this head branch **already has an open PR**, skip create — print that URL (push already updated it) +6. Otherwise: show title/body preview and create immediately via `gh pr create`, then print the URL + +Without an API key, title/body fall back to the latest commit subject and the commit list. + +| Command | Notes | +|---------|--------| +| `gg cnp pr` | Full path: stage → commit → push → PR | +| `gg pr` | PR from current branch (commits first if dirty) | +| `gg pr develop` | Base = `develop` (no base prompt) | +| `gg pr -y` | Default base, no base prompt (creates immediately either way) | + +--- + ## Optional web UI The package is **CLI-first**. There is also a local Next.js UI if you clone the full repo. diff --git a/lib/pr-message.ts b/lib/pr-message.ts new file mode 100644 index 0000000..522e4d3 --- /dev/null +++ b/lib/pr-message.ts @@ -0,0 +1,417 @@ +/** + * AI pull-request title + body generation — shared by the CLI `pr` flow. + * Two separate model calls (title, then body) so we never depend on JSON. + * Framework-agnostic (node builtins + fetch only), mirrors commit-message.ts. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + CommitMessageError, + PROVIDER_LABEL, + type Provider, + git, +} from "./commit-message"; + +const pexec = promisify(execFile); + +const MAX_DIFF_CHARS = 8000; +const MAX_LOG_CHARS = 4000; +const MAX_TITLE_TOKENS = 64; +const MAX_BODY_TOKENS = 512; +const MAX_TITLE_CHARS = 72; + +export class PrMessageError extends Error {} + +export interface PrContent { + title: string; + body: string; +} + +export interface GeneratePrContentOptions { + path: string; + /** Base branch name (e.g. main) — compared as origin/base when available. */ + base: string; + /** Head branch name (current feature branch). */ + head: string; + provider: Provider; + apiKey: string; + model: string; + language: "en" | "pt"; +} + +type PrPart = "title" | "body"; + +const TITLE_PROMPTS: Record = { + en: `GitHub pull request title generator. Reply with ONE line only. + +Rules: +- Conventional Commits style preferred: "feat: …" / "fix: …" / "chore: …" +- English, imperative, concise +- Aim for ~${MAX_TITLE_CHARS} characters. Prefer a complete title over cutting mid-word. +- ONLY the title. No quotes, no period, no markdown, no extra text. + +Examples: +feat: add automatic PR generation +fix: correct branch field validation +chore: update release documentation`, + pt: `Gerador de titulo de pull request no GitHub. Responda com UMA linha apenas. + +Regras: +- Estilo Conventional Commits quando fizer sentido: "feat: …" / "fix: …" / "chore: …" +- Portugues, imperativo, conciso +- Almeje ~${MAX_TITLE_CHARS} caracteres. Prefira um titulo completo a cortar no meio da palavra. +- APENAS o titulo. Sem aspas, sem ponto final, sem markdown, sem texto extra. + +Exemplos: +feat: adiciona geracao automatica de PR +fix: corrige validacao do campo de branch +chore: atualiza documentacao de release`, +}; + +const BODY_PROMPTS: Record = { + en: `GitHub pull request description writer. Reply with markdown only. + +Rules: +- Start with a ## Summary section (2–5 bullets of what changed and why) +- Optionally add ## Test plan with a short checklist +- Be concrete; use the commits and files provided +- English +- No title line, no JSON, no code fences around the whole reply +- No placeholder fluff like "This PR does X"`, + pt: `Escritor de descricao de pull request no GitHub. Responda apenas em markdown. + +Regras: +- Comece com ## Resumo (2–5 bullets do que mudou e por que) +- Opcionalmente adicione ## Plano de teste com checklist curto +- Seja concreto; use os commits e arquivos fornecidos +- Portugues +- Sem linha de titulo, sem JSON, sem fences de codigo em volta da resposta inteira +- Sem enrolacao generica`, +}; + +function systemPrompt(part: PrPart, language: string): string { + const map = part === "title" ? TITLE_PROMPTS : BODY_PROMPTS; + return map[language] || map.en; +} + +function openRouterRequest( + apiKey: string, + model: string, + system: string, + context: string, + maxTokens: number +) { + return fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Title": "Git Command Generator", + }, + body: JSON.stringify({ + model, + temperature: 0.2, + max_tokens: maxTokens, + messages: [ + { role: "system", content: system }, + { role: "user", content: context }, + ], + }), + }); +} + +function openAiRequest( + apiKey: string, + model: string, + system: string, + context: string, + maxTokens: number +) { + return fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + instructions: system, + input: context, + max_output_tokens: maxTokens, + temperature: 0.2, + }), + }); +} + +function extractResponseText(data: unknown): string { + const response = data as { + output_text?: unknown; + output?: Array<{ content?: Array<{ text?: unknown }> }>; + choices?: Array<{ message?: { content?: unknown } }>; + }; + + if (typeof response.output_text === "string") return response.output_text; + + const parts: string[] = []; + for (const item of response.output ?? []) { + for (const content of item.content ?? []) { + if (typeof content.text === "string") parts.push(content.text); + } + } + if (parts.length > 0) return parts.join("\n"); + + const chatContent = response.choices?.[0]?.message?.content; + return typeof chatContent === "string" ? chatContent : ""; +} + +async function readProviderError(res: Response, provider: Provider): Promise { + const text = await res.text().catch(() => ""); + let detail = `${PROVIDER_LABEL[provider]} responded with ${res.status}`; + try { + const j = JSON.parse(text) as { error?: { message?: string } }; + if (j?.error?.message) detail = j.error.message; + } catch { + /* keep default */ + } + return detail; +} + +/** Sanitize a one-line PR title from free-form model output. */ +export function cleanTitle(raw: string): string { + let text = (raw || "").trim(); + text = text.replace(/[\s\S]*?<\/think>/gi, "").trim(); + text = text.replace(/<\/?think>/gi, "").trim(); + + // Prefer the first non-empty line + const line = + text + .split("\n") + .map((l) => l.trim()) + .find(Boolean) || ""; + + return line + .replace(/^\s*(pr title|title|titulo)\s*[:\-]\s*/i, "") + .replace(/^[\s"'`*#\-]+/, "") + .replace(/[\s"'`.]+$/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); +} + +/** Sanitize markdown PR body from free-form model output. */ +export function cleanBody(raw: string): string { + let text = (raw || "").trim(); + text = text.replace(/[\s\S]*?<\/think>/gi, "").trim(); + text = text.replace(/<\/?think>/gi, "").trim(); + + // Drop a single outer markdown fence if the model wrapped the whole reply + if (/^```(?:markdown|md)?\s*\n/i.test(text) && text.endsWith("```")) { + text = text + .replace(/^```(?:markdown|md)?\s*\n/i, "") + .replace(/\n```\s*$/i, "") + .trim(); + } + + text = text.replace(/^\s*(pr body|body|descricao|description)\s*[:\-]\s*/i, "").trim(); + return text; +} + +/** + * Resolve which ref to diff against for `base`. + * Prefers origin/base when it exists, else local base, else the bare name. + */ +export async function resolveBaseRef(path: string, base: string): Promise { + const candidates = [`origin/${base}`, base]; + for (const ref of candidates) { + const ok = (await git(path, ["rev-parse", "--verify", "--quiet", ref])).trim(); + if (ok) return ref; + } + return base; +} + +/** Best-effort default base branch: origin/HEAD → main → master → "main". */ +export async function detectDefaultBase(path: string): Promise { + const sym = (await git(path, ["symbolic-ref", "refs/remotes/origin/HEAD"])).trim(); + // refs/remotes/origin/main → main + const m = sym.match(/refs\/remotes\/origin\/(.+)$/); + if (m?.[1]) return m[1]; + + for (const name of ["main", "master", "develop"]) { + const ok = (await git(path, ["rev-parse", "--verify", "--quiet", `origin/${name}`])).trim(); + if (ok) return name; + const local = (await git(path, ["rev-parse", "--verify", "--quiet", name])).trim(); + if (local) return name; + } + return "main"; +} + +function buildPrContext( + head: string, + base: string, + baseRef: string, + log: string, + stat: string, + diff: string +): string { + const parts = [ + `HEAD branch: ${head}`, + `BASE branch: ${base} (ref: ${baseRef})`, + ]; + const lg = log.trim(); + if (lg) { + parts.push( + `COMMITS (${baseRef}...HEAD):\n${lg.length > MAX_LOG_CHARS ? `${lg.slice(0, MAX_LOG_CHARS)}\n…(truncated)` : lg}` + ); + } + const st = stat.trim(); + if (st) parts.push(`FILE STAT:\n${st}`); + let patch = diff.trim(); + if (patch) { + if (patch.length > MAX_DIFF_CHARS) { + patch = `${patch.slice(0, MAX_DIFF_CHARS)}\n…(truncated)`; + } + parts.push(`DIFF:\n${patch}`); + } + return parts.join("\n\n"); +} + +async function completePart( + opts: { + provider: Provider; + apiKey: string; + model: string; + language: string; + context: string; + part: PrPart; + } +): Promise { + const { provider, apiKey, model, language, context, part } = opts; + const system = systemPrompt(part, language); + const maxTokens = part === "title" ? MAX_TITLE_TOKENS : MAX_BODY_TOKENS; + + const res = + provider === "openai" + ? await openAiRequest(apiKey, model, system, context, maxTokens) + : await openRouterRequest(apiKey, model, system, context, maxTokens); + + if (!res.ok) { + throw new PrMessageError(await readProviderError(res, provider)); + } + + const data = await res.json(); + return extractResponseText(data); +} + +/** + * Generate a PR title + markdown body from commits/diff of head vs base. + * Uses two independent model calls (title, body) — no JSON required. + * Throws PrMessageError on failure. + */ +export async function generatePrContent(opts: GeneratePrContentOptions): Promise { + const { path, base, head, provider, apiKey, model, language } = opts; + + const isRepo = (await git(path, ["rev-parse", "--is-inside-work-tree"])).trim(); + if (isRepo !== "true") { + throw new PrMessageError("Folder is not a git repository (or path does not exist)"); + } + + const baseRef = await resolveBaseRef(path, base); + const range = `${baseRef}...HEAD`; + + const [log, stat, diff] = await Promise.all([ + git(path, ["log", range, "--oneline", "--no-decorate"]), + git(path, ["diff", range, "--stat"]), + git(path, ["diff", range]), + ]); + + if (!log.trim() && !diff.trim()) { + throw new PrMessageError( + `No commits or diff between ${baseRef} and HEAD — push commits first or pick another base` + ); + } + + const context = buildPrContext(head, base, baseRef, log, stat, diff); + const common = { provider, apiKey, model, language, context }; + + // Parallel: title and body don't depend on each other + const [rawTitle, rawBody] = await Promise.all([ + completePart({ ...common, part: "title" }), + completePart({ ...common, part: "body" }), + ]); + + const title = cleanTitle(rawTitle); + const body = cleanBody(rawBody); + + if (!title) { + throw new PrMessageError("Model did not return a PR title"); + } + if (!body) { + throw new PrMessageError("Model did not return a PR body"); + } + + return { title, body }; +} + +/** Fallback when AI is unavailable: title from latest commit, body from log. */ +export async function fallbackPrContent( + path: string, + base: string, + head: string +): Promise { + const baseRef = await resolveBaseRef(path, base); + const range = `${baseRef}...HEAD`; + const log = (await git(path, ["log", range, "--oneline", "--no-decorate"])).trim(); + const subject = (await git(path, ["log", "-1", "--pretty=%s"])).trim(); + const title = cleanTitle(subject) || `Merge ${head} into ${base}`; + const bullets = log + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 20) + .map((l) => `- ${l.replace(/^[a-f0-9]+\s+/i, "")}`) + .join("\n"); + const body = bullets + ? `## Summary\n\n${bullets}\n` + : `## Summary\n\n- Merge \`${head}\` into \`${base}\`\n`; + return { title, body }; +} + +/** + * Run a command and return stdout; throws with stderr on non-zero exit. + * Used for `gh` — not for git (git helpers swallow errors). + */ +export async function runCapture( + cmd: string, + args: string[], + cwd: string +): Promise { + try { + const { stdout } = await pexec(cmd, args, { + cwd, + maxBuffer: 8 * 1024 * 1024, + windowsHide: true, + timeout: 120_000, + }); + return stdout; + } catch (e) { + const err = e as { stderr?: string; message?: string }; + const msg = (err.stderr || err.message || String(e)).trim(); + throw new Error(msg || `${cmd} failed`); + } +} + +/** True if `gh` is on PATH (does not require auth). */ +export async function isGhAvailable(): Promise { + try { + await pexec("gh", ["--version"], { + windowsHide: true, + timeout: 10_000, + }); + return true; + } catch { + return false; + } +} + +// Re-export for callers that already import CommitMessageError patterns +export { CommitMessageError }; diff --git a/scripts/cli.ts b/scripts/cli.ts index cc99a80..bcedd6b 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -9,8 +9,9 @@ * Long form Short What it does * ───────────────────── ─────────── ────────────────────────────────────────── * start start open the web app with current folder - * commit [push] c [p] add . -> (AI) commit [-> push] - * commit-and-push cnp add . -> (AI) commit -> push (one word) + * commit [push] [pr] c [p] [pr] add . -> (AI) commit [-> push] [-> PR] + * commit-and-push cnp [pr] add . -> (AI) commit -> push [-> PR] + * pr [base] pr [base] push branch -> AI title/body -> gh pr create * branch b checkout -b -> add -> commit -> push -u * merge [dst] m [d] add -> commit -> checkout dst -> merge -> push * save s add -> commit -> checkout main @@ -27,8 +28,9 @@ * Security: the API key is entered hidden (never echoed) and written to a * user-only config file (0600). It stays local — only sent to OpenRouter. * - * Flags: -m / --message "msg" · -y / --yes (skip restore confirm) + * Flags: -m / --message "msg" · -y / --yes (skip restore confirm; PR uses default base) * Also: --version / -v / -V (same as version) + * PR needs GitHub CLI (`gh`) authenticated (`gh auth login`). */ import { execFile, spawn } from "node:child_process"; import { existsSync } from "node:fs"; @@ -55,6 +57,15 @@ import { resolveRuntimeSettings, saveConfig, } from "../lib/config"; +import { + detectDefaultBase, + fallbackPrContent, + generatePrContent, + isGhAvailable, + PrMessageError, + runCapture, + type PrContent, +} from "../lib/pr-message"; import { evaluateUpdate, npmGlobalInstallCommand, @@ -295,6 +306,218 @@ async function hasChanges(): Promise { return (await gitQuiet(["status", "--porcelain", "-u"])).trim() !== ""; } +async function currentBranch(): Promise { + const name = (await gitQuiet(["branch", "--show-current"])).trim(); + if (!name) die("detached HEAD — checkout a branch before creating a PR"); + return name; +} + +/** True when the current branch has an upstream configured. */ +async function hasUpstream(): Promise { + const up = (await gitQuiet(["rev-parse", "--abbrev-ref", "@{upstream}"])).trim(); + return Boolean(up); +} + +function isPrToken(t: string | undefined): boolean { + const v = (t || "").toLowerCase(); + return v === "pr" || v === "pull" || v === "pull-request"; +} + +/** + * After a commit/push command, detect trailing `pr [base]`. + * Examples: cnp pr · cnp pr develop · commit push pr · commit pr main + */ +function parseCommitPrArgs( + raw: string, + a1: string | undefined, + a2: string | undefined, + a3: string | undefined +): { push: boolean; wantPr: boolean; prBase?: string } { + const pushByAlias = PUSH_ALIASES.has(raw); + // cnp [pr [base]] + if (pushByAlias) { + if (isPrToken(a1)) return { push: true, wantPr: true, prBase: a2 }; + return { push: true, wantPr: false }; + } + // commit push pr [base] | commit p pr [base] + if (isPushToken(a1)) { + if (isPrToken(a2)) return { push: true, wantPr: true, prBase: a3 }; + return { push: true, wantPr: false }; + } + // commit pr [base] (implies push — you can't open a remote PR without push) + if (isPrToken(a1)) return { push: true, wantPr: true, prBase: a2 }; + return { push: false, wantPr: false }; +} + +/** AI PR title/body, with sensible fallback if no key / API error. */ +async function resolvePrContent(base: string, head: string): Promise { + const { apiKey, model, language } = await ensureApiReady(); + if (apiKey) { + try { + // Two model calls (title, body) in parallel — plain text, no JSON. + const content = await withProgress( + `AI PR title + body · ${PROVIDER_LABEL[PROVIDER]}`, + () => + generatePrContent({ + path: cwd, + base, + head, + provider: PROVIDER, + apiKey, + model: model || DEFAULT_MODELS.openrouter, + language, + }) + ); + return content; + } catch (e) { + const reason = + e instanceof PrMessageError || e instanceof CommitMessageError + ? e.message + : e instanceof Error + ? e.message + : String(e); + log(` ${sym.warn} ${c.yellow("ai skipped")} ${c.dim(`(${reason}) — using commit log`)}`); + } + } else { + log(` ${c.dim("PR text from git log")} ${c.dim("(no API key)")}`); + } + return fallbackPrContent(cwd, base, head); +} + +type ExistingPr = { url: string; number: number; base: string }; + +/** Open PR for this head branch, if any (one feature branch → one open PR). */ +async function findOpenPrForHead(head: string): Promise { + try { + const out = ( + await runCapture( + "gh", + [ + "pr", + "list", + "--head", + head, + "--state", + "open", + "--json", + "url,number,baseRefName", + "--limit", + "5", + ], + cwd + ) + ).trim(); + const list = JSON.parse(out || "[]") as Array<{ + url?: string; + number?: number; + baseRefName?: string; + }>; + if (!Array.isArray(list) || list.length === 0) return null; + const first = list[0]; + if (!first?.url) return null; + return { + url: first.url, + number: typeof first.number === "number" ? first.number : 0, + base: first.baseRefName || "", + }; + } catch { + return null; + } +} + +/** + * Push current branch (set upstream if needed), generate AI PR text, create via `gh`. + * Creates immediately — no extra confirm (the command itself is the intent). + * If this head already has an open PR, push only and reuse that PR (no second create). + * `baseHint` is optional; interactive prompt defaults to origin's default branch + * (or uses the default when `-y` is set). + */ +async function createPullRequest(baseHint?: string): Promise { + if (!(await isGhAvailable())) { + die( + "GitHub CLI (gh) not found — install https://cli.github.com and run: gh auth login" + ); + } + + const head = await currentBranch(); + const defaultBase = await detectDefaultBase(cwd); + let base = (baseHint || "").trim(); + if (!base) { + if (yes) { + base = defaultBase; + } else { + base = await promptLine("Base branch (merge target)", defaultBase); + } + } + if (!base) die("base branch required"); + if (base === head) { + die(`base and head are both "${head}" — checkout a feature branch first`); + } + + // Ensure remote has our commits + if (await hasUpstream()) { + await git(["push"], { progress: true }); + } else { + await git(["push", "-u", "origin", head], { progress: true }); + } + + // Same head already has an open PR → just update via push; don't create another. + const existing = await withProgress("check existing PR", () => findOpenPrForHead(head)); + if (existing) { + log(` ${sym.ok} ${c.green("PR already open")} ${c.dim(`#${existing.number || "?"}`)}`); + log(row("base", existing.base || base)); + log(row("head", head)); + log(row("url", c.underline(c.cyan(existing.url)))); + log(` ${c.dim("skipped create — push updated the existing PR")}`); + return; + } + + const content = await resolvePrContent(base, head); + log(row("base", base)); + log(row("head", head)); + log(row("title", c.bold(content.title))); + // Preview body (compact) while gh creates the PR + const preview = content.body + .split("\n") + .slice(0, 12) + .map((l) => ` ${c.dim(l)}`) + .join("\n"); + if (preview) { + log(` ${c.dim("body")}`); + log(preview); + if (content.body.split("\n").length > 12) log(` ${c.dim("…")}`); + } + + const url = ( + await withProgress("gh pr create", () => + runCapture( + "gh", + [ + "pr", + "create", + "--base", + base, + "--head", + head, + "--title", + content.title, + "--body", + content.body, + ], + cwd + ) + ) + ) + .trim() + .split("\n") + .filter(Boolean) + .pop(); + + if (url) { + log(row("url", c.underline(c.cyan(url)))); + } +} + async function promptLine(question: string, defaultValue?: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); const hint = defaultValue ? ` ${c.dim(`[${defaultValue}]`)}` : ""; @@ -512,6 +735,9 @@ for (let i = 0; i < argv.length; i++) { const SHORT_CMDS: Record = { c: "commit", cnp: "commit", // commit AND push in one word (implies push — see PUSH_ALIASES) + pr: "pr", + pull: "pr", + "pull-request": "pr", b: "branch", m: "merge", s: "save", @@ -806,8 +1032,9 @@ function helpText(): string { const d = c.dim; const rows: Array<[string, string, string]> = [ ["start", "start", "open the web app with this folder"], - ["commit [push] [-m]", "c [p] [-m]", "add . → commit [→ push] (AI msg if no -m)"], - ["commit-and-push", "cnp", "add . → commit → push (one word)"], + ["commit [push] [pr] [-m]", "c [p] [pr] [-m]", "add . → commit [→ push] [→ PR]"], + ["commit-and-push [pr]", "cnp [pr]", "add . → commit → push [→ AI PR via gh]"], + ["pr [base] [-y]", "pr [base]", "push → AI title/body → create PR (asks base)"], ["branch [-m]", "b [-m]", "new branch → add → commit → push -u"], ["merge [dst] [-m]", "m [dst]", "commit → checkout dst|main → merge → push"], ["save [-m]", "s [-m]", "commit current work, then checkout main"], @@ -843,10 +1070,13 @@ ${body} ${d("$")} gg setup ${d("$")} gg start ${d("# open web UI (not bare gg)")} ${d("$")} gg cnp ${d("# commit + push")} + ${d("$")} gg cnp pr ${d("# commit + push + create PR (asks base)")} + ${d("$")} gg pr develop ${d("# push + PR into develop")} + ${d("$")} gg pr -y ${d("# PR into default base (no base prompt)")} ${d("$")} gg mo google/gemini-2.0-flash-001 ${d("$")} gg config reset ${d("# redo the full onboard")} - ${d("AI:")} ${PROVIDER_LABEL[PROVIDER]} ${d(`(${model})`)}${apiKey ? c.green(" ✓ key set") : c.yellow(" ! no API key — run gitgen setup")}`; + ${d("PR needs")} ${c.cyan("gh")} ${d("authenticated")} ${d("·")} ${d("AI:")} ${PROVIDER_LABEL[PROVIDER]} ${d(`(${model})`)}${apiKey ? c.green(" ✓ key set") : c.yellow(" ! no API key — run gitgen setup")}`; } async function main() { @@ -887,19 +1117,69 @@ async function main() { } case "commit": { - // `cnp` means commit+push by name; `commit p` / `commit push` also works. - const push = PUSH_ALIASES.has(rawCmd) || isPushToken(arg1); - banner(`commit${push ? " + push" : ""}`); - if (!(await hasChanges())) { + // `cnp` = commit+push; optional trailing `pr [base]` opens a GitHub PR. + // `commit push pr develop` / `cnp pr` / `commit pr` all work. + const { push, wantPr, prBase } = parseCommitPrArgs(rawCmd, arg1, arg2, arg3); + const label = wantPr ? "commit + push + PR" : push ? "commit + push" : "commit"; + banner(label); + + if (await hasChanges()) { + const message = await resolveMessage( + messageFlag, + push || wantPr ? "feat: update" : "feat: save progress" + ); + // When also opening a PR, skip runSteps' early "done" — single done after PR. + if (wantPr) { + try { + await git(["add", "."]); + await git(["commit", "-m", message]); + } catch (e) { + const stderr = (e as { stderr?: string })?.stderr; + die(stderr?.trim() || (e instanceof Error ? e.message : String(e))); + } + } else { + await runSteps(async () => { + await git(["add", "."]); + await git(["commit", "-m", message]); + if (push) await git(["push"], { progress: true }); + }); + } + } else if (!wantPr) { log(` ${sym.ok} ${c.dim("nothing to commit — working tree clean")}`); return; + } else { + log(` ${c.dim("nothing to commit — opening PR from current branch")}`); + } + + if (wantPr) { + // createPullRequest pushes (with -u if needed) then runs gh pr create + try { + await createPullRequest(prBase); + log(` ${sym.ok} ${c.green("done")}`); + } catch (e) { + const stderr = (e as { stderr?: string })?.stderr; + die(stderr?.trim() || (e instanceof Error ? e.message : String(e))); + } + } + return; + } + + case "pr": { + // gg pr [base] — commit dirty work, push, AI title/body, gh pr create + const baseArg = arg1 && !isPrToken(arg1) ? arg1 : undefined; + banner(`pull request${baseArg ? ` → ${baseArg}` : ""}`); + try { + if (await hasChanges()) { + const message = await resolveMessage(messageFlag, "feat: update"); + await git(["add", "."]); + await git(["commit", "-m", message]); + } + await createPullRequest(baseArg); + log(` ${sym.ok} ${c.green("done")}`); + } catch (e) { + const stderr = (e as { stderr?: string })?.stderr; + die(stderr?.trim() || (e instanceof Error ? e.message : String(e))); } - const message = await resolveMessage(messageFlag, push ? "feat: update" : "feat: save progress"); - await runSteps(async () => { - await git(["add", "."]); - await git(["commit", "-m", message]); - if (push) await git(["push"], { progress: true }); - }); return; } diff --git a/test/pr-message.test.ts b/test/pr-message.test.ts new file mode 100644 index 0000000..9e03f31 --- /dev/null +++ b/test/pr-message.test.ts @@ -0,0 +1,42 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { cleanBody, cleanTitle } from "../lib/pr-message"; + +describe("cleanTitle", () => { + it("strips quotes and trailing periods", () => { + assert.equal(cleanTitle('"feat: add PR flow".'), "feat: add PR flow"); + }); + + it("takes the first line only", () => { + assert.equal(cleanTitle("feat: one\nfeat: two"), "feat: one"); + }); + + it("strips Title: prefixes", () => { + assert.equal(cleanTitle("Title: chore: docs"), "chore: docs"); + }); + + it("collapses whitespace and caps length", () => { + const long = "x".repeat(200); + assert.equal(cleanTitle(` ${long} `).length, 120); + }); +}); + +describe("cleanBody", () => { + it("keeps markdown summary intact", () => { + const md = "## Summary\n\n- one\n- two\n"; + assert.equal(cleanBody(md), md.trim()); + }); + + it("unwraps a full-reply markdown fence", () => { + const raw = "```markdown\n## Summary\n\n- a\n```"; + assert.equal(cleanBody(raw), "## Summary\n\n- a"); + }); + + it("strips Body: prefix", () => { + assert.match(cleanBody("Body:\n## Summary\n- x"), /## Summary/); + }); + + it("strips think tags", () => { + assert.equal(cleanBody("secret\n## Summary\n- y"), "## Summary\n- y"); + }); +});