ai-git-tool 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +146 -0
  2. package/dist/index.js +893 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # ai-git
2
+
3
+ Groq API を使って、ステージ済み差分からコミットメッセージと PR 説明文を自動生成する TypeScript 製 CLI です。
4
+
5
+ ## 必要環境
6
+
7
+ - Node.js `18` 以上
8
+ - `git`
9
+ - Groq API キー(`GROQ_API_KEY`)
10
+
11
+ ## セットアップ
12
+
13
+ ### npm からインストール(推奨)
14
+
15
+ ```bash
16
+ npm install -g @imakento/ai-git
17
+ ```
18
+
19
+ ### ローカル開発版をリンク
20
+
21
+ ```bash
22
+ npm install
23
+ npm run build
24
+ npm link
25
+ ```
26
+
27
+ `npm link` 後は、どの Git リポジトリでも `ai-git` が使えます。
28
+
29
+ ## 環境変数
30
+
31
+ ```bash
32
+ export GROQ_API_KEY="your_api_key"
33
+ ```
34
+
35
+ 任意でモデル指定も可能です。
36
+
37
+ ```bash
38
+ export GROQ_MODEL="llama-3.1-8b-instant"
39
+ ```
40
+
41
+ API キーの取得先: [Groq Console](https://console.groq.com/keys)
42
+
43
+ ## 使い方
44
+
45
+ ### コミットメッセージ生成
46
+
47
+ 1. 先に変更をステージ
48
+
49
+ ```bash
50
+ git add .
51
+ ```
52
+
53
+ 2. コミットメッセージを生成
54
+
55
+ ```bash
56
+ # 通常(タイトル + 箇条書き本文)
57
+ ai-git commit
58
+ ```
59
+
60
+ デフォルト言語は日本語です。
61
+
62
+ ```bash
63
+ # 今回だけ英語で生成
64
+ ai-git commit --lang en
65
+
66
+ # デフォルト言語を永続化
67
+ ai-git --set-lang en
68
+ ai-git --set-lang ja
69
+ ```
70
+
71
+ 3. 確認プロンプトで選択
72
+
73
+ - `y`: そのままコミット
74
+ - `n`: 中止
75
+ - `e`: エディタで編集してからコミット
76
+
77
+ ### PR作成
78
+
79
+ 1. ブランチで作業してコミット
80
+
81
+ ```bash
82
+ git checkout -b feature/new-feature
83
+ git add .
84
+ ai-git commit
85
+ ```
86
+
87
+ 2. PR説明文を生成してPR作成
88
+
89
+ ```bash
90
+ ai-git pr
91
+
92
+ # 言語指定も可能
93
+ ai-git pr --lang en
94
+ ```
95
+
96
+ `ai-git pr` は自動的に以下を実行します:
97
+
98
+ - ブランチがまだpushされていない場合、`git push -u origin <branch>` を実行
99
+ - ローカルに新しいコミットがある場合、`git push` を実行
100
+ - PR説明文を生成してPRを作成
101
+
102
+ 3. 確認プロンプトで選択
103
+
104
+ - `y`: PRを作成
105
+ - `n`: 中止
106
+ - `e`: エディタで編集してから作成
107
+
108
+ **前提条件:**
109
+
110
+ - GitHub CLI (`gh`) がインストール済み ([インストール方法](https://cli.github.com/))
111
+ - `gh auth login` で認証済み
112
+
113
+ **生成されるPR説明文のフォーマット:**
114
+
115
+ - ## Summary: 変更の概要(2-3文)
116
+ - ## Changes: 具体的な変更内容(箇条書き)
117
+ - ## Test plan: テスト方法(箇条書き)
118
+
119
+ ### ブランチ作成(自動命名)
120
+
121
+ 変更差分(ステージ済み + 未ステージ)からブランチ名を推定して作成します。
122
+
123
+ ```bash
124
+ ai-git checkout
125
+ ```
126
+
127
+ 命名例:
128
+
129
+ - `feat/auth-login-flow`
130
+ - `fix/api-error-handling`
131
+ - `docs/readme-update`
132
+
133
+ 推定できない場合は `feat/update-xxxxxx` のような名前を自動で使います。
134
+
135
+ ## 開発
136
+
137
+ ```bash
138
+ npm run dev
139
+ npm run build
140
+ ```
141
+
142
+ ## トラブルシューティング
143
+
144
+ - `No staged changes found. Run \`git add\` first.`: `git add` してから実行
145
+ - `GROQ_API_KEY が未設定です`: `GROQ_API_KEY` を設定
146
+ - `413 Request too large` / `TPM` 超過: `git add -p` でステージを分割
package/dist/index.js ADDED
@@ -0,0 +1,893 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ const child_process_1 = require("child_process");
41
+ const readline = __importStar(require("readline"));
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ const openai_1 = __importDefault(require("openai"));
46
+ // ── フラグ解析 ──────────────────────────────────────────
47
+ const args = process.argv.slice(2);
48
+ // サブコマンドの抽出
49
+ const subcommand = args[0];
50
+ const subcommandArgs = args.slice(1);
51
+ const showHelp = args.includes("--help") || args.includes("-h") || subcommand === "help";
52
+ const setLangArg = getOptionValue(args, "--set-lang");
53
+ const langArg = getOptionValue(subcommandArgs, "--lang");
54
+ const CONFIG_DIR = path.join(os.homedir(), ".ai-commit");
55
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
56
+ const defaultGroqModel = "llama-3.1-8b-instant";
57
+ const COMMIT_DIFF_COMPACT_CHARS = 3500;
58
+ const PR_COMMITS_COMPACT_CHARS = 1800;
59
+ const PR_DIFF_COMPACT_CHARS = 3500;
60
+ if (setLangArg) {
61
+ const nextLang = parseLanguage(setLangArg);
62
+ if (!nextLang) {
63
+ console.error("Error: --set-lang は ja または en を指定してください。");
64
+ process.exit(1);
65
+ }
66
+ saveConfig({ language: nextLang });
67
+ console.log(`✅ デフォルト言語を '${nextLang}' に保存しました。`);
68
+ process.exit(0);
69
+ }
70
+ if (showHelp) {
71
+ console.log(`
72
+ Usage: ai-git <command> [options]
73
+
74
+ Commands:
75
+ commit Generate commit message from staged changes
76
+ pr Generate PR description and create pull request
77
+ checkout Create branch from current changes
78
+
79
+ Commit Options:
80
+ --lang <ja|en> Set language for this run
81
+
82
+ PR Options:
83
+ --lang <ja|en> Set language for this run
84
+
85
+ Global Options:
86
+ --set-lang <ja|en> Persist default language
87
+ --help, -h Show this help message
88
+
89
+ Environment:
90
+ GROQ_API_KEY Your Groq API key (required)
91
+ GROQ_MODEL Optional model name (default: llama-3.1-8b-instant)
92
+ `);
93
+ process.exit(0);
94
+ }
95
+ if (!subcommand ||
96
+ (subcommand !== "commit" && subcommand !== "pr" && subcommand !== "checkout")) {
97
+ console.error("Error: Please specify a command: 'commit', 'pr' or 'checkout'");
98
+ console.error("Run 'ai-git --help' for usage information");
99
+ process.exit(1);
100
+ }
101
+ const language = resolveLanguage(langArg);
102
+ // ── git diff 取得 ────────────────────────────────────────
103
+ function getStagedDiff() {
104
+ try {
105
+ return (0, child_process_1.execSync)("git diff --cached", { encoding: "utf-8" });
106
+ }
107
+ catch {
108
+ console.error("Error: Failed to run git diff --cached");
109
+ process.exit(1);
110
+ }
111
+ }
112
+ // ── Groq APIでコミットメッセージ生成 ───────────────────
113
+ async function generateCommitMessage(diff) {
114
+ const inputDiff = truncateByChars(diff, COMMIT_DIFF_COMPACT_CHARS);
115
+ const prompt = buildPrompt(inputDiff, language);
116
+ try {
117
+ return await generateText(prompt);
118
+ }
119
+ catch (error) {
120
+ if (isRequestTooLargeError(error)) {
121
+ const smallerDiff = truncateByChars(diff, 1800);
122
+ return generateText(buildPrompt(smallerDiff, language));
123
+ }
124
+ handleGroqError(error);
125
+ process.exit(1);
126
+ }
127
+ }
128
+ function buildPrompt(diff, lang) {
129
+ if (lang === "ja") {
130
+ return `あなたは git のコミットメッセージ作成の専門家です。
131
+ 次の git diff から、Conventional Commits 形式の詳細コミットメッセージを生成してください。
132
+
133
+ ルール:
134
+ - 1行目: <type>(<optional scope>): <short description>(72文字以内)
135
+ - 2行目は空行
136
+ - 本文は "- " で始まる箇条書き
137
+ - 箇条書きは 3-5 行
138
+ - WHAT と WHY を書き、HOW は書かない
139
+ - 命令形を使う
140
+ - 出力はコミットメッセージ本文のみ(説明文は不要)
141
+ - 説明文は日本語にする
142
+
143
+ 出力例:
144
+ feat(auth): Google ログインを追加する
145
+
146
+ - auth.ts に GoogleAuthProvider の設定を追加する
147
+ - トークン期限切れ時の更新処理を追加する
148
+ - ネットワーク障害時のエラーハンドリングを追加する
149
+
150
+ Git diff:
151
+ ${diff}`;
152
+ }
153
+ return `You are an expert at writing git commit messages.
154
+ Generate a detailed commit message in Conventional Commits format for the following git diff.
155
+
156
+ Rules:
157
+ - First line: <type>(<optional scope>): <short description> (max 72 chars)
158
+ - Blank line after the first line
159
+ - Body: bullet points starting with "- " explaining WHAT and WHY
160
+ - 3-5 bullet points max
161
+ - Use imperative mood ("add" not "added")
162
+ - Output ONLY the commit message, nothing else
163
+
164
+ Example:
165
+ feat(auth): add Google login with Firebase Auth
166
+
167
+ - Add GoogleAuthProvider setup in auth.ts
168
+ - Handle token refresh on expiry
169
+ - Add error handling for network failures
170
+
171
+ Git diff:
172
+ ${diff}`;
173
+ }
174
+ // ── ユーザー確認 ─────────────────────────────────────────
175
+ function askUser(question) {
176
+ const rl = readline.createInterface({
177
+ input: process.stdin,
178
+ output: process.stdout,
179
+ });
180
+ return new Promise((resolve) => {
181
+ rl.question(question, (answer) => {
182
+ rl.close();
183
+ resolve(answer.trim().toLowerCase());
184
+ });
185
+ });
186
+ }
187
+ // ── エディタで編集 ───────────────────────────────────────
188
+ function editInEditor(message) {
189
+ const tmpFile = path.join(os.tmpdir(), `commit-msg-${Date.now()}.txt`);
190
+ fs.writeFileSync(tmpFile, message, "utf-8");
191
+ const editor = process.env.EDITOR || "vi";
192
+ const result = (0, child_process_1.spawnSync)(editor, [tmpFile], { stdio: "inherit" });
193
+ if (result.error) {
194
+ console.error(`Error: Failed to open editor: ${result.error.message}`);
195
+ process.exit(1);
196
+ }
197
+ const edited = fs.readFileSync(tmpFile, "utf-8").trim();
198
+ fs.unlinkSync(tmpFile);
199
+ return edited;
200
+ }
201
+ // ── git commit 実行 ──────────────────────────────────────
202
+ function doCommit(message) {
203
+ const result = (0, child_process_1.spawnSync)("git", ["commit", "-m", message], {
204
+ stdio: "inherit",
205
+ });
206
+ if (result.status !== 0) {
207
+ console.error("Error: git commit failed");
208
+ process.exit(1);
209
+ }
210
+ }
211
+ async function mainCheckout() {
212
+ const suggested = await suggestBranchName();
213
+ const branchName = ensureAvailableBranchName(suggested);
214
+ const result = (0, child_process_1.spawnSync)("git", ["checkout", "-b", branchName], {
215
+ stdio: "inherit",
216
+ });
217
+ if (result.status !== 0) {
218
+ console.error("❌ ブランチ作成に失敗しました。");
219
+ process.exit(1);
220
+ }
221
+ console.log(`✅ ブランチを作成しました: ${branchName}`);
222
+ }
223
+ async function suggestBranchName() {
224
+ const files = getChangedFiles();
225
+ const diff = getCombinedDiff();
226
+ const fromAI = await suggestBranchNameWithAI(files, diff);
227
+ if (fromAI) {
228
+ return fromAI;
229
+ }
230
+ const type = inferBranchType(files, diff);
231
+ const topic = inferTopic(files, diff);
232
+ if (!topic) {
233
+ return `${type}/update-${randomSuffix()}`;
234
+ }
235
+ return `${type}/${topic}`;
236
+ }
237
+ async function suggestBranchNameWithAI(files, diff) {
238
+ if (!process.env.GROQ_API_KEY) {
239
+ return null;
240
+ }
241
+ const compactFiles = files.slice(0, 30).join("\n");
242
+ const compactDiff = truncateByChars(diff, 2800);
243
+ const prompt = `You are an expert at naming git branches.
244
+ Generate exactly one branch name from the following changes.
245
+
246
+ Rules:
247
+ - Output only branch name, nothing else
248
+ - Format: <type>/<slug>
249
+ - type must be one of: feat, fix, docs, chore, refactor, test, style
250
+ - slug must use kebab-case
251
+ - Prefer meaningful feature intent over noisy token names
252
+ - If changes look like new feature or command addition, prefer feat
253
+
254
+ Changed files:
255
+ ${compactFiles}
256
+
257
+ Diff:
258
+ ${compactDiff}`;
259
+ try {
260
+ const raw = await generateText(prompt);
261
+ return normalizeBranchCandidate(raw);
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ function normalizeBranchCandidate(raw) {
268
+ const firstLine = raw.split("\n")[0]?.trim().toLowerCase() || "";
269
+ const cleaned = firstLine.replace(/[`"' ]/g, "");
270
+ const normalized = cleaned.replace(/^feature\//, "feat/");
271
+ const [head, ...tail] = normalized.split("/");
272
+ if (!head || tail.length === 0) {
273
+ return null;
274
+ }
275
+ const validTypes = new Set([
276
+ "feat",
277
+ "fix",
278
+ "docs",
279
+ "chore",
280
+ "refactor",
281
+ "test",
282
+ "style",
283
+ ]);
284
+ if (!validTypes.has(head)) {
285
+ return null;
286
+ }
287
+ const slug = sanitizeBranchPart(tail.join("-"));
288
+ if (!slug || slug.length < 3) {
289
+ return null;
290
+ }
291
+ return `${head}/${slug}`;
292
+ }
293
+ function getChangedFiles() {
294
+ const staged = getCommandOutput("git diff --cached --name-only");
295
+ const unstaged = getCommandOutput("git diff --name-only");
296
+ const merged = `${staged}\n${unstaged}`
297
+ .split("\n")
298
+ .map((v) => v.trim())
299
+ .filter(Boolean);
300
+ return Array.from(new Set(merged));
301
+ }
302
+ function getCombinedDiff() {
303
+ const staged = getCommandOutput("git diff --cached");
304
+ const unstaged = getCommandOutput("git diff");
305
+ return `${staged}\n${unstaged}`;
306
+ }
307
+ function getCommandOutput(command) {
308
+ try {
309
+ return (0, child_process_1.execSync)(command, { encoding: "utf-8", stdio: "pipe" });
310
+ }
311
+ catch {
312
+ return "";
313
+ }
314
+ }
315
+ function inferBranchType(files, diff) {
316
+ const lowerFiles = files.map((f) => f.toLowerCase());
317
+ const lowerDiff = diff.toLowerCase();
318
+ if (lowerFiles.length > 0 && lowerFiles.every((f) => f.endsWith(".md"))) {
319
+ return "docs";
320
+ }
321
+ if (lowerFiles.some((f) => f.includes("readme") || f.endsWith(".md")) &&
322
+ lowerFiles.length <= 2) {
323
+ return "docs";
324
+ }
325
+ if (lowerFiles.some((f) => f.includes("package.json") || f.includes("lock")) &&
326
+ lowerFiles.length <= 2) {
327
+ return "chore";
328
+ }
329
+ if (lowerDiff.includes("fix") ||
330
+ lowerDiff.includes("bug") ||
331
+ lowerDiff.includes("error")) {
332
+ return "fix";
333
+ }
334
+ return "feat";
335
+ }
336
+ function inferTopic(files, diff) {
337
+ const stopWords = new Set([
338
+ "src",
339
+ "dist",
340
+ "index",
341
+ "readme",
342
+ "package",
343
+ "lock",
344
+ "json",
345
+ "ts",
346
+ "js",
347
+ "md",
348
+ "diff",
349
+ "added",
350
+ "removed",
351
+ "file",
352
+ "files",
353
+ "change",
354
+ "changes",
355
+ "update",
356
+ "updated",
357
+ "line",
358
+ "lines",
359
+ ]);
360
+ // ファイル名ベースを優先し、diff本文は補助的に使う
361
+ const fileTokens = files
362
+ .flatMap((f) => f.split(/[\/._-]+/))
363
+ .map((v) => v.toLowerCase())
364
+ .filter((v) => isGoodTopicToken(v, stopWords));
365
+ const diffTokens = diff
366
+ .toLowerCase()
367
+ .split(/[^a-z0-9]+/)
368
+ .filter((v) => isGoodTopicToken(v, stopWords))
369
+ .slice(0, 20);
370
+ const tokens = [...fileTokens, ...diffTokens]
371
+ .map((v) => sanitizeBranchPart(v))
372
+ .filter(Boolean);
373
+ if (tokens.length === 0) {
374
+ return "";
375
+ }
376
+ const unique = Array.from(new Set(tokens));
377
+ return unique.slice(0, 3).join("-");
378
+ }
379
+ function isGoodTopicToken(token, stopWords) {
380
+ if (token.length < 3) {
381
+ return false;
382
+ }
383
+ if (stopWords.has(token)) {
384
+ return false;
385
+ }
386
+ // 16進ハッシュ断片(例: 22e133f, cd9353f)を除外
387
+ if (/^[a-f0-9]{6,}$/.test(token)) {
388
+ return false;
389
+ }
390
+ // 数字主体トークンを除外
391
+ if (/^\d+$/.test(token)) {
392
+ return false;
393
+ }
394
+ return true;
395
+ }
396
+ function sanitizeBranchPart(value) {
397
+ return value
398
+ .toLowerCase()
399
+ .replace(/[^a-z0-9-]/g, "-")
400
+ .replace(/-+/g, "-")
401
+ .replace(/^-|-$/g, "");
402
+ }
403
+ function randomSuffix() {
404
+ return Math.random().toString(36).slice(2, 8);
405
+ }
406
+ function ensureAvailableBranchName(baseName) {
407
+ const [headRaw, ...tailRaw] = baseName.split("/");
408
+ const head = sanitizeBranchPart(headRaw || "feat");
409
+ const tail = sanitizeBranchPart(tailRaw.join("-") || `update-${randomSuffix()}`);
410
+ const normalized = `${head || "feat"}/${tail}`;
411
+ if (!branchExists(normalized)) {
412
+ return normalized;
413
+ }
414
+ let i = 2;
415
+ while (branchExists(`${normalized}-${i}`)) {
416
+ i += 1;
417
+ }
418
+ return `${normalized}-${i}`;
419
+ }
420
+ function branchExists(name) {
421
+ try {
422
+ (0, child_process_1.execSync)(`git show-ref --verify --quiet refs/heads/${name}`, {
423
+ stdio: "pipe",
424
+ });
425
+ return true;
426
+ }
427
+ catch {
428
+ return false;
429
+ }
430
+ }
431
+ // ── メイン ───────────────────────────────────────────────
432
+ async function main() {
433
+ if (subcommand === "checkout") {
434
+ await mainCheckout();
435
+ return;
436
+ }
437
+ if (subcommand === "pr") {
438
+ await mainPR();
439
+ return;
440
+ }
441
+ const diff = getStagedDiff();
442
+ if (!diff.trim()) {
443
+ console.log("No staged changes found. Run `git add` first.");
444
+ process.exit(1);
445
+ }
446
+ console.log("🤖 コミットメッセージを生成中... (compact summary input)");
447
+ const message = await generateCommitMessage(diff);
448
+ console.log(`\n📝 Generated commit message:\n`);
449
+ // 詳細モードは複数行なのでインデントして表示
450
+ message.split("\n").forEach((line) => {
451
+ console.log(` ${line}`);
452
+ });
453
+ console.log();
454
+ const answer = await askUser("Use this message? [y/n/e(edit)]: ");
455
+ let finalMessage = message;
456
+ if (answer === "e" || answer === "edit") {
457
+ finalMessage = editInEditor(message);
458
+ if (!finalMessage) {
459
+ console.log("Aborted: empty message.");
460
+ process.exit(0);
461
+ }
462
+ console.log(`\n✏️ Edited message:\n`);
463
+ finalMessage.split("\n").forEach((line) => {
464
+ console.log(` ${line}`);
465
+ });
466
+ console.log();
467
+ }
468
+ else if (answer !== "y" && answer !== "yes") {
469
+ console.log("Aborted.");
470
+ process.exit(0);
471
+ }
472
+ doCommit(finalMessage);
473
+ console.log(`\n✅ Committed successfully!`);
474
+ }
475
+ main().catch((err) => {
476
+ console.error("❌ 予期しないエラー:", err.message);
477
+ process.exit(1);
478
+ });
479
+ function getOptionValue(argv, name) {
480
+ const idx = argv.indexOf(name);
481
+ if (idx === -1) {
482
+ return undefined;
483
+ }
484
+ return argv[idx + 1];
485
+ }
486
+ function parseLanguage(value) {
487
+ if (!value) {
488
+ return null;
489
+ }
490
+ if (value === "ja" || value === "en") {
491
+ return value;
492
+ }
493
+ return null;
494
+ }
495
+ function resolveLanguage(langValue) {
496
+ const fromFlag = parseLanguage(langValue);
497
+ if (langValue && !fromFlag) {
498
+ console.error("Error: --lang は ja または en を指定してください。");
499
+ process.exit(1);
500
+ }
501
+ if (fromFlag) {
502
+ return fromFlag;
503
+ }
504
+ const config = loadConfig();
505
+ return config?.language ?? "ja";
506
+ }
507
+ function loadConfig() {
508
+ try {
509
+ if (!fs.existsSync(CONFIG_PATH)) {
510
+ return null;
511
+ }
512
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
513
+ const parsed = JSON.parse(raw);
514
+ const lang = parseLanguage(parsed.language);
515
+ if (!lang) {
516
+ return null;
517
+ }
518
+ return { language: lang };
519
+ }
520
+ catch {
521
+ return null;
522
+ }
523
+ }
524
+ function saveConfig(config) {
525
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
526
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
527
+ }
528
+ function getGroqClient() {
529
+ const apiKey = process.env.GROQ_API_KEY;
530
+ if (!apiKey) {
531
+ console.error("❌ GROQ_API_KEY が未設定です。");
532
+ console.error(' 取得先: https://console.groq.com/keys\n 例: export GROQ_API_KEY="your_api_key"');
533
+ process.exit(1);
534
+ }
535
+ return new openai_1.default({
536
+ apiKey,
537
+ baseURL: "https://api.groq.com/openai/v1",
538
+ });
539
+ }
540
+ async function generateText(prompt) {
541
+ const client = getGroqClient();
542
+ const model = process.env.GROQ_MODEL || defaultGroqModel;
543
+ const res = await client.chat.completions.create({
544
+ model,
545
+ temperature: 0.2,
546
+ messages: [{ role: "user", content: prompt }],
547
+ });
548
+ return res.choices[0]?.message?.content?.trim() || "";
549
+ }
550
+ function handleGroqError(error) {
551
+ const message = error instanceof Error ? error.message : String(error);
552
+ const lower = message.toLowerCase();
553
+ if (lower.includes("429") ||
554
+ lower.includes("quota") ||
555
+ lower.includes("rate")) {
556
+ console.error("❌ Groq API の利用上限に達しました(429: quota/rate limit)。");
557
+ console.error(" - 少し待って再実行してください");
558
+ console.error(" - https://console.groq.com で利用枠を確認してください");
559
+ console.error(" - 必要なら別プロジェクトの API キーを利用してください");
560
+ return;
561
+ }
562
+ if (lower.includes("401") ||
563
+ lower.includes("403") ||
564
+ lower.includes("api key")) {
565
+ console.error("❌ Groq API 認証エラーです。GROQ_API_KEY を確認してください。");
566
+ console.error(" 取得先: https://console.groq.com/keys");
567
+ return;
568
+ }
569
+ console.error("❌ Groq API 呼び出しに失敗しました。");
570
+ console.error(` 詳細: ${message}`);
571
+ }
572
+ // ── PR生成用の関数群 ─────────────────────────────────────
573
+ function checkGHCLI() {
574
+ try {
575
+ (0, child_process_1.execSync)("gh --version", { encoding: "utf-8", stdio: "pipe" });
576
+ }
577
+ catch {
578
+ if (language === "ja") {
579
+ console.error("❌ GitHub CLI (gh) がインストールされていません。");
580
+ console.error(" インストール: https://cli.github.com/");
581
+ }
582
+ else {
583
+ console.error("❌ GitHub CLI (gh) is not installed.");
584
+ console.error(" Install from: https://cli.github.com/");
585
+ }
586
+ process.exit(1);
587
+ }
588
+ }
589
+ function checkGHAuth() {
590
+ if (process.env.GH_TOKEN) {
591
+ return;
592
+ }
593
+ try {
594
+ (0, child_process_1.execSync)("gh auth status", { encoding: "utf-8", stdio: "pipe" });
595
+ }
596
+ catch {
597
+ if (language === "ja") {
598
+ console.error("❌ GitHub CLI の認証が必要です。");
599
+ console.error(" 次を実行してください: gh auth login");
600
+ console.error(" もしくは GH_TOKEN を環境変数に設定してください。");
601
+ }
602
+ else {
603
+ console.error("❌ GitHub CLI authentication is required.");
604
+ console.error(" Run: gh auth login");
605
+ console.error(" Or set GH_TOKEN environment variable.");
606
+ }
607
+ process.exit(1);
608
+ }
609
+ }
610
+ function getPullRequestURL(branch) {
611
+ try {
612
+ const remoteUrl = (0, child_process_1.execSync)("git remote get-url origin", {
613
+ encoding: "utf-8",
614
+ stdio: "pipe",
615
+ }).trim();
616
+ const repoPath = parseGitHubRepoPath(remoteUrl);
617
+ if (!repoPath) {
618
+ return null;
619
+ }
620
+ return `https://github.com/${repoPath}/pull/new/${branch}`;
621
+ }
622
+ catch {
623
+ return null;
624
+ }
625
+ }
626
+ function parseGitHubRepoPath(remoteUrl) {
627
+ const normalized = remoteUrl.replace(/\.git$/, "");
628
+ const sshMatch = normalized.match(/^git@github\.com:(.+\/.+)$/);
629
+ if (sshMatch) {
630
+ return sshMatch[1];
631
+ }
632
+ const httpsMatch = normalized.match(/^https:\/\/github\.com\/(.+\/.+)$/);
633
+ if (httpsMatch) {
634
+ return httpsMatch[1];
635
+ }
636
+ return null;
637
+ }
638
+ function detectBaseBranch() {
639
+ // Try 1: Get default branch from remote
640
+ try {
641
+ const result = (0, child_process_1.execSync)("git symbolic-ref refs/remotes/origin/HEAD", {
642
+ encoding: "utf-8",
643
+ stdio: "pipe",
644
+ }).trim();
645
+ // Parse "refs/remotes/origin/main" -> "main"
646
+ return result.replace("refs/remotes/origin/", "");
647
+ }
648
+ catch {
649
+ // Fallback: Try common branch names
650
+ }
651
+ // Try 2: Check for common base branches
652
+ for (const branch of ["main", "master", "develop"]) {
653
+ try {
654
+ (0, child_process_1.execSync)(`git rev-parse --verify ${branch}`, { stdio: "pipe" });
655
+ return branch;
656
+ }
657
+ catch {
658
+ continue;
659
+ }
660
+ }
661
+ // Error: No base branch found
662
+ if (language === "ja") {
663
+ console.error("❌ ベースブランチを検出できませんでした。");
664
+ console.error(" main, master, develop のいずれも存在しません。");
665
+ }
666
+ else {
667
+ console.error("❌ Could not detect base branch.");
668
+ console.error(" None of main, master, develop exist.");
669
+ }
670
+ process.exit(1);
671
+ }
672
+ function getBranchDiff(baseBranch) {
673
+ try {
674
+ const commits = (0, child_process_1.execSync)(`git log ${baseBranch}..HEAD --format="%h %s%n%b%n---"`, { encoding: "utf-8" });
675
+ const diff = (0, child_process_1.execSync)(`git diff ${baseBranch}...HEAD`, {
676
+ encoding: "utf-8",
677
+ });
678
+ return { commits, diff };
679
+ }
680
+ catch {
681
+ if (language === "ja") {
682
+ console.error("❌ ブランチの差分取得に失敗しました。");
683
+ }
684
+ else {
685
+ console.error("❌ Failed to get branch diff.");
686
+ }
687
+ process.exit(1);
688
+ }
689
+ }
690
+ function buildPRPrompt(commits, diff, lang) {
691
+ if (lang === "ja") {
692
+ return `あなたは GitHub の Pull Request 作成の専門家です。
693
+ 次のコミット履歴と差分から、PR の説明文を生成してください。
694
+
695
+ ルール:
696
+ - GitHub の標準フォーマットを使用する
697
+ - ## Summary: 2-3文で変更の概要を説明
698
+ - ## Changes: "- " で始まる箇条書き(3-7個)で具体的な変更内容
699
+ - ## Test plan: テスト方法の箇条書き(2-4個)
700
+ - 命令形を使う
701
+ - WHATとWHYを重視し、HOWは最小限に
702
+ - 出力はPR説明文のみ(余計な説明は不要)
703
+
704
+ コミット履歴:
705
+ ${commits}
706
+
707
+ 変更差分:
708
+ ${diff}`;
709
+ }
710
+ return `You are an expert at writing GitHub Pull Request descriptions.
711
+ Generate a PR description from the following commit history and diff.
712
+
713
+ Rules:
714
+ - Use standard GitHub format
715
+ - ## Summary: 2-3 sentences explaining the overall change
716
+ - ## Changes: bullet points (3-7 items) with "- " prefix detailing specific changes
717
+ - ## Test plan: bullet points (2-4 items) describing how to test
718
+ - Use imperative mood
719
+ - Focus on WHAT and WHY, minimize HOW
720
+ - Output ONLY the PR description, no extra explanation
721
+
722
+ Commit history:
723
+ ${commits}
724
+
725
+ Diff:
726
+ ${diff}`;
727
+ }
728
+ async function generatePRDescription(baseBranch) {
729
+ const { commits, diff } = getBranchDiff(baseBranch);
730
+ const inputCommits = truncateByChars(commits, PR_COMMITS_COMPACT_CHARS);
731
+ const inputDiff = truncateByChars(diff, PR_DIFF_COMPACT_CHARS);
732
+ const prompt = buildPRPrompt(inputCommits, inputDiff, language);
733
+ try {
734
+ return await generateText(prompt);
735
+ }
736
+ catch (error) {
737
+ if (isRequestTooLargeError(error)) {
738
+ const compactCommits = truncateByChars(commits, 1200);
739
+ const compactDiff = truncateByChars(diff, 2200);
740
+ const compactPrompt = buildPRPrompt(compactCommits, compactDiff, language);
741
+ if (language === "ja") {
742
+ console.log("ℹ️ 入力サイズが大きいため、差分を要約モードにして再試行します...");
743
+ }
744
+ else {
745
+ console.log("ℹ️ Input is too large. Retrying with compact diff summary...");
746
+ }
747
+ try {
748
+ return await generateText(compactPrompt);
749
+ }
750
+ catch (retryError) {
751
+ handleGroqError(retryError);
752
+ process.exit(1);
753
+ }
754
+ }
755
+ handleGroqError(error);
756
+ process.exit(1);
757
+ }
758
+ }
759
+ function truncateByChars(input, maxChars) {
760
+ if (input.length <= maxChars) {
761
+ return input;
762
+ }
763
+ return `${input.slice(0, maxChars)}\n\n... (truncated)`;
764
+ }
765
+ function isRequestTooLargeError(error) {
766
+ const message = error instanceof Error ? error.message : String(error);
767
+ const lower = message.toLowerCase();
768
+ return (lower.includes("413") ||
769
+ lower.includes("request too large") ||
770
+ lower.includes("tokens per minute") ||
771
+ lower.includes("tpm"));
772
+ }
773
+ function createPR(description, baseBranch, fallbackURL) {
774
+ // Extract title from first non-header line
775
+ const lines = description.split("\n");
776
+ const titleLine = lines.find((l) => l.trim() && !l.startsWith("#")) || "Pull Request";
777
+ const result = (0, child_process_1.spawnSync)("gh", [
778
+ "pr",
779
+ "create",
780
+ "--base",
781
+ baseBranch,
782
+ "--title",
783
+ titleLine.trim(),
784
+ "--body",
785
+ description,
786
+ ], { encoding: "utf-8", stdio: "pipe" });
787
+ if (result.stdout) {
788
+ process.stdout.write(result.stdout);
789
+ }
790
+ if (result.stderr) {
791
+ process.stderr.write(result.stderr);
792
+ }
793
+ if (result.status !== 0) {
794
+ if (language === "ja") {
795
+ console.error("❌ PR の作成に失敗しました。");
796
+ if (fallbackURL) {
797
+ console.error(` 手動で作成する場合: ${fallbackURL}`);
798
+ }
799
+ }
800
+ else {
801
+ console.error("❌ Failed to create PR.");
802
+ if (fallbackURL) {
803
+ console.error(` Create manually: ${fallbackURL}`);
804
+ }
805
+ }
806
+ process.exit(1);
807
+ }
808
+ }
809
+ async function mainPR() {
810
+ checkGHCLI();
811
+ checkGHAuth();
812
+ const baseBranch = detectBaseBranch();
813
+ const currentBranch = (0, child_process_1.execSync)("git branch --show-current", {
814
+ encoding: "utf-8",
815
+ }).trim();
816
+ if (currentBranch === baseBranch) {
817
+ if (language === "ja") {
818
+ console.error(`❌ ベースブランチ (${baseBranch}) からPRを作成できません。`);
819
+ }
820
+ else {
821
+ console.error(`❌ Cannot create PR from base branch (${baseBranch}).`);
822
+ }
823
+ process.exit(1);
824
+ }
825
+ // ブランチが push されているかチェック、されていなければ自動 push
826
+ try {
827
+ // upstream が設定されているかチェック
828
+ (0, child_process_1.execSync)(`git rev-parse --abbrev-ref ${currentBranch}@{upstream}`, {
829
+ encoding: "utf-8",
830
+ stdio: "pipe",
831
+ });
832
+ // upstream が存在する場合、リモートと同期しているかチェック
833
+ const localCommit = (0, child_process_1.execSync)(`git rev-parse ${currentBranch}`, {
834
+ encoding: "utf-8",
835
+ stdio: "pipe",
836
+ }).trim();
837
+ const remoteCommit = (0, child_process_1.execSync)(`git rev-parse ${currentBranch}@{upstream}`, {
838
+ encoding: "utf-8",
839
+ stdio: "pipe",
840
+ }).trim();
841
+ if (localCommit !== remoteCommit) {
842
+ // ローカルに新しいコミットがある場合は push
843
+ console.log(`📤 ブランチを push 中... (origin ${currentBranch})`);
844
+ const pushResult = (0, child_process_1.spawnSync)("git", ["push"], { stdio: "inherit" });
845
+ if (pushResult.status !== 0) {
846
+ console.error("❌ ブランチの push に失敗しました。");
847
+ process.exit(1);
848
+ }
849
+ }
850
+ }
851
+ catch {
852
+ // upstream が存在しない場合、新規 push
853
+ console.log(`📤 ブランチを push 中... (origin ${currentBranch} を新規作成)`);
854
+ const pushResult = (0, child_process_1.spawnSync)("git", ["push", "-u", "origin", currentBranch], { stdio: "inherit" });
855
+ if (pushResult.status !== 0) {
856
+ if (language === "ja") {
857
+ console.error("❌ ブランチの push に失敗しました。");
858
+ }
859
+ else {
860
+ console.error("❌ Failed to push branch.");
861
+ }
862
+ process.exit(1);
863
+ }
864
+ }
865
+ console.log(`🤖 PR説明文を生成中... (${baseBranch}...${currentBranch}) [compact summary input]`);
866
+ const description = await generatePRDescription(baseBranch);
867
+ console.log(`\n📝 Generated PR description:\n`);
868
+ description.split("\n").forEach((line) => {
869
+ console.log(` ${line}`);
870
+ });
871
+ console.log();
872
+ const answer = await askUser("Create PR with this description? [y/n/e(edit)]: ");
873
+ let finalDescription = description;
874
+ if (answer === "e" || answer === "edit") {
875
+ finalDescription = editInEditor(description);
876
+ if (!finalDescription) {
877
+ console.log("Aborted: empty description.");
878
+ process.exit(0);
879
+ }
880
+ console.log(`\n✏️ Edited description:\n`);
881
+ finalDescription.split("\n").forEach((line) => {
882
+ console.log(` ${line}`);
883
+ });
884
+ console.log();
885
+ }
886
+ else if (answer !== "y" && answer !== "yes") {
887
+ console.log("Aborted.");
888
+ process.exit(0);
889
+ }
890
+ const fallbackURL = getPullRequestURL(currentBranch);
891
+ createPR(finalDescription, baseBranch, fallbackURL);
892
+ console.log(`\n✅ PR created successfully!`);
893
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "ai-git-tool",
3
+ "version": "1.0.0",
4
+ "description": "AI-powered git commit and PR description generator using Groq API",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "ai-git": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/index.ts",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "keywords": [
15
+ "git",
16
+ "commit",
17
+ "pr",
18
+ "pull-request",
19
+ "ai",
20
+ "groq",
21
+ "cli"
22
+ ],
23
+ "license": "MIT",
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "tsx": "^4.0.0",
30
+ "typescript": "^5.0.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "dependencies": {
36
+ "openai": "^6.34.0"
37
+ }
38
+ }