feat(config): add interactive config editor and GitLab PR support #4

Merged
Mplan merged 8 commits from v0.1.2 into main 2026-06-12 09:00:29 +08:00
8 changed files with 536 additions and 131 deletions
+3 -2
View File
@@ -22,7 +22,7 @@ Generate **Conventional Commits** messages and pull request descriptions using A
- **Conventional Commits** — `feat(scope): description` format by default - **Conventional Commits** — `feat(scope): description` format by default
- **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all" - **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 - **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 - **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more
- **Review before commit** — confirm, edit, or abort the generated message - **Review before commit** — confirm, edit, or abort the generated message
- **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies - **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies
@@ -84,7 +84,7 @@ $ gai commit
Select files to stage: Select files to stage:
2 unstaged files available 2 unstaged files available
↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel ↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel
□ Select all □ Select all
□ src/ai.ts modified □ src/ai.ts modified
@@ -108,6 +108,7 @@ $ gai commit
- GitHub remotes use the `gh` CLI - GitHub remotes use the `gh` CLI
- Gitea remotes use the `tea` CLI - Gitea remotes use the `tea` CLI
- GitLab remotes use the `glab` CLI
- Unknown remotes prompt you to choose a platform - 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. 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.
+386 -81
View File
@@ -13,7 +13,8 @@ import {
commit, commit,
} from "./src/git"; } from "./src/git";
import { selectFiles } from "./src/selector"; 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 { collectProjectContext } from "./src/context";
import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt"; import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt";
import { generateCommitMessage } from "./src/ai"; import { generateCommitMessage } from "./src/ai";
@@ -29,8 +30,6 @@ import {
getBranchDiff, getBranchDiff,
detectPlatform, detectPlatform,
getRemoteHostname, getRemoteHostname,
checkCLI,
checkAuth,
createPR, createPR,
} from "./src/pr"; } from "./src/pr";
import type { Platform } from "./src/pr"; import type { Platform } from "./src/pr";
@@ -77,41 +76,333 @@ function ask(question: string): Promise<string> {
}); });
} }
async function handleConfig() { type ConfigKey = keyof Config;
const config = await loadConfig();
console.log(`\n ${BOLD}Current configuration:${RESET}`); interface ConfigField {
console.log( key: ConfigKey;
` API Key: ${config.apiKey ? "****" + config.apiKey.slice(-4) : `${YELLOW}(not set)${RESET}`}`, label: string;
); format: (config: Config) => string;
console.log(` API Base: ${config.apiBase}`); initialEditValue: (config: Config) => string;
console.log(` Model: ${config.model}`); parse: (value: string) => { value: Config[ConfigKey] } | { error: string };
console.log(` Max Tokens: ${config.maxTokens}`); }
console.log(` Temperature: ${config.temperature}`);
console.log( const CONFIG_FIELDS: ConfigField[] = [
`\n ${BOLD}Enter new values (leave empty to keep current):${RESET}`, {
); 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: "); function visibleLength(value: string) {
const apiBase = await ask(` API Base [${config.apiBase}]: `); return value.replace(/\x1b\[[0-9;]*m/g, "").length;
const model = await ask(` Model [${config.model}]: `); }
const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `);
const temperature = await ask(` Temperature [${config.temperature}]: `);
const updates: Partial<Config> = {}; function clearLine() {
if (apiKey) updates.apiKey = apiKey; process.stdout.write("\r\x1b[2K");
if (apiBase) updates.apiBase = apiBase; }
if (model) updates.model = model;
if (maxTokens) updates.maxTokens = parseInt(maxTokens);
if (temperature) updates.temperature = parseFloat(temperature);
if (Object.keys(updates).length > 0) { function moveUp(lines: number) {
await saveConfig(updates); if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
console.log(`\n ${GREEN}Configuration saved!${RESET}`); }
} else {
console.log("\n No changes."); 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>);
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"> { async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
@@ -306,22 +597,34 @@ async function showMenu(): Promise<void> {
process.exit(1); process.exit(1);
} }
const selected = await selectOne({ while (true) {
title: "gai", const selected = await selectOne({
subtitle: "Choose a workflow", title: "gai",
items: MENU_ACTIONS.map((action) => ({ subtitle: "Choose a workflow",
label: action.label, allowBack: false,
value: action.key, items: MENU_ACTIONS.map((action) => ({
description: action.description, label: action.label,
})), value: action.key,
}); description: action.description,
})),
});
if (selected === "commit") await handleCommit(false, false); if (selected === null || selected === BACK) return;
if (selected === "pr") await handlePR(false);
if (selected === "config") await handleConfig(); 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<Platform | null> { async function selectPlatform(
hostname: string,
): Promise<Platform | null | PromptBack> {
if (!process.stdin.isTTY) { if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`); console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`);
process.exit(1); process.exit(1);
@@ -333,20 +636,15 @@ async function selectPlatform(hostname: string): Promise<Platform | null> {
items: [ items: [
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" }, { label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea 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<void> { async function handleCommit(
const config = await loadConfig(); autoMode: boolean,
dryRun: boolean,
if (!config.apiKey) { ): Promise<"done" | "back"> {
console.error(
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
);
process.exit(1);
}
if (!(await isGitRepo())) { if (!(await isGitRepo())) {
console.error(` ${RED}Error: Not a git repository.${RESET}`); console.error(` ${RED}Error: Not a git repository.${RESET}`);
process.exit(1); process.exit(1);
@@ -356,8 +654,8 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && unstagedFiles.length === 0) { if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.log(" Nothing to commit."); console.log(` ${DIM}Nothing to commit. No staged or unstaged changes.${RESET}`);
return; return "done";
} }
if (unstagedFiles.length > 0) { if (unstagedFiles.length > 0) {
@@ -368,6 +666,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
); );
} else { } else {
const selected = await selectFiles(stagedFiles, unstagedFiles); const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return "back";
if (selected.length > 0) { if (selected.length > 0) {
await stageFiles(selected); await stageFiles(selected);
console.log( console.log(
@@ -379,8 +678,17 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const diff = await getStagedDiff(); const diff = await getStagedDiff();
if (!diff) { if (!diff) {
console.log(" No staged changes to commit."); console.log(` ${DIM}Nothing to commit. No staged changes to commit.${RESET}`);
return; 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; const MAX_DIFF_SIZE = 15000;
@@ -418,7 +726,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
console.log(` ${GREEN}${message}${RESET}`); console.log(` ${GREEN}${message}${RESET}`);
await copyToClipboard(message); await copyToClipboard(message);
console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`); console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`);
return; return "done";
} }
const action = await confirmCommit(message); const action = await confirmCommit(message);
@@ -456,9 +764,11 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
: ` Aborted. Message: ${message}`, : ` Aborted. Message: ${message}`,
); );
} }
return "done";
} }
async function handlePR(draft: boolean): Promise<void> { async function handlePR(draft: boolean): Promise<"done" | "back"> {
const config = await loadConfig(); const config = await loadConfig();
if (!config.apiKey) { if (!config.apiKey) {
@@ -477,6 +787,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (!platform) { if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown"; const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname); const chosen = await selectPlatform(hostname);
if (chosen === BACK) return "back";
if (!chosen) { if (!chosen) {
console.log(" Aborted."); console.log(" Aborted.");
process.exit(0); process.exit(0);
@@ -484,21 +795,10 @@ async function handlePR(draft: boolean): Promise<void> {
platform = chosen; platform = chosen;
} }
const platformLabel = platform === "github" ? "GitHub" : "Gitea"; const platformLabel =
platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea";
console.log(` Using: ${CYAN}${platformLabel}${RESET}`); 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 baseBranch = await getDefaultBranch();
const branchName = await getBranchName(); const branchName = await getBranchName();
@@ -516,10 +816,13 @@ async function handlePR(draft: boolean): Promise<void> {
const commits = await getBranchCommits(baseBranch); const commits = await getBranchCommits(baseBranch);
if (commits.length === 0) { if (commits.length === 0) {
console.error( const choice = await selectOne({
` ${RED}Error: No commits on ${branchName} compared to ${baseBranch}. Commit something first.${RESET}`, title: "No commits to compare",
); subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`,
process.exit(1); items: [{ label: "Back", value: "back" as const }],
});
if (choice === null) process.exit(0);
return "done";
} }
console.log( console.log(
@@ -535,7 +838,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (answer.toLowerCase() === "n") { if (answer.toLowerCase() === "n") {
console.log(" Aborted."); console.log(" Aborted.");
return; return "done";
} }
console.log(` Pushing ${CYAN}${branchName}${RESET}...`); console.log(` Pushing ${CYAN}${branchName}${RESET}...`);
@@ -604,7 +907,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (lower === "n") { if (lower === "n") {
console.log(" Aborted."); console.log(" Aborted.");
return; return "done";
} }
if (lower === "e") { if (lower === "e") {
@@ -612,7 +915,7 @@ async function handlePR(draft: boolean): Promise<void> {
const newBody = await ask(" Body (optional): "); const newBody = await ask(" Body (optional): ");
if (!newTitle.trim()) { if (!newTitle.trim()) {
console.log(" Aborted."); console.log(" Aborted.");
return; return "done";
} }
title = newTitle; title = newTitle;
body = newBody; body = newBody;
@@ -630,6 +933,8 @@ async function handlePR(draft: boolean): Promise<void> {
); );
process.exit(1); process.exit(1);
} }
return "done";
} }
async function main() { async function main() {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gai", "name": "gai",
"version": "0.1.0", "version": "0.1.2",
"description": "AI-powered git commit message generator", "description": "AI-powered git commit message generator",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
+29 -8
View File
@@ -2,11 +2,17 @@ import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
const UP = "\x1b[A"; const UP = "\x1b[A";
const DOWN = "\x1b[B"; const DOWN = "\x1b[B";
const LEFT = "\x1b[D";
const ALT_UP = "\x1bOA"; const ALT_UP = "\x1bOA";
const ALT_DOWN = "\x1bOB"; const ALT_DOWN = "\x1bOB";
const ALT_LEFT = "\x1bOD";
const SPACE = " "; const SPACE = " ";
const ENTER = "\r"; const ENTER = "\r";
const CTRL_C = "\x03"; const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-back");
export type PromptBack = typeof BACK;
export interface Choice<T> { export interface Choice<T> {
label: string; label: string;
@@ -19,6 +25,7 @@ interface BasePromptOptions {
title: string; title: string;
subtitle?: string; subtitle?: string;
cancelMessage?: string; cancelMessage?: string;
allowBack?: boolean;
} }
interface SinglePromptOptions<T> extends BasePromptOptions { interface SinglePromptOptions<T> extends BasePromptOptions {
@@ -55,11 +62,13 @@ function padLabel(label: string, width: number) {
return label + " ".repeat(Math.max(1, width - visibleLength(label))); return label + " ".repeat(Math.max(1, width - visibleLength(label)));
} }
function controls(mode: "single" | "multi") { function controls(mode: "single" | "multi", showBackHint = true) {
if (mode === "single") { 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) { function renderPrompt(lines: string[], previousLines: number) {
@@ -89,6 +98,9 @@ function clearPrompt(lines: number) {
function normalizeKey(key: string, escapeBuf: string) { function normalizeKey(key: string, escapeBuf: string) {
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", 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 === SPACE) return { action: "space", escapeBuf: "" };
if (key === ENTER) return { action: "enter", escapeBuf: "" }; if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
@@ -101,6 +113,9 @@ function normalizeKey(key: string, escapeBuf: string) {
const next = escapeBuf + key; const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) {
return { action: "back", escapeBuf: "" };
}
return { return {
action: null, action: null,
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
@@ -126,7 +141,7 @@ function createLines<T>(
]; ];
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`); 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++) { for (let i = 0; i < options.items.length; i++) {
const item = options.items[i]!; const item = options.items[i]!;
@@ -156,7 +171,7 @@ function ensureTTY(title: string) {
export async function selectOne<T>( export async function selectOne<T>(
options: SinglePromptOptions<T>, options: SinglePromptOptions<T>,
): Promise<T | null> { ): Promise<T | null | PromptBack> {
ensureTTY(options.title); ensureTTY(options.title);
let cursor = 0; let cursor = 0;
@@ -178,7 +193,7 @@ export async function selectOne<T>(
render(); render();
return new Promise((resolve) => { return new Promise((resolve) => {
const finish = (value: T | null) => { const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
@@ -195,6 +210,9 @@ export async function selectOne<T>(
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) { if (result.action === "up" && cursor > 0) {
cursor--; cursor--;
render(); render();
@@ -212,7 +230,7 @@ export async function selectOne<T>(
export async function selectMany<T>( export async function selectMany<T>(
options: MultiPromptOptions<T>, options: MultiPromptOptions<T>,
): Promise<T[] | null> { ): Promise<T[] | null | PromptBack> {
ensureTTY(options.title); ensureTTY(options.title);
const items: Choice<T | null>[] = options.selectAllLabel const items: Choice<T | null>[] = options.selectAllLabel
@@ -267,7 +285,7 @@ export async function selectMany<T>(
render(); render();
return new Promise((resolve) => { return new Promise((resolve) => {
const finish = (value: T[] | null) => { const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
@@ -284,6 +302,9 @@ export async function selectMany<T>(
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) { if (result.action === "up" && cursor > 0) {
cursor--; cursor--;
render(); render();
+44 -37
View File
@@ -1,4 +1,4 @@
export type Platform = "github" | "gitea"; export type Platform = "github" | "gitea" | "gitlab";
export async function getDefaultBranch(): Promise<string> { export async function getDefaultBranch(): Promise<string> {
try { try {
@@ -93,16 +93,23 @@ function parseRemoteHostname(url: string): string | null {
return hostname || 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<Platform | null> { export async function detectPlatform(): Promise<Platform | null> {
try { try {
const url = await Bun.$`git remote get-url origin`.quiet().text(); const url = await Bun.$`git remote get-url origin`.quiet().text();
const hostname = parseRemoteHostname(url); const hostname = parseRemoteHostname(url);
if (!hostname) return null; return detectPlatformFromHostname(hostname);
if (hostname === "github.com") return "github";
if (hostname.includes("gitea")) return "gitea";
return null;
} catch { } catch {
return null; return null;
} }
@@ -117,37 +124,6 @@ export async function getRemoteHostname(): Promise<string | null> {
} }
} }
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<string | null> {
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( export async function createPR(
platform: Platform, platform: Platform,
title: string, title: string,
@@ -186,6 +162,37 @@ export async function createPR(
return match?.[1] ?? stdout.trim(); 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 = [ const args = [
"pulls", "pulls",
"create", "create",
+4 -2
View File
@@ -1,11 +1,12 @@
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; 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( export async function selectFiles(
stagedFiles: FileEntry[], stagedFiles: FileEntry[],
unstagedFiles: FileEntry[], unstagedFiles: FileEntry[],
): Promise<string[]> { ): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return []; if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) { if (stagedFiles.length > 0) {
@@ -30,6 +31,7 @@ export async function selectFiles(
}); });
if (selected === null) process.exit(1); if (selected === null) process.exit(1);
if (selected === BACK) return BACK;
if (selected.length > 0) { if (selected.length > 0) {
process.stdout.write( process.stdout.write(
+50
View File
@@ -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<string, string> = {}) {
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");
});
});
+19
View File
@@ -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();
});
});