From 08297a31fc57376cc99ab260f88d3f40cf70fffd Mon Sep 17 00:00:00 2001 From: Jubarte Date: Fri, 10 Jul 2026 09:25:08 -0300 Subject: [PATCH 1/3] chore: update README and CHANGELOG with new PR workflow commands --- CHANGELOG.md | 4 + README.md | 51 +++++- lib/pr-message.ts | 361 ++++++++++++++++++++++++++++++++++++++++ scripts/cli.ts | 253 ++++++++++++++++++++++++++-- test/pr-message.test.ts | 59 +++++++ 5 files changed, 709 insertions(+), 19 deletions(-) create mode 100644 lib/pr-message.ts create mode 100644 test/pr-message.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 505d9cc..bd2550f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **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. + ## 1.0.6 — 2026-07-09 - Automated release from main. diff --git a/README.md b/README.md index f341b2f..ca6a4be 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]` | push branch → 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, skip confirm | | `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 confirmation (restore discard, or PR create) | | `-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 confirm + # 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. Send commit log + diff vs base to OpenRouter → PR **title** + markdown **body** +5. Preview title/body → confirm (skip with `-y`) +6. `gh pr create --base … --head … --title … --body …` and print the PR 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 + create without confirm | + +--- + ## 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..0e7c784 --- /dev/null +++ b/lib/pr-message.ts @@ -0,0 +1,361 @@ +/** + * AI pull-request title + body generation — shared by the CLI `pr` flow. + * 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_COMPLETION_TOKENS = 512; + +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"; +} + +const PROMPTS: Record = { + en: `You write GitHub pull request titles and descriptions from a branch diff. + +Reply with ONLY valid JSON (no markdown fences, no extra text): +{"title":"...","body":"..."} + +Rules for title: +- Short, imperative, Conventional Commits style preferred: "feat: …" / "fix: …" +- Max ~72 characters, no trailing period +- English + +Rules for body (markdown): +- Start with a ## Summary section (2–5 bullets of what changed and why) +- Optionally ## Test plan with a short checklist +- Be concrete; use the commits and files provided +- English +- No placeholder fluff like "This PR does X"`, + pt: `Voce escreve titulos e descricoes de pull request no GitHub a partir do diff do branch. + +Responda APENAS com JSON valido (sem fences markdown, sem texto extra): +{"title":"...","body":"..."} + +Regras do titulo: +- Curto, imperativo, estilo Conventional Commits quando fizer sentido: "feat: …" / "fix: …" +- Max ~72 caracteres, sem ponto final +- Portugues + +Regras do body (markdown): +- Comece com ## Resumo (2–5 bullets do que mudou e por que) +- Opcionalmente ## Plano de teste com checklist curto +- Seja concreto; use os commits e arquivos fornecidos +- Portugues +- Sem enrolacao generica`, +}; + +function openRouterRequest(apiKey: string, model: string, language: string, context: string) { + 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: MAX_COMPLETION_TOKENS, + messages: [ + { role: "system", content: PROMPTS[language] || PROMPTS.en }, + { role: "user", content: context }, + ], + }), + }); +} + +function openAiRequest(apiKey: string, model: string, language: string, context: string) { + return fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + instructions: PROMPTS[language] || PROMPTS.en, + input: context, + max_output_tokens: MAX_COMPLETION_TOKENS, + 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; +} + +/** Strip ```json fences and extract the first JSON object from model output. */ +export function extractJsonObject(raw: string): string { + let text = (raw || "").trim(); + text = text.replace(/[\s\S]*?<\/think>/gi, "").trim(); + text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim(); + + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start >= 0 && end > start) { + return text.slice(start, end + 1); + } + return text; +} + +/** Parse and sanitize model JSON into title + body. */ +export function parsePrContent(raw: string): PrContent { + const jsonText = extractJsonObject(raw); + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + // Fallback: TITLE:/BODY: plain text layout + const titleMatch = raw.match(/^\s*TITLE:\s*(.+)$/im); + const bodyMatch = raw.match(/BODY:\s*([\s\S]+)/i); + if (titleMatch) { + return { + title: cleanTitle(titleMatch[1]), + body: (bodyMatch?.[1] || "").trim() || cleanTitle(titleMatch[1]), + }; + } + throw new PrMessageError("Model did not return valid JSON for the PR"); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new PrMessageError("Model returned unexpected PR JSON shape"); + } + const obj = parsed as Record; + const title = typeof obj.title === "string" ? cleanTitle(obj.title) : ""; + const body = typeof obj.body === "string" ? obj.body.trim() : ""; + if (!title) throw new PrMessageError("Model did not return a PR title"); + return { title, body: body || title }; +} + +export function cleanTitle(raw: string): string { + return (raw || "") + .replace(/^[\s"'`]+/, "") + .replace(/[\s"'`.]+$/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); +} + +/** + * 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"); +} + +/** + * Generate a PR title + markdown body from commits/diff of head vs base. + * Throws PrMessageError (or CommitMessageError-like) 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 res = + provider === "openai" + ? await openAiRequest(apiKey, model, language, context) + : await openRouterRequest(apiKey, model, language, context); + + if (!res.ok) { + throw new PrMessageError(await readProviderError(res, provider)); + } + + const data = await res.json(); + return parsePrContent(extractResponseText(data)); +} + +/** 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..0b78f6d 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 / PR confirm) * 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,170 @@ 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 { + 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); +} + +/** + * Push current branch (set upstream if needed), generate AI PR text, create via `gh`. + * `baseHint` is optional; interactive prompt defaults to origin's default branch. + * @returns true if a PR was created, false if the user aborted the confirm step. + */ +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 }); + } + + const content = await resolvePrContent(base, head); + log(row("base", base)); + log(row("head", head)); + log(row("title", c.bold(content.title))); + // Preview body (compact) + 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("…")}`); + } + + if (!yes) { + const ok = await confirm("Create this pull request?"); + if (!ok) { + log(` ${c.dim("PR aborted")}`); + return false; + } + } + + 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)))); + } + return true; +} + 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 +687,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 +984,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 branch → AI title/body → gh pr create"], ["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 +1022,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 + open PR (asks base)")} + ${d("$")} gg pr develop ${d("# push + PR into develop")} + ${d("$")} gg pr -y ${d("# PR into default base, no confirm")} ${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 +1069,58 @@ 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" + ); + await runSteps(async () => { + await git(["add", "."]); + await git(["commit", "-m", message]); + if (push && !wantPr) 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 { + const created = await createPullRequest(prBase); + if (created) 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]); + } + const created = await createPullRequest(baseArg); + if (created) 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..4e4e9fc --- /dev/null +++ b/test/pr-message.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + cleanTitle, + extractJsonObject, + parsePrContent, +} from "../lib/pr-message"; + +describe("cleanTitle", () => { + it("strips quotes and trailing periods", () => { + assert.equal(cleanTitle('"feat: add PR flow".'), "feat: add PR flow"); + }); + + it("collapses whitespace and caps length", () => { + const long = "x".repeat(200); + assert.equal(cleanTitle(` ${long} `).length, 120); + }); +}); + +describe("extractJsonObject", () => { + it("unwraps markdown fences", () => { + const raw = '```json\n{"title":"a","body":"b"}\n```'; + assert.equal(extractJsonObject(raw), '{"title":"a","body":"b"}'); + }); + + it("finds object inside prose", () => { + const raw = 'Here you go:\n{"title":"feat: x","body":"## Summary\\n- y"}\nThanks'; + assert.ok(extractJsonObject(raw).startsWith("{")); + assert.ok(extractJsonObject(raw).endsWith("}")); + }); +}); + +describe("parsePrContent", () => { + it("parses clean JSON", () => { + const r = parsePrContent('{"title":"feat: ship pr","body":"## Summary\\n- one"}'); + assert.equal(r.title, "feat: ship pr"); + assert.match(r.body, /Summary/); + }); + + it("parses fenced JSON", () => { + const r = parsePrContent('```json\n{"title":"fix: bug","body":"details"}\n```'); + assert.equal(r.title, "fix: bug"); + assert.equal(r.body, "details"); + }); + + it("falls back to TITLE:/BODY: layout", () => { + const r = parsePrContent("TITLE: chore: tidy\nBODY:\n## Summary\n- a\n"); + assert.equal(r.title, "chore: tidy"); + assert.match(r.body, /Summary/); + }); + + it("throws on garbage", () => { + assert.throws(() => parsePrContent("not json at all"), /valid JSON|PR/); + }); + + it("throws when title missing", () => { + assert.throws(() => parsePrContent('{"body":"only body"}'), /title/i); + }); +}); From d25e96e9d360501df9acdf56595c8e21fd827070 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Fri, 10 Jul 2026 09:27:55 -0300 Subject: [PATCH 2/3] fix: improve PR workflow AI title and body generation --- CHANGELOG.md | 1 + README.md | 2 +- lib/pr-message.ts | 226 +++++++++++++++++++++++++--------------- scripts/cli.ts | 23 ++-- test/pr-message.test.ts | 55 ++++------ 5 files changed, 175 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2550f..29b3da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - **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. ## 1.0.6 — 2026-07-09 diff --git a/README.md b/README.md index ca6a4be..8d18aee 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ 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. Send commit log + diff vs base to OpenRouter → PR **title** + markdown **body** +4. Two OpenRouter calls on the same commit log + diff: one for the **title** (one line), one for the markdown **body** (no JSON) 5. Preview title/body → confirm (skip with `-y`) 6. `gh pr create --base … --head … --title … --body …` and print the PR URL diff --git a/lib/pr-message.ts b/lib/pr-message.ts index 0e7c784..522e4d3 100644 --- a/lib/pr-message.ts +++ b/lib/pr-message.ts @@ -1,5 +1,6 @@ /** * 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"; @@ -15,7 +16,9 @@ const pexec = promisify(execFile); const MAX_DIFF_CHARS = 8000; const MAX_LOG_CHARS = 4000; -const MAX_COMPLETION_TOKENS = 512; +const MAX_TITLE_TOKENS = 64; +const MAX_BODY_TOKENS = 512; +const MAX_TITLE_CHARS = 72; export class PrMessageError extends Error {} @@ -36,42 +39,68 @@ export interface GeneratePrContentOptions { language: "en" | "pt"; } -const PROMPTS: Record = { - en: `You write GitHub pull request titles and descriptions from a branch diff. - -Reply with ONLY valid JSON (no markdown fences, no extra text): -{"title":"...","body":"..."} +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`, +}; -Rules for title: -- Short, imperative, Conventional Commits style preferred: "feat: …" / "fix: …" -- Max ~72 characters, no trailing period -- English +const BODY_PROMPTS: Record = { + en: `GitHub pull request description writer. Reply with markdown only. -Rules for body (markdown): +Rules: - Start with a ## Summary section (2–5 bullets of what changed and why) -- Optionally ## Test plan with a short checklist +- 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: `Voce escreve titulos e descricoes de pull request no GitHub a partir do diff do branch. - -Responda APENAS com JSON valido (sem fences markdown, sem texto extra): -{"title":"...","body":"..."} + pt: `Escritor de descricao de pull request no GitHub. Responda apenas em markdown. -Regras do titulo: -- Curto, imperativo, estilo Conventional Commits quando fizer sentido: "feat: …" / "fix: …" -- Max ~72 caracteres, sem ponto final -- Portugues - -Regras do body (markdown): +Regras: - Comece com ## Resumo (2–5 bullets do que mudou e por que) -- Opcionalmente ## Plano de teste com checklist curto +- 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 openRouterRequest(apiKey: string, model: string, language: string, context: string) { +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: { @@ -82,16 +111,22 @@ function openRouterRequest(apiKey: string, model: string, language: string, cont body: JSON.stringify({ model, temperature: 0.2, - max_tokens: MAX_COMPLETION_TOKENS, + max_tokens: maxTokens, messages: [ - { role: "system", content: PROMPTS[language] || PROMPTS.en }, + { role: "system", content: system }, { role: "user", content: context }, ], }), }); } -function openAiRequest(apiKey: string, model: string, language: string, context: string) { +function openAiRequest( + apiKey: string, + model: string, + system: string, + context: string, + maxTokens: number +) { return fetch("https://api.openai.com/v1/responses", { method: "POST", headers: { @@ -100,9 +135,9 @@ function openAiRequest(apiKey: string, model: string, language: string, context: }, body: JSON.stringify({ model, - instructions: PROMPTS[language] || PROMPTS.en, + instructions: system, input: context, - max_output_tokens: MAX_COMPLETION_TOKENS, + max_output_tokens: maxTokens, temperature: 0.2, }), }); @@ -141,58 +176,46 @@ async function readProviderError(res: Response, provider: Provider): Promise[\s\S]*?<\/think>/gi, "").trim(); - text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim(); - - const start = text.indexOf("{"); - const end = text.lastIndexOf("}"); - if (start >= 0 && end > start) { - return text.slice(start, end + 1); - } - return text; -} - -/** Parse and sanitize model JSON into title + body. */ -export function parsePrContent(raw: string): PrContent { - const jsonText = extractJsonObject(raw); - let parsed: unknown; - try { - parsed = JSON.parse(jsonText); - } catch { - // Fallback: TITLE:/BODY: plain text layout - const titleMatch = raw.match(/^\s*TITLE:\s*(.+)$/im); - const bodyMatch = raw.match(/BODY:\s*([\s\S]+)/i); - if (titleMatch) { - return { - title: cleanTitle(titleMatch[1]), - body: (bodyMatch?.[1] || "").trim() || cleanTitle(titleMatch[1]), - }; - } - throw new PrMessageError("Model did not return valid JSON for the PR"); - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new PrMessageError("Model returned unexpected PR JSON shape"); - } - const obj = parsed as Record; - const title = typeof obj.title === "string" ? cleanTitle(obj.title) : ""; - const body = typeof obj.body === "string" ? obj.body.trim() : ""; - if (!title) throw new PrMessageError("Model did not return a PR title"); - return { title, body: body || title }; -} - -export function cleanTitle(raw: string): string { - return (raw || "") - .replace(/^[\s"'`]+/, "") + 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. @@ -252,9 +275,37 @@ function buildPrContext( 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. - * Throws PrMessageError (or CommitMessageError-like) on failure. + * 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; @@ -280,18 +331,25 @@ export async function generatePrContent(opts: GeneratePrContentOptions): Promise } const context = buildPrContext(head, base, baseRef, log, stat, diff); + const common = { provider, apiKey, model, language, context }; - const res = - provider === "openai" - ? await openAiRequest(apiKey, model, language, context) - : await openRouterRequest(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" }), + ]); - if (!res.ok) { - throw new PrMessageError(await readProviderError(res, provider)); + 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"); } - const data = await res.json(); - return parsePrContent(extractResponseText(data)); + return { title, body }; } /** Fallback when AI is unavailable: title from latest commit, body from log. */ @@ -303,9 +361,7 @@ export async function fallbackPrContent( 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 subject = (await git(path, ["log", "-1", "--pretty=%s"])).trim(); const title = cleanTitle(subject) || `Merge ${head} into ${base}`; const bullets = log .split("\n") diff --git a/scripts/cli.ts b/scripts/cli.ts index 0b78f6d..36e1c5a 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -354,16 +354,19 @@ async function resolvePrContent(base: string, head: string): Promise const { apiKey, model, language } = await ensureApiReady(); if (apiKey) { try { - 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, - }) + // 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) { diff --git a/test/pr-message.test.ts b/test/pr-message.test.ts index 4e4e9fc..9e03f31 100644 --- a/test/pr-message.test.ts +++ b/test/pr-message.test.ts @@ -1,59 +1,42 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { - cleanTitle, - extractJsonObject, - parsePrContent, -} from "../lib/pr-message"; +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("collapses whitespace and caps length", () => { - const long = "x".repeat(200); - assert.equal(cleanTitle(` ${long} `).length, 120); + it("takes the first line only", () => { + assert.equal(cleanTitle("feat: one\nfeat: two"), "feat: one"); }); -}); -describe("extractJsonObject", () => { - it("unwraps markdown fences", () => { - const raw = '```json\n{"title":"a","body":"b"}\n```'; - assert.equal(extractJsonObject(raw), '{"title":"a","body":"b"}'); + it("strips Title: prefixes", () => { + assert.equal(cleanTitle("Title: chore: docs"), "chore: docs"); }); - it("finds object inside prose", () => { - const raw = 'Here you go:\n{"title":"feat: x","body":"## Summary\\n- y"}\nThanks'; - assert.ok(extractJsonObject(raw).startsWith("{")); - assert.ok(extractJsonObject(raw).endsWith("}")); + it("collapses whitespace and caps length", () => { + const long = "x".repeat(200); + assert.equal(cleanTitle(` ${long} `).length, 120); }); }); -describe("parsePrContent", () => { - it("parses clean JSON", () => { - const r = parsePrContent('{"title":"feat: ship pr","body":"## Summary\\n- one"}'); - assert.equal(r.title, "feat: ship pr"); - assert.match(r.body, /Summary/); - }); - - it("parses fenced JSON", () => { - const r = parsePrContent('```json\n{"title":"fix: bug","body":"details"}\n```'); - assert.equal(r.title, "fix: bug"); - assert.equal(r.body, "details"); +describe("cleanBody", () => { + it("keeps markdown summary intact", () => { + const md = "## Summary\n\n- one\n- two\n"; + assert.equal(cleanBody(md), md.trim()); }); - it("falls back to TITLE:/BODY: layout", () => { - const r = parsePrContent("TITLE: chore: tidy\nBODY:\n## Summary\n- a\n"); - assert.equal(r.title, "chore: tidy"); - assert.match(r.body, /Summary/); + 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("throws on garbage", () => { - assert.throws(() => parsePrContent("not json at all"), /valid JSON|PR/); + it("strips Body: prefix", () => { + assert.match(cleanBody("Body:\n## Summary\n- x"), /## Summary/); }); - it("throws when title missing", () => { - assert.throws(() => parsePrContent('{"body":"only body"}'), /title/i); + it("strips think tags", () => { + assert.equal(cleanBody("secret\n## Summary\n- y"), "## Summary\n- y"); }); }); From 1f2f69c9b0f03a0b0c46e807e3b156c4845a4d09 Mon Sep 17 00:00:00 2001 From: Jubarte Date: Fri, 10 Jul 2026 09:33:40 -0300 Subject: [PATCH 3/3] fix: update PR workflow and AI to reuse existing open PRs --- CHANGELOG.md | 2 + README.md | 14 +++---- scripts/cli.ts | 108 +++++++++++++++++++++++++++++++++++++------------ 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b3da6..0bb0b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - **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 8d18aee..95feefb 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ No browser required. No accounts beyond an optional OpenRouter key. Works in any |----------|-----------| | `gg cnp` | `git add .` → AI commit → `git push` | | `gg cnp pr` | commit → push → AI PR title/body → `gh pr create` | -| `gg pr [base]` | push branch → AI PR → open PR into `base` (default: main) | +| `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` | @@ -128,7 +128,7 @@ All of these work with **`gg`**, **`gitgen`**, or **`git-gen`**. | `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, skip confirm | +| `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` | @@ -159,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` / `pr` | Skip confirmation (restore discard, or PR create) | +| `-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` | @@ -247,7 +247,7 @@ 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 confirm +gg pr -y # default base, no base prompt # Merge into a non-main branch gg m feature/checkout develop @@ -311,8 +311,8 @@ Flow: 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. Preview title/body → confirm (skip with `-y`) -6. `gh pr create --base … --head … --title … --body …` and print the PR URL +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. @@ -321,7 +321,7 @@ Without an API key, title/body fall back to the latest commit subject and the co | `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 + create without confirm | +| `gg pr -y` | Default base, no base prompt (creates immediately either way) | --- diff --git a/scripts/cli.ts b/scripts/cli.ts index 36e1c5a..bcedd6b 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -28,7 +28,7 @@ * 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 / PR 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`). */ @@ -384,12 +384,55 @@ async function resolvePrContent(base: string, head: string): Promise 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`. - * `baseHint` is optional; interactive prompt defaults to origin's default branch. - * @returns true if a PR was created, false if the user aborted the confirm step. + * 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 { +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" @@ -418,11 +461,22 @@ async function createPullRequest(baseHint?: string): Promise { 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) + // Preview body (compact) while gh creates the PR const preview = content.body .split("\n") .slice(0, 12) @@ -434,14 +488,6 @@ async function createPullRequest(baseHint?: string): Promise { if (content.body.split("\n").length > 12) log(` ${c.dim("…")}`); } - if (!yes) { - const ok = await confirm("Create this pull request?"); - if (!ok) { - log(` ${c.dim("PR aborted")}`); - return false; - } - } - const url = ( await withProgress("gh pr create", () => runCapture( @@ -470,7 +516,6 @@ async function createPullRequest(baseHint?: string): Promise { if (url) { log(row("url", c.underline(c.cyan(url)))); } - return true; } async function promptLine(question: string, defaultValue?: string): Promise { @@ -989,7 +1034,7 @@ function helpText(): string { ["start", "start", "open the web app with this folder"], ["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 branch → AI title/body → gh pr create"], + ["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"], @@ -1025,9 +1070,9 @@ ${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 + open PR (asks base)")} + ${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 confirm")} + ${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")} @@ -1083,11 +1128,22 @@ async function main() { messageFlag, push || wantPr ? "feat: update" : "feat: save progress" ); - await runSteps(async () => { - await git(["add", "."]); - await git(["commit", "-m", message]); - if (push && !wantPr) await git(["push"], { progress: true }); - }); + // 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; @@ -1098,8 +1154,8 @@ async function main() { if (wantPr) { // createPullRequest pushes (with -u if needed) then runs gh pr create try { - const created = await createPullRequest(prBase); - if (created) log(` ${sym.ok} ${c.green("done")}`); + 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))); @@ -1118,8 +1174,8 @@ async function main() { await git(["add", "."]); await git(["commit", "-m", message]); } - const created = await createPullRequest(baseArg); - if (created) log(` ${sym.ok} ${c.green("done")}`); + 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)));