diff --git a/README.md b/README.md index ed9fa52..f82e76c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Generate **Conventional Commits** messages and pull request descriptions using A - **Conventional Commits** — `feat(scope): description` format by default - **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all" - **Inline editing** — edit AI-generated messages right in the terminal with cursor movement -- **AI-generated PRs** — create GitHub or Gitea pull requests with generated title and body +- **AI-generated PRs** — create GitHub, Gitea, or GitLab pull requests with generated title and body - **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more - **Review before commit** — confirm, edit, or abort the generated message - **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies @@ -84,7 +84,7 @@ $ gai commit Select files to stage: 2 unstaged files available - ↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel + ↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel ❯ □ Select all □ src/ai.ts modified @@ -108,6 +108,7 @@ $ gai commit - GitHub remotes use the `gh` CLI - Gitea remotes use the `tea` CLI +- GitLab remotes use the `glab` CLI - Unknown remotes prompt you to choose a platform The command compares your current branch against the default branch, pushes the branch if needed, generates a PR title/body from the branch commits and diff, then asks for confirmation before creating the PR. diff --git a/index.ts b/index.ts index 696246f..4d962d1 100644 --- a/index.ts +++ b/index.ts @@ -13,7 +13,8 @@ import { commit, } from "./src/git"; import { selectFiles } from "./src/selector"; -import { selectOne } from "./src/menu"; +import { BACK, selectOne } from "./src/menu"; +import type { PromptBack } from "./src/menu"; import { collectProjectContext } from "./src/context"; import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt"; import { generateCommitMessage } from "./src/ai"; @@ -29,8 +30,6 @@ import { getBranchDiff, detectPlatform, getRemoteHostname, - checkCLI, - checkAuth, createPR, } from "./src/pr"; import type { Platform } from "./src/pr"; @@ -77,41 +76,333 @@ function ask(question: string): Promise { }); } -async function handleConfig() { - const config = await loadConfig(); +type ConfigKey = keyof Config; - console.log(`\n ${BOLD}Current configuration:${RESET}`); - console.log( - ` API Key: ${config.apiKey ? "****" + config.apiKey.slice(-4) : `${YELLOW}(not set)${RESET}`}`, - ); - console.log(` API Base: ${config.apiBase}`); - console.log(` Model: ${config.model}`); - console.log(` Max Tokens: ${config.maxTokens}`); - console.log(` Temperature: ${config.temperature}`); +interface ConfigField { + key: ConfigKey; + label: string; + format: (config: Config) => string; + initialEditValue: (config: Config) => string; + parse: (value: string) => { value: Config[ConfigKey] } | { error: string }; +} - console.log( - `\n ${BOLD}Enter new values (leave empty to keep current):${RESET}`, - ); +const CONFIG_FIELDS: ConfigField[] = [ + { + key: "apiKey", + label: "API Key", + format: (config) => + config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW}(not set)${RESET}`, + initialEditValue: () => "", + parse: (value) => ({ value }), + }, + { + key: "apiBase", + label: "API Base", + format: (config) => config.apiBase, + initialEditValue: (config) => config.apiBase, + parse: (value) => ({ value }), + }, + { + key: "model", + label: "Model", + format: (config) => config.model, + initialEditValue: (config) => config.model, + parse: (value) => ({ value }), + }, + { + key: "maxTokens", + label: "Max Tokens", + format: (config) => String(config.maxTokens), + initialEditValue: (config) => String(config.maxTokens), + parse: (value) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return { error: "Max Tokens must be a positive integer." }; + } + return { value: parsed }; + }, + }, + { + key: "temperature", + label: "Temperature", + format: (config) => String(config.temperature), + initialEditValue: (config) => String(config.temperature), + parse: (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return { error: "Temperature must be a finite number." }; + } + return { value: parsed }; + }, + }, +]; - const apiKey = await ask(" API Key: "); - const apiBase = await ask(` API Base [${config.apiBase}]: `); - const model = await ask(` Model [${config.model}]: `); - const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `); - const temperature = await ask(` Temperature [${config.temperature}]: `); +function visibleLength(value: string) { + return value.replace(/\x1b\[[0-9;]*m/g, "").length; +} - const updates: Partial = {}; - if (apiKey) updates.apiKey = apiKey; - if (apiBase) updates.apiBase = apiBase; - if (model) updates.model = model; - if (maxTokens) updates.maxTokens = parseInt(maxTokens); - if (temperature) updates.temperature = parseFloat(temperature); +function clearLine() { + process.stdout.write("\r\x1b[2K"); +} - if (Object.keys(updates).length > 0) { - await saveConfig(updates); - console.log(`\n ${GREEN}Configuration saved!${RESET}`); - } else { - console.log("\n No changes."); +function moveUp(lines: number) { + if (lines > 0) process.stdout.write(`\x1b[${lines}A`); +} + +function renderConfigPage( + config: Config, + cursor: number, + previousLines: number, + status: string | null, + editState: { buffer: string; cursor: number } | null, +) { + if (previousLines > 0) { + for (let i = 0; i < previousLines; i++) { + clearLine(); + process.stdout.write("\n"); + } + moveUp(previousLines); } + + const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2; + const lines = [ + "", + ` ${BOLD}Configuration${RESET}`, + editState + ? ` ${DIM}editing · enter save · esc cancel · ctrl+c cancel${RESET}` + : ` ${DIM}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET}`, + "", + ]; + + let activeValueOffset = 0; + for (let i = 0; i < CONFIG_FIELDS.length; i++) { + const field = CONFIG_FIELDS[i]!; + const active = i === cursor; + const pointer = active ? `${CYAN}❯${RESET}` : " "; + const marker = active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`; + const label = active ? `${BOLD}${field.label}${RESET}` : field.label; + const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label))); + const value = active && editState ? editState.buffer : field.format(config); + if (active && editState) { + activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`); + } + lines.push(` ${pointer} ${marker} ${label}${padding}${value}`); + } + + if (status) { + lines.push("", ` ${status}`); + } + + for (const line of lines) { + process.stdout.write(`${line}\n`); + } + if (editState) { + moveUp(lines.length - (4 + cursor)); + const column = activeValueOffset + editState.cursor; + process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`); + } else { + moveUp(lines.length); + } + return lines.length; +} + +async function handleConfig(): Promise<"done" | "back"> { + if (process.stdin.isTTY !== true) { + console.error(` ${RED}Error: Configuration requires a TTY.${RESET}`); + process.exit(1); + } + + let config = await loadConfig(); + let cursor = 0; + let renderedLines = 0; + let escapeBuf = ""; + let status: string | null = null; + let editState: { buffer: string; cursor: number } | null = null; + let renderedCursorRow = 0; + const wasRaw = process.stdin.isRaw; + + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdout.write("\x1b[?25l"); + + const render = () => { + moveUp(renderedCursorRow); + renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState); + renderedCursorRow = editState ? 4 + cursor : 0; + process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l"); + }; + + render(); + + return new Promise((resolve) => { + const finish = (value: "done" | "back") => { + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + moveUp(renderedCursorRow); + for (let i = 0; i < renderedLines; i++) { + clearLine(); + process.stdout.write("\n"); + } + moveUp(renderedLines); + process.stdout.write("\x1b[?25h"); + resolve(value); + }; + + const saveEdit = async () => { + if (!editState) return; + const field = CONFIG_FIELDS[cursor]!; + const value = editState.buffer.trim(); + editState = null; + + if (value === "") { + status = `${DIM}No changes.${RESET}`; + } else { + const parsed = field.parse(value); + if ("error" in parsed) { + status = `${RED}${parsed.error}${RESET}`; + } else { + await saveConfig({ [field.key]: parsed.value } as Partial); + config = await loadConfig(); + status = `${GREEN}${field.label} saved.${RESET}`; + } + } + + render(); + }; + + const onData = (data: Buffer) => { + const key = data.toString(); + const UP = "\x1b[A"; + const DOWN = "\x1b[B"; + const LEFT = "\x1b[D"; + const ALT_UP = "\x1bOA"; + const ALT_DOWN = "\x1bOB"; + const ALT_LEFT = "\x1bOD"; + const SPACE = " "; + const ENTER = "\r"; + const ESC = "\x1b"; + const RIGHT = "\x1b[C"; + const ALT_RIGHT = "\x1bOC"; + const CTRL_C = "\x03"; + const BACKSPACE = "\x7f"; + + if (editState) { + if (key === CTRL_C || key === ESC) { + editState = null; + status = `${DIM}No changes.${RESET}`; + render(); + return; + } + if (key === ENTER) { + void saveEdit(); + return; + } + if (key === "\x01") { + editState.cursor = 0; + render(); + return; + } + if (key === "\x05") { + editState.cursor = editState.buffer.length; + render(); + return; + } + if (key === "\x0b") { + editState.buffer = editState.buffer.slice(0, editState.cursor); + render(); + return; + } + if (key === "\x15") { + editState.buffer = editState.buffer.slice(editState.cursor); + editState.cursor = 0; + render(); + return; + } + if (key === BACKSPACE) { + if (editState.cursor > 0) { + editState.buffer = + editState.buffer.slice(0, editState.cursor - 1) + + editState.buffer.slice(editState.cursor); + editState.cursor--; + render(); + } + return; + } + if (key === LEFT || key === ALT_LEFT) { + if (editState.cursor > 0) editState.cursor--; + render(); + return; + } + if (key === RIGHT || key === ALT_RIGHT) { + if (editState.cursor < editState.buffer.length) editState.cursor++; + render(); + return; + } + if (key.startsWith("\x1b[")) { + if (key === "\x1b[H" || key === "\x1b[1~") { + editState.cursor = 0; + } else if (key === "\x1b[F" || key === "\x1b[4~") { + editState.cursor = editState.buffer.length; + } else if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) { + editState.buffer = + editState.buffer.slice(0, editState.cursor) + + editState.buffer.slice(editState.cursor + 1); + } + render(); + return; + } + if (key >= " " && key !== "\x7f") { + editState.buffer = + editState.buffer.slice(0, editState.cursor) + + key + + editState.buffer.slice(editState.cursor); + editState.cursor += key.length; + render(); + } + return; + } + + const action = (() => { + if (key === UP || key === ALT_UP) return "up"; + if (key === DOWN || key === ALT_DOWN) return "down"; + if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return "back"; + if (key === SPACE) return "edit"; + if (key === CTRL_C) return "cancel"; + if (key === "\x1b" || key.startsWith("\x1b[")) { + escapeBuf = key; + return null; + } + if (escapeBuf) { + const next = escapeBuf + key; + escapeBuf = /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next; + if (next === UP || next === ALT_UP) return "up"; + if (next === DOWN || next === ALT_DOWN) return "down"; + if (next === LEFT || next === ALT_LEFT) return "back"; + } + return null; + })(); + + if (action === "cancel") return finish("done"); + if (action === "back") return finish("back"); + if (action === "up" && cursor > 0) { + cursor--; + status = null; + render(); + } else if (action === "down" && cursor < CONFIG_FIELDS.length - 1) { + cursor++; + status = null; + render(); + } else if (action === "edit") { + const value = CONFIG_FIELDS[cursor]!.initialEditValue(config); + editState = { buffer: value, cursor: value.length }; + status = null; + render(); + } + }; + + process.stdin.on("data", onData); + }); } async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { @@ -306,22 +597,34 @@ async function showMenu(): Promise { process.exit(1); } - const selected = await selectOne({ - title: "gai", - subtitle: "Choose a workflow", - items: MENU_ACTIONS.map((action) => ({ - label: action.label, - value: action.key, - description: action.description, - })), - }); + while (true) { + const selected = await selectOne({ + title: "gai", + subtitle: "Choose a workflow", + allowBack: false, + items: MENU_ACTIONS.map((action) => ({ + label: action.label, + value: action.key, + description: action.description, + })), + }); - if (selected === "commit") await handleCommit(false, false); - if (selected === "pr") await handlePR(false); - if (selected === "config") await handleConfig(); + if (selected === null || selected === BACK) return; + + const result = + selected === "commit" + ? await handleCommit(false, false) + : selected === "pr" + ? await handlePR(false) + : await handleConfig(); + + if (result !== "back") return; + } } -async function selectPlatform(hostname: string): Promise { +async function selectPlatform( + hostname: string, +): Promise { if (!process.stdin.isTTY) { console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`); process.exit(1); @@ -333,20 +636,15 @@ async function selectPlatform(hostname: string): Promise { 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" }, ], }); } -async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error( - ` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`, - ); - process.exit(1); - } - +async function handleCommit( + autoMode: boolean, + dryRun: boolean, +): Promise<"done" | "back"> { if (!(await isGitRepo())) { console.error(` ${RED}Error: Not a git repository.${RESET}`); process.exit(1); @@ -356,8 +654,8 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { const unstagedFiles = await getUnstagedFiles(); if (stagedFiles.length === 0 && unstagedFiles.length === 0) { - console.log(" Nothing to commit."); - return; + console.log(` ${DIM}Nothing to commit. No staged or unstaged changes.${RESET}`); + return "done"; } if (unstagedFiles.length > 0) { @@ -368,6 +666,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { ); } else { const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return "back"; if (selected.length > 0) { await stageFiles(selected); console.log( @@ -379,8 +678,17 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { const diff = await getStagedDiff(); if (!diff) { - console.log(" No staged changes to commit."); - return; + console.log(` ${DIM}Nothing to commit. No staged changes to commit.${RESET}`); + return "done"; + } + + const config = await loadConfig(); + + if (!config.apiKey) { + console.error( + ` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`, + ); + process.exit(1); } const MAX_DIFF_SIZE = 15000; @@ -418,7 +726,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { console.log(` ${GREEN}${message}${RESET}`); await copyToClipboard(message); console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`); - return; + return "done"; } const action = await confirmCommit(message); @@ -456,9 +764,11 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { : ` Aborted. Message: ${message}`, ); } + + return "done"; } -async function handlePR(draft: boolean): Promise { +async function handlePR(draft: boolean): Promise<"done" | "back"> { const config = await loadConfig(); if (!config.apiKey) { @@ -477,6 +787,7 @@ async function handlePR(draft: boolean): Promise { if (!platform) { const hostname = (await getRemoteHostname()) || "unknown"; const chosen = await selectPlatform(hostname); + if (chosen === BACK) return "back"; if (!chosen) { console.log(" Aborted."); process.exit(0); @@ -484,21 +795,10 @@ async function handlePR(draft: boolean): Promise { platform = chosen; } - const platformLabel = platform === "github" ? "GitHub" : "Gitea"; + const platformLabel = + platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea"; console.log(` Using: ${CYAN}${platformLabel}${RESET}`); - const cliError = checkCLI(platform); - if (cliError) { - console.error(` ${RED}Error: ${cliError}${RESET}`); - process.exit(1); - } - - const authError = await checkAuth(platform); - if (authError) { - console.error(` ${RED}Error: ${authError}${RESET}`); - process.exit(1); - } - const baseBranch = await getDefaultBranch(); const branchName = await getBranchName(); @@ -516,10 +816,13 @@ async function handlePR(draft: boolean): Promise { const commits = await getBranchCommits(baseBranch); if (commits.length === 0) { - console.error( - ` ${RED}Error: No commits on ${branchName} compared to ${baseBranch}. Commit something first.${RESET}`, - ); - process.exit(1); + 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 "done"; } console.log( @@ -535,7 +838,7 @@ async function handlePR(draft: boolean): Promise { if (answer.toLowerCase() === "n") { console.log(" Aborted."); - return; + return "done"; } console.log(` Pushing ${CYAN}${branchName}${RESET}...`); @@ -604,7 +907,7 @@ async function handlePR(draft: boolean): Promise { if (lower === "n") { console.log(" Aborted."); - return; + return "done"; } if (lower === "e") { @@ -612,7 +915,7 @@ async function handlePR(draft: boolean): Promise { const newBody = await ask(" Body (optional): "); if (!newTitle.trim()) { console.log(" Aborted."); - return; + return "done"; } title = newTitle; body = newBody; @@ -630,6 +933,8 @@ async function handlePR(draft: boolean): Promise { ); process.exit(1); } + + return "done"; } async function main() { diff --git a/package.json b/package.json index a1f4f69..ad09ed6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gai", - "version": "0.1.0", + "version": "0.1.2", "description": "AI-powered git commit message generator", "module": "index.ts", "type": "module", diff --git a/src/menu.ts b/src/menu.ts index 11fe07a..4d59295 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -2,11 +2,17 @@ import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; const UP = "\x1b[A"; const DOWN = "\x1b[B"; +const LEFT = "\x1b[D"; const ALT_UP = "\x1bOA"; const ALT_DOWN = "\x1bOB"; +const ALT_LEFT = "\x1bOD"; const SPACE = " "; const ENTER = "\r"; const CTRL_C = "\x03"; +const BACKSPACE = "\x7f"; + +export const BACK = Symbol("prompt-back"); +export type PromptBack = typeof BACK; export interface Choice { label: string; @@ -19,6 +25,7 @@ interface BasePromptOptions { title: string; subtitle?: string; cancelMessage?: string; + allowBack?: boolean; } interface SinglePromptOptions extends BasePromptOptions { @@ -55,11 +62,13 @@ function padLabel(label: string, width: number) { return label + " ".repeat(Math.max(1, width - visibleLength(label))); } -function controls(mode: "single" | "multi") { +function controls(mode: "single" | "multi", showBackHint = true) { if (mode === "single") { - return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`; + const backHint = showBackHint ? " · ←/backspace back" : ""; + return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`; } - return `${DIM}↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel${RESET}`; + const backHint = showBackHint ? " · ←/backspace back" : ""; + return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`; } function renderPrompt(lines: string[], previousLines: number) { @@ -89,6 +98,9 @@ function clearPrompt(lines: number) { function normalizeKey(key: string, escapeBuf: string) { if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" }; + if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) { + return { action: "back", escapeBuf: "" }; + } if (key === SPACE) return { action: "space", escapeBuf: "" }; if (key === ENTER) return { action: "enter", escapeBuf: "" }; if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; @@ -101,6 +113,9 @@ function normalizeKey(key: string, escapeBuf: string) { const next = escapeBuf + key; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; + if (next === LEFT || next === ALT_LEFT) { + return { action: "back", escapeBuf: "" }; + } return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next, @@ -126,7 +141,7 @@ function createLines( ]; if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`); - lines.push(` ${controls(mode)}`, ""); + lines.push(` ${controls(mode, options.allowBack !== false)}`, ""); for (let i = 0; i < options.items.length; i++) { const item = options.items[i]!; @@ -156,7 +171,7 @@ function ensureTTY(title: string) { export async function selectOne( options: SinglePromptOptions, -): Promise { +): Promise { ensureTTY(options.title); let cursor = 0; @@ -178,7 +193,7 @@ export async function selectOne( render(); return new Promise((resolve) => { - const finish = (value: T | null) => { + const finish = (value: T | null | PromptBack) => { process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdin.removeListener("data", onData); @@ -195,6 +210,9 @@ export async function selectOne( escapeBuf = result.escapeBuf; if (result.action === "cancel") return finish(null); + if (result.action === "back" && options.allowBack !== false) { + return finish(BACK); + } if (result.action === "up" && cursor > 0) { cursor--; render(); @@ -212,7 +230,7 @@ export async function selectOne( export async function selectMany( options: MultiPromptOptions, -): Promise { +): Promise { ensureTTY(options.title); const items: Choice[] = options.selectAllLabel @@ -267,7 +285,7 @@ export async function selectMany( render(); return new Promise((resolve) => { - const finish = (value: T[] | null) => { + const finish = (value: T[] | null | PromptBack) => { process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdin.removeListener("data", onData); @@ -284,6 +302,9 @@ export async function selectMany( escapeBuf = result.escapeBuf; if (result.action === "cancel") return finish(null); + if (result.action === "back" && options.allowBack !== false) { + return finish(BACK); + } if (result.action === "up" && cursor > 0) { cursor--; render(); diff --git a/src/pr.ts b/src/pr.ts index 1bd758e..eafd7df 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -1,4 +1,4 @@ -export type Platform = "github" | "gitea"; +export type Platform = "github" | "gitea" | "gitlab"; export async function getDefaultBranch(): Promise { try { @@ -93,16 +93,23 @@ function parseRemoteHostname(url: string): string | null { return hostname || null; } +export function detectPlatformFromHostname(hostname: string | null): Platform | null { + if (!hostname) return null; + + if (hostname === "github.com") return "github"; + if (hostname === "gitlab.com" || hostname.includes("gitlab")) { + return "gitlab"; + } + if (hostname.includes("gitea")) return "gitea"; + return null; +} + export async function detectPlatform(): Promise { try { const url = await Bun.$`git remote get-url origin`.quiet().text(); const hostname = parseRemoteHostname(url); - if (!hostname) return null; - - if (hostname === "github.com") return "github"; - if (hostname.includes("gitea")) return "gitea"; - return null; + return detectPlatformFromHostname(hostname); } catch { return null; } @@ -117,37 +124,6 @@ export async function getRemoteHostname(): Promise { } } -export function checkCLI(platform: Platform): string | null { - const bin = platform === "github" ? "gh" : "tea"; - const path = Bun.which(bin); - if (!path) { - if (platform === "github") { - return "GitHub CLI (gh) not found. Install: brew install gh"; - } - return "Gitea CLI (tea) not found. Install from: https://gitea.com/gitea/tea"; - } - return null; -} - -export async function checkAuth(platform: Platform): Promise { - if (platform === "github") { - try { - await Bun.$`gh auth status`.quiet(); - return null; - } catch { - return "Not authenticated with GitHub CLI. Run: gh auth login"; - } - } - - try { - const result = await Bun.$`tea logins list`.quiet().text(); - if (result.trim()) return null; - return "Not authenticated with Gitea CLI. Run: tea login add"; - } catch { - return "Not authenticated with Gitea CLI. Run: tea login add"; - } -} - export async function createPR( platform: Platform, title: string, @@ -186,6 +162,37 @@ export async function createPR( return match?.[1] ?? stdout.trim(); } + if (platform === "gitlab") { + const args = [ + "mr", + "create", + "--title", + title, + "--description", + body, + "--target-branch", + base, + ]; + if (draft) args.push("--draft"); + + const proc = Bun.spawn(["glab", ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + throw new Error( + stderr.trim() || `glab mr create failed (exit code ${exitCode})`, + ); + } + + const match = stdout.match(/(https?:\/\/[^\s]+)/); + return match?.[1] ?? stdout.trim(); + } + const args = [ "pulls", "create", diff --git a/src/selector.ts b/src/selector.ts index 9702cc5..96019b1 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,11 +1,12 @@ import type { FileEntry } from "./types"; import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; -import { selectMany } from "./menu"; +import { BACK, selectMany } from "./menu"; +import type { PromptBack } from "./menu"; export async function selectFiles( stagedFiles: FileEntry[], unstagedFiles: FileEntry[], -): Promise { +): Promise { if (unstagedFiles.length === 0) return []; if (stagedFiles.length > 0) { @@ -30,6 +31,7 @@ export async function selectFiles( }); if (selected === null) process.exit(1); + if (selected === BACK) return BACK; if (selected.length > 0) { process.stdout.write( diff --git a/test/commit.test.ts b/test/commit.test.ts new file mode 100644 index 0000000..c748b13 --- /dev/null +++ b/test/commit.test.ts @@ -0,0 +1,50 @@ +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { test, expect, describe } from "bun:test"; + +async function run(command: string[], cwd: string, env: Record = {}) { + const proc = Bun.spawn(command, { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + PATH: process.env.PATH ?? "", + HOME: env.HOME ?? process.env.HOME ?? "", + ...env, + }, + }); + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + return { exitCode, stdout, stderr }; +} + +describe("commit command", () => { + test("clean repository exits without requiring API key", async () => { + const repo = mkdtempSync(join(tmpdir(), "gai-clean-repo-")); + const home = mkdtempSync(join(tmpdir(), "gai-empty-home-")); + + const init = await run(["git", "init"], repo, { HOME: home }); + expect(init.exitCode).toBe(0); + + const result = await run( + ["bun", "run", join(import.meta.dir, "..", "index.ts"), "commit"], + repo, + { + HOME: home, + GAI_API_KEY: "", + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Nothing to commit"); + expect(result.stdout).toContain("No staged or unstaged changes"); + expect(result.stderr).not.toContain("API key not set"); + expect(result.stderr).not.toContain("requires a TTY"); + }); +}); diff --git a/test/pr.test.ts b/test/pr.test.ts new file mode 100644 index 0000000..8d9fd19 --- /dev/null +++ b/test/pr.test.ts @@ -0,0 +1,19 @@ +import { test, expect, describe } from "bun:test"; +import { detectPlatformFromHostname } from "../src/pr"; + +describe("pr platform detection", () => { + test("detects supported hosted platforms", () => { + expect(detectPlatformFromHostname("github.com")).toBe("github"); + expect(detectPlatformFromHostname("gitlab.com")).toBe("gitlab"); + }); + + test("detects self-hosted platform hostnames", () => { + expect(detectPlatformFromHostname("gitlab.example.com")).toBe("gitlab"); + expect(detectPlatformFromHostname("gitea.example.com")).toBe("gitea"); + }); + + test("returns null for unknown hostnames", () => { + expect(detectPlatformFromHostname("git.example.com")).toBeNull(); + expect(detectPlatformFromHostname(null)).toBeNull(); + }); +});