ai-git-tool 1.2.0 → 1.4.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.
@@ -0,0 +1,520 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getGroqClient = getGroqClient;
7
+ exports.generateText = generateText;
8
+ exports.generateCommitMessage = generateCommitMessage;
9
+ exports.extractPRTitle = extractPRTitle;
10
+ exports.stripTitleLine = stripTitleLine;
11
+ exports.generatePRDescription = generatePRDescription;
12
+ exports.suggestBranchNameWithAI = suggestBranchNameWithAI;
13
+ exports.suggestBranchName = suggestBranchName;
14
+ const openai_1 = __importDefault(require("openai"));
15
+ const errors_js_1 = require("../utils/errors.js");
16
+ const text_js_1 = require("../utils/text.js");
17
+ const git_js_1 = require("../utils/git.js");
18
+ const defaultGroqModel = "llama-3.3-70b-versatile";
19
+ const COMMIT_DIFF_COMPACT_CHARS = 3500;
20
+ const PR_COMMITS_COMPACT_CHARS = 1800;
21
+ const PR_DIFF_COMPACT_CHARS = 3500;
22
+ /**
23
+ * Groq クライアントを取得
24
+ */
25
+ function getGroqClient() {
26
+ const apiKey = process.env.GROQ_API_KEY;
27
+ if (!apiKey) {
28
+ (0, errors_js_1.showFriendlyError)("GROQ_API_KEY が未設定です", "AI 機能を使用するには Groq API キーが必要です", [
29
+ "Groq にサインアップ(無料): https://console.groq.com/",
30
+ "API キーを取得: https://console.groq.com/keys",
31
+ "環境変数に設定:",
32
+ ' - 一時的: export GROQ_API_KEY="gsk_..."',
33
+ " - 永続的: ~/.bashrc や ~/.zshrc に上記を追加",
34
+ ], [
35
+ "API キー設定後に ai-git commit を実行",
36
+ "API キー設定後に ai-git pr を実行",
37
+ ]);
38
+ process.exit(1);
39
+ }
40
+ return new openai_1.default({
41
+ apiKey,
42
+ baseURL: "https://api.groq.com/openai/v1",
43
+ });
44
+ }
45
+ /**
46
+ * AI でテキストを生成
47
+ */
48
+ async function generateText(prompt) {
49
+ const client = getGroqClient();
50
+ const model = process.env.GROQ_MODEL || defaultGroqModel;
51
+ const res = await client.chat.completions.create({
52
+ model,
53
+ temperature: 0.2,
54
+ messages: [{ role: "user", content: prompt }],
55
+ });
56
+ return res.choices[0]?.message?.content?.trim() || "";
57
+ }
58
+ /**
59
+ * コミットメッセージ生成用プロンプトを構築
60
+ */
61
+ function buildCommitPrompt(diff, lang) {
62
+ if (lang === "ja") {
63
+ return `あなたは git のコミットメッセージ作成の専門家です。
64
+ 次の git diff を注意深く分析し、Conventional Commits 形式の詳細コミットメッセージを生成してください。
65
+
66
+ 重要: diff の内容を正確に読み取り、実際に何が変更されたかを理解してください。
67
+ 変更されたファイル名、追加/削除された関数名、変更の目的を特定してください。
68
+
69
+ ルール:
70
+ - 1行目: <type>(<optional scope>): <short description>(72文字以内)
71
+ * type は実際の変更内容に基づいて選択(feat, fix, docs, refactor, test, chore など)
72
+ * scope は変更の影響範囲(例: error-handling, ui, api)
73
+ - 2行目は空行
74
+ - 本文は "- " で始まる箇条書き
75
+ - 箇条書きは 3-5 行で、具体的な変更内容を記述
76
+ - WHAT(何を変更したか)と WHY(なぜ変更したか)を書く
77
+ - HOW(どうやって実装したか)は書かない
78
+ - 命令形を使う(「追加する」「修正する」など)
79
+ - 出力はコミットメッセージ本文のみ(説明文は不要)
80
+ - 説明文は日本語にする
81
+
82
+ 出力例:
83
+ feat(auth): Google ログインを追加する
84
+
85
+ - auth.ts に GoogleAuthProvider の設定を追加する
86
+ - トークン期限切れ時の更新処理を追加する
87
+ - ネットワーク障害時のエラーハンドリングを追加する
88
+
89
+ Git diff:
90
+ ${diff}`;
91
+ }
92
+ return `You are an expert at writing git commit messages.
93
+ Carefully analyze the following git diff and generate a detailed commit message in Conventional Commits format.
94
+
95
+ IMPORTANT: Read the diff content accurately and understand what was actually changed.
96
+ Identify changed file names, added/removed function names, and the purpose of the changes.
97
+
98
+ Rules:
99
+ - First line: <type>(<optional scope>): <short description> (max 72 chars)
100
+ * Choose type based on actual changes (feat, fix, docs, refactor, test, chore, etc.)
101
+ * Scope should reflect the area of impact (e.g., error-handling, ui, api)
102
+ - Blank line after the first line
103
+ - Body: bullet points starting with "- " describing specific changes
104
+ - 3-5 bullet points explaining WHAT was changed and WHY
105
+ - Do NOT explain HOW it was implemented
106
+ - Use imperative mood ("add" not "added")
107
+ - Output ONLY the commit message, nothing else
108
+
109
+ Example:
110
+ feat(auth): add Google login with Firebase Auth
111
+
112
+ - Add GoogleAuthProvider setup in auth.ts
113
+ - Handle token refresh on expiry
114
+ - Add error handling for network failures
115
+
116
+ Git diff:
117
+ ${diff}`;
118
+ }
119
+ /**
120
+ * ファイルパスだけのような内容かチェック
121
+ */
122
+ function looksLikePathOnly(value) {
123
+ const normalized = value.trim().toLowerCase();
124
+ if (!normalized) {
125
+ return false;
126
+ }
127
+ const hasPathToken = normalized.includes("/") || normalized.includes(".");
128
+ return hasPathToken && /^[a-z0-9/_\-.]+$/.test(normalized);
129
+ }
130
+ /**
131
+ * フォールバックのサブジェクト行を生成
132
+ */
133
+ function buildFallbackSubjectLine(diff, lang) {
134
+ const files = (0, git_js_1.getChangedFiles)();
135
+ const type = inferBranchType(files, diff);
136
+ const rawTopic = inferTopic(files, diff);
137
+ const topic = rawTopic ? rawTopic.replace(/-/g, " ") : "";
138
+ if (lang === "ja") {
139
+ const description = topic
140
+ ? `${topic} の変更意図を分かりやすく整理する`
141
+ : "変更意図が伝わるように更新内容を整理する";
142
+ return `${type}: ${description}`;
143
+ }
144
+ const description = topic
145
+ ? `clarify the intent behind ${topic} changes`
146
+ : "clarify update intent in one clear sentence";
147
+ return `${type}: ${description}`;
148
+ }
149
+ /**
150
+ * フォールバックのコミットメッセージを生成
151
+ */
152
+ function buildFallbackCommitMessage(diff, lang) {
153
+ const subject = buildFallbackSubjectLine(diff, lang);
154
+ if (lang === "ja") {
155
+ return `${subject}\n\n- 差分の主目的を明確にし、変更理由が伝わる形に整える`;
156
+ }
157
+ return `${subject}\n\n- Clarify the main change intent so the reason is easy to understand`;
158
+ }
159
+ /**
160
+ * AI が生成したコミットメッセージを正規化
161
+ */
162
+ function normalizeGeneratedCommitMessage(raw, diff, lang) {
163
+ const lines = raw
164
+ .split("\n")
165
+ .map((line) => line.replace(/\s+$/g, ""))
166
+ .filter((_, idx, arr) => !(idx > 0 && arr[idx - 1] === "" && arr[idx] === ""));
167
+ if (lines.length === 0) {
168
+ return buildFallbackCommitMessage(diff, lang);
169
+ }
170
+ const subjectLine = lines[0]?.trim() || "";
171
+ const conventionalMatch = /^[a-z]+(\([^)]+\))?:\s+(.+)$/.exec(subjectLine);
172
+ const hasConventionalPrefix = Boolean(conventionalMatch);
173
+ const shortDescription = conventionalMatch?.[2]?.trim() || subjectLine;
174
+ if (!hasConventionalPrefix ||
175
+ looksLikePathOnly(shortDescription) ||
176
+ shortDescription.length < 8) {
177
+ lines[0] = buildFallbackSubjectLine(diff, lang);
178
+ }
179
+ return lines.join("\n").trim();
180
+ }
181
+ /**
182
+ * git diff から AI を使ってコミットメッセージを生成
183
+ */
184
+ async function generateCommitMessage(diff, language) {
185
+ const inputDiff = (0, text_js_1.truncateByChars)(diff, COMMIT_DIFF_COMPACT_CHARS);
186
+ const prompt = buildCommitPrompt(inputDiff, language);
187
+ try {
188
+ const raw = await generateText(prompt);
189
+ return normalizeGeneratedCommitMessage(raw, diff, language);
190
+ }
191
+ catch (error) {
192
+ if ((0, errors_js_1.isRequestTooLargeError)(error)) {
193
+ const smallerDiff = (0, text_js_1.truncateByChars)(diff, 1800);
194
+ const retryRaw = await generateText(buildCommitPrompt(smallerDiff, language));
195
+ return normalizeGeneratedCommitMessage(retryRaw, diff, language);
196
+ }
197
+ (0, errors_js_1.handleGroqError)(error);
198
+ process.exit(1);
199
+ }
200
+ }
201
+ /**
202
+ * PR 説明文生成用プロンプトを構築
203
+ */
204
+ function buildPRPrompt(commits, diff, lang) {
205
+ if (lang === "ja") {
206
+ return `あなたは GitHub の Pull Request 作成の専門家です。
207
+ 次のコミット履歴と差分から、PR のタイトルと説明文を生成してください。
208
+
209
+ 出力フォーマット(必ずこの順番で出力してください):
210
+ 1行目: Title: <Conventional Commits 形式のタイトル(72文字以内、日本語で)>
211
+ 2行目: 空行
212
+ 3行目以降: PR 説明文(以下の形式)
213
+
214
+ 説明文のルール:
215
+ - ## Summary の後は改行し、次の行から概要を 1-2 文で説明
216
+ - ## Changes の後は改行し、次の行から "- " で始まる箇条書き(3-7個)で具体的な変更内容
217
+ - ## Test plan の後は改行し、次の行から "- " で始まるテスト方法の箇条書き(2-4個)
218
+ - 命令形を使う
219
+ - WHATとWHYを重視し、HOWは最小限に
220
+ - 出力はタイトルとPR説明文のみ(余計な説明は不要)
221
+
222
+ 出力例:
223
+ Title: feat: push サブコマンドを追加する
224
+
225
+ ## Summary
226
+ コミット後に自動でリモートにプッシュする機能を追加しました。
227
+
228
+ ## Changes
229
+ - push サブコマンドを追加し、コミットとプッシュを一括実行できるようにする
230
+ - upstream が未設定の場合は自動で設定する機能を追加する
231
+ - エラーハンドリングを強化する
232
+
233
+ ## Test plan
234
+ - ai-git push を実行してコミットとプッシュが正常に動作するか確認する
235
+ - upstream が未設定の状態でも正しくプッシュされるか確認する
236
+
237
+ コミット履歴:
238
+ ${commits}
239
+
240
+ 変更差分:
241
+ ${diff}`;
242
+ }
243
+ return `You are an expert at writing GitHub Pull Request descriptions.
244
+ Generate a PR title and description from the following commit history and diff.
245
+
246
+ Output format (in this exact order):
247
+ Line 1: Title: <Conventional Commits title, max 72 chars>
248
+ Line 2: empty line
249
+ Line 3+: PR description in the following format
250
+
251
+ Description rules:
252
+ - ## Summary should be followed by a line break, then 1-2 sentences explaining the overall change
253
+ - ## Changes should be followed by a line break, then bullet points (3-7 items) with "- " prefix detailing specific changes
254
+ - ## Test plan should be followed by a line break, then bullet points (2-4 items) describing how to test
255
+ - Use imperative mood
256
+ - Focus on WHAT and WHY, minimize HOW
257
+ - Output ONLY the title line and PR description, no extra explanation
258
+
259
+ Output example:
260
+ Title: feat: add push subcommand
261
+
262
+ ## Summary
263
+ Added a new push subcommand that automatically commits and pushes changes to remote.
264
+
265
+ ## Changes
266
+ - Add push subcommand to execute commit and push in one command
267
+ - Add automatic upstream setup when not configured
268
+ - Improve error handling for push operations
269
+
270
+ ## Test plan
271
+ - Run ai-git push and verify commit and push work correctly
272
+ - Verify it works correctly when upstream is not set
273
+
274
+ Commit history:
275
+ ${commits}
276
+
277
+ Diff:
278
+ ${diff}`;
279
+ }
280
+ /**
281
+ * PR のタイトルを抽出
282
+ */
283
+ function extractPRTitle(raw) {
284
+ const firstLine = raw.split("\n")[0]?.trim() ?? "";
285
+ const titleMatch = /^Title:\s*(.+)$/i.exec(firstLine);
286
+ if (titleMatch?.[1]) {
287
+ return titleMatch[1].trim();
288
+ }
289
+ const lines = raw
290
+ .split("\n")
291
+ .map((v) => v.trim())
292
+ .filter(Boolean);
293
+ const summaryIdx = lines.findIndex((l) => l.toLowerCase().startsWith("## summary"));
294
+ let candidate = summaryIdx >= 0
295
+ ? (lines.slice(summaryIdx + 1).find((l) => !l.startsWith("##")) ?? "")
296
+ : (lines.find((l) => !l.startsWith("#") && !l.startsWith("-")) ?? "");
297
+ candidate = candidate
298
+ .replace(/^this pull request\s+(is|does)\s*/i, "")
299
+ .replace(/^この\s*pull request\s*は、?/i, "")
300
+ .replace(/^このprは、?/i, "")
301
+ .replace(/\s+/g, " ")
302
+ .trim();
303
+ const sentenceCut = candidate.search(/[。.!?]/);
304
+ if (sentenceCut > 0) {
305
+ candidate = candidate.slice(0, sentenceCut);
306
+ }
307
+ return candidate || "Update project changes";
308
+ }
309
+ /**
310
+ * Title 行を除去
311
+ */
312
+ function stripTitleLine(raw) {
313
+ const lines = raw.split("\n");
314
+ if (/^Title:\s*/i.test(lines[0]?.trim() ?? "")) {
315
+ return lines
316
+ .slice(lines[1]?.trim() === "" ? 2 : 1)
317
+ .join("\n")
318
+ .trimStart();
319
+ }
320
+ return raw;
321
+ }
322
+ /**
323
+ * PR 説明文を生成
324
+ */
325
+ async function generatePRDescription(baseBranch, language, getBranchDiff) {
326
+ const { commits, diff } = getBranchDiff(baseBranch, language);
327
+ const inputCommits = (0, text_js_1.truncateByChars)(commits, PR_COMMITS_COMPACT_CHARS);
328
+ const inputDiff = (0, text_js_1.truncateByChars)(diff, PR_DIFF_COMPACT_CHARS);
329
+ const prompt = buildPRPrompt(inputCommits, inputDiff, language);
330
+ try {
331
+ return await generateText(prompt);
332
+ }
333
+ catch (error) {
334
+ if ((0, errors_js_1.isRequestTooLargeError)(error)) {
335
+ const compactCommits = (0, text_js_1.truncateByChars)(commits, 1200);
336
+ const compactDiff = (0, text_js_1.truncateByChars)(diff, 2200);
337
+ const compactPrompt = buildPRPrompt(compactCommits, compactDiff, language);
338
+ if (language === "ja") {
339
+ console.log("ℹ️ 入力サイズが大きいため、差分を要約モードにして再試行します...");
340
+ }
341
+ else {
342
+ console.log("ℹ️ Input is too large. Retrying with compact diff summary...");
343
+ }
344
+ try {
345
+ return await generateText(compactPrompt);
346
+ }
347
+ catch (retryError) {
348
+ (0, errors_js_1.handleGroqError)(retryError);
349
+ process.exit(1);
350
+ }
351
+ }
352
+ (0, errors_js_1.handleGroqError)(error);
353
+ process.exit(1);
354
+ }
355
+ }
356
+ // ブランチ名推測用のヘルパー関数
357
+ function inferBranchType(files, diff) {
358
+ const lowerFiles = files.map((f) => f.toLowerCase());
359
+ const lowerDiff = diff.toLowerCase();
360
+ if (lowerFiles.length > 0 && lowerFiles.every((f) => f.endsWith(".md"))) {
361
+ return "docs";
362
+ }
363
+ if (lowerFiles.some((f) => f.includes("readme") || f.endsWith(".md")) &&
364
+ lowerFiles.length <= 2) {
365
+ return "docs";
366
+ }
367
+ if (lowerFiles.some((f) => f.includes("package.json") || f.includes("lock")) &&
368
+ lowerFiles.length <= 2) {
369
+ return "chore";
370
+ }
371
+ if (lowerDiff.includes("fix") ||
372
+ lowerDiff.includes("bug") ||
373
+ lowerDiff.includes("error")) {
374
+ return "fix";
375
+ }
376
+ return "feat";
377
+ }
378
+ function isGoodTopicToken(token, stopWords) {
379
+ if (token.length < 3) {
380
+ return false;
381
+ }
382
+ if (stopWords.has(token)) {
383
+ return false;
384
+ }
385
+ if (/^[a-f0-9]{6,}$/.test(token)) {
386
+ return false;
387
+ }
388
+ if (/^\d+$/.test(token)) {
389
+ return false;
390
+ }
391
+ return true;
392
+ }
393
+ function sanitizeBranchPart(value) {
394
+ return value
395
+ .toLowerCase()
396
+ .replace(/[^a-z0-9-]/g, "-")
397
+ .replace(/-+/g, "-")
398
+ .replace(/^-|-$/g, "");
399
+ }
400
+ function inferTopic(files, diff) {
401
+ const stopWords = new Set([
402
+ "src",
403
+ "dist",
404
+ "index",
405
+ "readme",
406
+ "package",
407
+ "lock",
408
+ "json",
409
+ "ts",
410
+ "js",
411
+ "md",
412
+ "diff",
413
+ "added",
414
+ "removed",
415
+ "file",
416
+ "files",
417
+ "change",
418
+ "changes",
419
+ "update",
420
+ "updated",
421
+ "line",
422
+ "lines",
423
+ ]);
424
+ const fileTokens = files
425
+ .flatMap((f) => f.split(/[\/._-]+/))
426
+ .map((v) => v.toLowerCase())
427
+ .filter((v) => isGoodTopicToken(v, stopWords));
428
+ const diffTokens = diff
429
+ .toLowerCase()
430
+ .split(/[^a-z0-9]+/)
431
+ .filter((v) => isGoodTopicToken(v, stopWords))
432
+ .slice(0, 20);
433
+ const tokens = [...fileTokens, ...diffTokens]
434
+ .map((v) => sanitizeBranchPart(v))
435
+ .filter(Boolean);
436
+ if (tokens.length === 0) {
437
+ return "";
438
+ }
439
+ const unique = Array.from(new Set(tokens));
440
+ return unique.slice(0, 3).join("-");
441
+ }
442
+ function randomSuffix() {
443
+ return Math.random().toString(36).slice(2, 8);
444
+ }
445
+ /**
446
+ * AI でブランチ名を提案
447
+ */
448
+ async function suggestBranchNameWithAI(files, diff) {
449
+ if (!process.env.GROQ_API_KEY) {
450
+ return null;
451
+ }
452
+ const compactFiles = files.slice(0, 30).join("\n");
453
+ const compactDiff = (0, text_js_1.truncateByChars)(diff, 2800);
454
+ const prompt = `You are an expert at naming git branches.
455
+ Generate exactly one branch name from the following changes.
456
+
457
+ Rules:
458
+ - Output only branch name, nothing else
459
+ - Format: <type>/<slug>
460
+ - type must be one of: feat, fix, docs, chore, refactor, test, style
461
+ - slug must use kebab-case
462
+ - Prefer meaningful feature intent over noisy token names
463
+ - If changes look like new feature or command addition, prefer feat
464
+
465
+ Changed files:
466
+ ${compactFiles}
467
+
468
+ Diff:
469
+ ${compactDiff}`;
470
+ try {
471
+ const raw = await generateText(prompt);
472
+ return normalizeBranchCandidate(raw);
473
+ }
474
+ catch {
475
+ return null;
476
+ }
477
+ }
478
+ function normalizeBranchCandidate(raw) {
479
+ const firstLine = raw.split("\n")[0]?.trim().toLowerCase() || "";
480
+ const cleaned = firstLine.replace(/[`"' ]/g, "");
481
+ const normalized = cleaned.replace(/^feature\//, "feat/");
482
+ const [head, ...tail] = normalized.split("/");
483
+ if (!head || tail.length === 0) {
484
+ return null;
485
+ }
486
+ const validTypes = new Set([
487
+ "feat",
488
+ "fix",
489
+ "docs",
490
+ "chore",
491
+ "refactor",
492
+ "test",
493
+ "style",
494
+ ]);
495
+ if (!validTypes.has(head)) {
496
+ return null;
497
+ }
498
+ const slug = sanitizeBranchPart(tail.join("-"));
499
+ if (!slug || slug.length < 3) {
500
+ return null;
501
+ }
502
+ return `${head}/${slug}`;
503
+ }
504
+ /**
505
+ * ブランチ名を提案(AI またはヒューリスティック)
506
+ */
507
+ async function suggestBranchName() {
508
+ const files = (0, git_js_1.getChangedFiles)();
509
+ const diff = (0, git_js_1.getCombinedDiff)();
510
+ const fromAI = await suggestBranchNameWithAI(files, diff);
511
+ if (fromAI) {
512
+ return fromAI;
513
+ }
514
+ const type = inferBranchType(files, diff);
515
+ const topic = inferTopic(files, diff);
516
+ if (!topic) {
517
+ return `${type}/update-${randomSuffix()}`;
518
+ }
519
+ return `${type}/${topic}`;
520
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ensureAvailableBranchName = ensureAvailableBranchName;
4
+ const git_js_1 = require("../utils/git.js");
5
+ /**
6
+ * ブランチパーツをサニタイズ
7
+ */
8
+ function sanitizeBranchPart(value) {
9
+ return value
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9-]/g, "-")
12
+ .replace(/-+/g, "-")
13
+ .replace(/^-|-$/g, "");
14
+ }
15
+ /**
16
+ * ランダムサフィックスを生成
17
+ */
18
+ function randomSuffix() {
19
+ return Math.random().toString(36).slice(2, 8);
20
+ }
21
+ /**
22
+ * 利用可能なブランチ名を確保
23
+ */
24
+ function ensureAvailableBranchName(baseName) {
25
+ const [headRaw, ...tailRaw] = baseName.split("/");
26
+ const head = sanitizeBranchPart(headRaw || "feat");
27
+ const tail = sanitizeBranchPart(tailRaw.join("-") || `update-${randomSuffix()}`);
28
+ const normalized = `${head || "feat"}/${tail}`;
29
+ if (!(0, git_js_1.branchExists)(normalized)) {
30
+ return normalized;
31
+ }
32
+ let i = 2;
33
+ while ((0, git_js_1.branchExists)(`${normalized}-${i}`)) {
34
+ i += 1;
35
+ }
36
+ return `${normalized}-${i}`;
37
+ }