Compare commits
7 Commits
v0.1.1
...
12e71a0af7
| Author | SHA1 | Date | |
|---|---|---|---|
| 12e71a0af7 | |||
| e1354e8651 | |||
| 7e662b25cc | |||
| 5bb2dc8e8a | |||
| 1dbfac7985 | |||
| c8626ff69a | |||
| 7e9cab5805 |
@@ -0,0 +1,25 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bun-build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: bun run build
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
# gai
|
# gai
|
||||||
|
|
||||||
**AI-powered git commit message generator**
|
**AI-powered Git commit and pull request helper**
|
||||||
|
|
||||||
[](https://github.com/mplan/git-ai)
|
[](https://git.catpl.top/Mplan/gai/releases)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://bun.sh)
|
[](https://bun.sh)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
Generate **Conventional Commits** messages using AI — based on your project context, code diff, and commit history.
|
Generate **Conventional Commits** messages and pull request descriptions using AI, based on your project context, code diff, branch changes, and commit history.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,13 +18,14 @@ Generate **Conventional Commits** messages using AI — based on your project co
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter
|
- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter
|
||||||
- **3-layer context** — project overview, staged diff, and recent commit history
|
- **Context-aware commits** — reads project overview, staged diff, and recent commit history
|
||||||
- **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
|
||||||
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, and more
|
- **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
|
- **Review before commit** — confirm, edit, or abort the generated message
|
||||||
- **Zero dependencies** — built entirely on Bun native APIs
|
- **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ gai
|
|||||||
|
|
||||||
# Or directly generate a commit message
|
# Or directly generate a commit message
|
||||||
gai commit
|
gai commit
|
||||||
|
|
||||||
|
# Or create an AI-generated pull request
|
||||||
|
gai pr
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -49,6 +53,8 @@ gai Open interactive menu
|
|||||||
gai commit Generate commit message (interactive file selection)
|
gai commit Generate commit message (interactive file selection)
|
||||||
gai commit --auto Auto-stage all changed files
|
gai commit --auto Auto-stage all changed files
|
||||||
gai commit -d Generate message without committing
|
gai commit -d Generate message without committing
|
||||||
|
gai pr Create a PR with AI-generated title and body
|
||||||
|
gai pr --draft Create a draft PR
|
||||||
gai config Configure API settings
|
gai config Configure API settings
|
||||||
gai --help Show help
|
gai --help Show help
|
||||||
gai --version Show version
|
gai --version Show version
|
||||||
@@ -78,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
|
||||||
@@ -96,6 +102,22 @@ $ gai commit
|
|||||||
1 file changed, 45 insertions(+), 12 deletions(-)
|
1 file changed, 45 insertions(+), 12 deletions(-)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pull Request Flow
|
||||||
|
|
||||||
|
`gai pr` detects the remote platform from `origin`:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gai pr
|
||||||
|
gai pr --draft
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Via `gai config` (interactive)
|
### Via `gai config` (interactive)
|
||||||
@@ -110,7 +132,7 @@ gai config
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GAI_API_KEY` | — | **Required.** Your API key |
|
| `GAI_API_KEY` | — | **Required.** Your API key |
|
||||||
| `GAI_API_BASE` | `https://api.deepseek.com/v1` | API base URL |
|
| `GAI_API_BASE` | `https://api.deepseek.com/v1` | API base URL |
|
||||||
| `GAI_MODEL` | `deepseek-chat` | Model name |
|
| `GAI_MODEL` | `deepseek-v4-flash` | Model name |
|
||||||
| `GAI_MAX_TOKENS` | `500` | Max response tokens |
|
| `GAI_MAX_TOKENS` | `500` | Max response tokens |
|
||||||
| `GAI_TEMPERATURE` | `0.7` | Sampling temperature |
|
| `GAI_TEMPERATURE` | `0.7` | Sampling temperature |
|
||||||
|
|
||||||
@@ -121,7 +143,7 @@ Bun auto-loads `.env` — no dotenv needed:
|
|||||||
```bash
|
```bash
|
||||||
GAI_API_KEY=sk-your-key
|
GAI_API_KEY=sk-your-key
|
||||||
GAI_API_BASE=https://api.deepseek.com/v1
|
GAI_API_BASE=https://api.deepseek.com/v1
|
||||||
GAI_MODEL=deepseek-chat
|
GAI_MODEL=deepseek-v4-flash
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using other providers
|
### Using other providers
|
||||||
@@ -170,14 +192,16 @@ GAI_MODEL=anthropic/claude-sonnet-4
|
|||||||
│ └─ Directory structure │
|
│ └─ Directory structure │
|
||||||
│ │
|
│ │
|
||||||
│ 2. Collect code changes │
|
│ 2. Collect code changes │
|
||||||
│ └─ git diff --staged │
|
│ ├─ git diff --staged for commits │
|
||||||
|
│ └─ branch diff for pull requests │
|
||||||
│ │
|
│ │
|
||||||
│ 3. Collect commit history │
|
│ 3. Collect commit history │
|
||||||
│ └─ git log --oneline -10 │
|
│ ├─ git log --oneline -10 for commits │
|
||||||
|
│ └─ branch commits for pull requests │
|
||||||
│ │
|
│ │
|
||||||
│ 4. Build prompt → Call AI API │
|
│ 4. Build prompt → Call AI API │
|
||||||
│ │
|
│ │
|
||||||
│ 5. Review → Confirm → Commit │
|
│ 5. Review → Confirm → Commit or Create PR │
|
||||||
└─────────────────────────────────────────────┘
|
└─────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +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(
|
||||||
|
autoMode: boolean,
|
||||||
|
dryRun: boolean,
|
||||||
|
): Promise<"done" | "back"> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
|
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
@@ -357,7 +664,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
|
|||||||
|
|
||||||
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
||||||
console.log(" Nothing to commit.");
|
console.log(" Nothing to commit.");
|
||||||
return;
|
return "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unstagedFiles.length > 0) {
|
if (unstagedFiles.length > 0) {
|
||||||
@@ -368,6 +675,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(
|
||||||
@@ -380,7 +688,7 @@ 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(" No staged changes to commit.");
|
||||||
return;
|
return "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
@@ -535,7 +835,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 +904,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 +912,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 +930,8 @@ async function handlePR(draft: boolean): Promise<void> {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user