@amnesia2k/git-aic 2.1.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.
package/src/llm.ts ADDED
@@ -0,0 +1,340 @@
1
+ import axios from "axios";
2
+ import type { AxiosError } from "axios";
3
+ import chalk from "chalk";
4
+ import { getStoredApiKey } from "./config.js";
5
+ import {
6
+ buildBatchDiffExplanationsPrompt,
7
+ buildDiffExplanationPrompt,
8
+ buildDiffFileNamePrompt,
9
+ buildPrompt,
10
+ } from "./prompt.js";
11
+
12
+ interface GeminiPart {
13
+ text?: string;
14
+ }
15
+
16
+ interface GeminiContent {
17
+ parts?: GeminiPart[];
18
+ }
19
+
20
+ interface GeminiCandidate {
21
+ content?: GeminiContent;
22
+ }
23
+
24
+ interface GeminiResponse {
25
+ candidates: GeminiCandidate[];
26
+ }
27
+
28
+ interface RequestContext {
29
+ operation: "commit-message" | "diff-filename" | "diff-explanations";
30
+ target?: string;
31
+ }
32
+
33
+ interface DiffFileInput {
34
+ filePath: string;
35
+ diff: string;
36
+ }
37
+
38
+ const VALID_DIFF_FILE_TYPES = new Set([
39
+ "feat",
40
+ "fix",
41
+ "refactor",
42
+ "chore",
43
+ "docs",
44
+ "style",
45
+ "test",
46
+ "perf",
47
+ "bugfix",
48
+ ]);
49
+
50
+ const API_URL =
51
+ "https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent";
52
+ const MAX_RETRIES = 4;
53
+
54
+ const getApiKey = () =>
55
+ process.env.GEMINI_COMMIT_MESSAGE_API_KEY || getStoredApiKey() || "";
56
+
57
+ const ensureApiKey = () => {
58
+ const API_KEY = getApiKey();
59
+
60
+ if (!API_KEY) {
61
+ console.error(
62
+ chalk.red(
63
+ "\nMissing GEMINI_COMMIT_MESSAGE_API_KEY environment variable.\n",
64
+ ),
65
+ );
66
+
67
+ console.log("Please set your API key before running this command.\n");
68
+
69
+ console.log(chalk.yellow("How to fix this:\n"));
70
+
71
+ console.log(chalk.cyan("macOS / Linux:"));
72
+ console.log(" export GEMINI_COMMIT_MESSAGE_API_KEY=your_api_key_here\n");
73
+
74
+ console.log(chalk.cyan("Windows (PowerShell):"));
75
+ console.log(' setx GEMINI_COMMIT_MESSAGE_API_KEY "your_api_key_here"\n');
76
+
77
+ console.log(
78
+ chalk.gray("After setting the variable, restart your terminal.\n"),
79
+ );
80
+
81
+ process.exit(1);
82
+ }
83
+
84
+ return API_KEY;
85
+ };
86
+
87
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
88
+
89
+ const getOperationLabel = ({ operation, target }: RequestContext) => {
90
+ if (operation === "diff-filename") {
91
+ return "generating diff filename";
92
+ }
93
+
94
+ if (operation === "diff-explanations") {
95
+ return target
96
+ ? `generating diff explanations for ${target}`
97
+ : "generating diff explanations";
98
+ }
99
+
100
+ return "generating commit message";
101
+ };
102
+
103
+ const getFallbackLabel = ({ operation, target }: RequestContext) => {
104
+ if (operation === "diff-filename") {
105
+ return "using fallback filename";
106
+ }
107
+
108
+ if (operation === "diff-explanations") {
109
+ return target
110
+ ? `using fallback explanations for ${target}`
111
+ : "using fallback explanations";
112
+ }
113
+
114
+ return "using fallback commit message";
115
+ };
116
+
117
+ const isRetryableError = (error: unknown) => {
118
+ if (!axios.isAxiosError(error)) {
119
+ return false;
120
+ }
121
+
122
+ const status = error.response?.status;
123
+
124
+ if (status === 429) {
125
+ return true;
126
+ }
127
+
128
+ if (status && status >= 500) {
129
+ return true;
130
+ }
131
+
132
+ return [
133
+ "ECONNABORTED",
134
+ "ETIMEDOUT",
135
+ "ECONNRESET",
136
+ "ENOTFOUND",
137
+ "EAI_AGAIN",
138
+ ].includes(error.code || "");
139
+ };
140
+
141
+ const getRetryDelay = (error: AxiosError, attempt: number) => {
142
+ const retryAfterHeader = error.response?.headers?.["retry-after"];
143
+
144
+ if (typeof retryAfterHeader === "string") {
145
+ const retryAfterSeconds = Number(retryAfterHeader);
146
+
147
+ if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
148
+ return retryAfterSeconds * 1000;
149
+ }
150
+ }
151
+
152
+ return Math.min(750 * 2 ** (attempt - 1), 3000);
153
+ };
154
+
155
+ const requestText = async (
156
+ prompt: string,
157
+ fallback: string,
158
+ context: RequestContext,
159
+ ) => {
160
+ const API_KEY = ensureApiKey();
161
+
162
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) {
163
+ try {
164
+ const response = await axios.post<GeminiResponse>(
165
+ API_URL,
166
+ {
167
+ contents: [{ parts: [{ text: prompt }] }],
168
+ },
169
+ {
170
+ headers: {
171
+ "Content-Type": "application/json",
172
+ "x-goog-api-key": API_KEY,
173
+ },
174
+ },
175
+ );
176
+
177
+ return response.data.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
178
+ ? response.data.candidates[0].content!.parts![0].text!.trim()
179
+ : fallback;
180
+ } catch (error) {
181
+ if (axios.isAxiosError(error) && isRetryableError(error)) {
182
+ if (attempt < MAX_RETRIES) {
183
+ const delay = getRetryDelay(error, attempt);
184
+ const status = error.response?.status || error.code || "unknown";
185
+ console.log(
186
+ chalk.yellow(
187
+ `Gemini request retry ${attempt}/${MAX_RETRIES - 1} while ${getOperationLabel(context)} (${status}). Waiting ${Math.ceil(delay / 1000)}s...`,
188
+ ),
189
+ );
190
+ await sleep(delay);
191
+ continue;
192
+ }
193
+ }
194
+
195
+ const status = axios.isAxiosError(error)
196
+ ? error.response?.status || error.code || "request-error"
197
+ : "request-error";
198
+ console.log(
199
+ chalk.yellow(
200
+ `Gemini request failed while ${getOperationLabel(context)} (${status}); ${getFallbackLabel(context)}.`,
201
+ ),
202
+ );
203
+ return fallback;
204
+ }
205
+ }
206
+
207
+ return fallback;
208
+ };
209
+
210
+ const sanitizeTopic = (value: string) => {
211
+ const sanitized = value
212
+ .trim()
213
+ .toLowerCase()
214
+ .replace(/\.md$/i, "")
215
+ .replace(/[^a-z0-9-]+/g, "-")
216
+ .replace(/-+/g, "-")
217
+ .replace(/^-|-$/g, "");
218
+
219
+ const tokens = sanitized.split("-").filter(Boolean).slice(0, 3);
220
+
221
+ return tokens.join("-");
222
+ };
223
+
224
+ const parseDiffFileName = (value: string) => {
225
+ const lines = value
226
+ .split(/\r?\n/)
227
+ .map((line) => line.trim())
228
+ .filter(Boolean);
229
+
230
+ const typeLine = lines.find((line) => /^type\s*:/i.test(line));
231
+ const topicLine = lines.find((line) => /^topic\s*:/i.test(line));
232
+
233
+ const rawType =
234
+ typeLine
235
+ ?.replace(/^type\s*:/i, "")
236
+ .trim()
237
+ .toLowerCase() || "";
238
+ const rawTopic = topicLine?.replace(/^topic\s*:/i, "").trim() || "";
239
+
240
+ const type = VALID_DIFF_FILE_TYPES.has(rawType) ? rawType : "chore";
241
+ const topic = sanitizeTopic(rawTopic) || "changes";
242
+
243
+ return `${type}-${topic}`;
244
+ };
245
+
246
+ export const generateCommitMessage = async (
247
+ rawDiff: string,
248
+ branchName: string,
249
+ ): Promise<string> => {
250
+ const prompt = buildPrompt(rawDiff, branchName);
251
+
252
+ return requestText(prompt, "chore: update code", {
253
+ operation: "commit-message",
254
+ });
255
+ };
256
+
257
+ export const generateDiffExplanation = async (
258
+ filePath: string,
259
+ rawDiff: string,
260
+ branchName: string,
261
+ ): Promise<string> => {
262
+ const prompt = buildDiffExplanationPrompt(filePath, rawDiff, branchName);
263
+
264
+ return requestText(
265
+ prompt,
266
+ `Updates ${filePath} with the selected changes shown below.`,
267
+ {
268
+ operation: "diff-explanations",
269
+ target: filePath,
270
+ },
271
+ );
272
+ };
273
+
274
+ const parseBatchDiffExplanations = (value: string, filePaths: string[]) => {
275
+ const result = new Map<string, string>();
276
+ const blockRegex =
277
+ /FILE:\s*(.+?)\r?\nEXPLANATION:\s*([\s\S]*?)\r?\nEND_FILE/g;
278
+ let match: RegExpExecArray | null;
279
+
280
+ while ((match = blockRegex.exec(value)) !== null) {
281
+ const filePath = match[1].trim();
282
+ const explanation = match[2].trim();
283
+
284
+ if (filePaths.includes(filePath) && explanation.length > 0) {
285
+ result.set(filePath, explanation);
286
+ }
287
+ }
288
+
289
+ return result;
290
+ };
291
+
292
+ export const generateDiffExplanations = async (
293
+ fileDiffs: DiffFileInput[],
294
+ branchName: string,
295
+ ): Promise<Map<string, string>> => {
296
+ const prompt = buildBatchDiffExplanationsPrompt(fileDiffs, branchName);
297
+ const response = await requestText(prompt, "", {
298
+ operation: "diff-explanations",
299
+ target: `${fileDiffs.length} files`,
300
+ });
301
+
302
+ const explanations = parseBatchDiffExplanations(
303
+ response,
304
+ fileDiffs.map(({ filePath }) => filePath),
305
+ );
306
+
307
+ if (explanations.size === fileDiffs.length) {
308
+ return explanations;
309
+ }
310
+
311
+ if (response.trim().length > 0) {
312
+ console.log(
313
+ chalk.yellow(
314
+ "Gemini returned incomplete diff explanations; using fallback text for missing files.",
315
+ ),
316
+ );
317
+ }
318
+
319
+ fileDiffs.forEach(({ filePath }) => {
320
+ if (!explanations.has(filePath)) {
321
+ explanations.set(
322
+ filePath,
323
+ `Updates ${filePath} with the selected changes shown below.`,
324
+ );
325
+ }
326
+ });
327
+
328
+ return explanations;
329
+ };
330
+
331
+ export const generateDiffFileName = async (
332
+ rawDiff: string,
333
+ ): Promise<string> => {
334
+ const prompt = buildDiffFileNamePrompt(rawDiff);
335
+ const rawName = await requestText(prompt, "type: chore\ntopic: changes", {
336
+ operation: "diff-filename",
337
+ });
338
+
339
+ return parseDiffFileName(rawName);
340
+ };
package/src/prompt.ts ADDED
@@ -0,0 +1,144 @@
1
+ export const buildPrompt = (diff: string, branchName: string): string =>
2
+ `
3
+ CRITICAL INSTRUCTIONS - READ CAREFULLY:
4
+ You are an expert Git commit message writer. You MUST follow ALL these rules:
5
+
6
+ 1. FORMAT: Use Conventional Commits format: <type>(<scope>/<branch_name>): <description>
7
+ - type: MUST be one of: feat, fix, refactor, chore, docs, style, test, perf, bugfix
8
+ - scope: Should be the module/file affected (e.g., "auth", "api", "ui", "config", "feature")
9
+ - branch_name: The current Git branch you are on. It is "${branchName || "unknown"}"
10
+ - description: Clear, imperative description in present tense
11
+
12
+ 2. DESCRIPTION REQUIREMENTS:
13
+ - Start with an imperative verb (add, fix, remove, update, refactor, etc.)
14
+ - Be specific about what changed
15
+ - Keep the first line (the summary) under 72 characters total
16
+ - NO trailing punctuation on the summary line
17
+ - NO emojis ever
18
+ - MUST be a complete sentence
19
+ - Group related file changes together. If you change a service, controller, and route for the same feature (e.g. "auth"), summarize the collective change in just ONE line.
20
+ - Do NOT list every file that changed.
21
+
22
+ 3. MESSAGE STRUCTURE:
23
+ - The first line must be the summary: type(scope/${branchName || "unknown"}): description
24
+ - If there is ONLY ONE logical feature/fix being made (even if across multiple files), the commit message MUST BE EXACTLY ONE LINE. Do not use bullet points.
25
+ - ONLY if there are entirely UNRELATED distinct features changed at once, you may add a blank line after the summary, followed by a bulleted list summarizing those distinct features.
26
+ - Example 1 (Single logical change spanning multiple files): "feat(auth/${branchName || "unknown"}): implement jwt authentication flow"
27
+ - Example 2 (Unrelated distinct changes):
28
+ feat(core/${branchName || "unknown"}): update foundational systems
29
+
30
+ - handle null response in user endpoint
31
+ - add rate limiting to requests
32
+
33
+ 4. QUALITY CHECKS - YOUR OUTPUT MUST PASS:
34
+ - Contains opening and closing parentheses
35
+ - Has a colon after the parentheses
36
+ - Description exists and is not empty
37
+ - Summary line total length ≤ 72 characters
38
+ - No markdown formatting
39
+ - No code blocks
40
+ - No explanations or notes
41
+
42
+ 5. FAILURE MODE:
43
+ - If you cannot generate a proper message, return exactly: "chore: update code"
44
+
45
+ YOUR TASK:
46
+ Analyze this git diff and generate exactly ONE proper commit message following all rules above.
47
+
48
+ Git diff:
49
+ ${diff}
50
+
51
+ Commit message:
52
+ `.trim();
53
+
54
+ export const buildDiffExplanationPrompt = (
55
+ filePath: string,
56
+ diff: string,
57
+ branchName: string,
58
+ ): string =>
59
+ `
60
+ You are explaining a staged git diff to a developer.
61
+
62
+ Rules:
63
+ - Explain only the changes shown for this file
64
+ - Write 2 to 4 short sentences
65
+ - Be concrete and precise
66
+ - Focus on what changed and why it matters
67
+ - No markdown headings
68
+ - No bullet points
69
+ - No code fences
70
+ - Do not restate the entire diff line by line
71
+ - The current branch is "${branchName || "unknown"}"
72
+
73
+ File:
74
+ ${filePath}
75
+
76
+ Diff:
77
+ ${diff}
78
+
79
+ Explanation:
80
+ `.trim();
81
+
82
+ export const buildBatchDiffExplanationsPrompt = (
83
+ fileDiffs: Array<{ filePath: string; diff: string }>,
84
+ branchName: string,
85
+ ): string =>
86
+ `
87
+ You are explaining staged git diffs to a developer.
88
+
89
+ Rules:
90
+ - Write exactly one explanation block for each file shown below
91
+ - Keep each explanation to 2 to 4 short sentences
92
+ - Be concrete and precise
93
+ - Focus on what changed and why it matters
94
+ - No markdown headings
95
+ - No bullet points
96
+ - No code fences
97
+ - Do not restate the diff line by line
98
+ - Use the exact file path provided
99
+ - Return blocks in this exact format:
100
+ FILE: <exact file path>
101
+ EXPLANATION: <plain text explanation>
102
+ END_FILE
103
+ - Return nothing except these blocks
104
+ - The current branch is "${branchName || "unknown"}"
105
+
106
+ Files and diffs:
107
+ ${fileDiffs
108
+ .map(
109
+ ({ filePath, diff }) => `
110
+ FILE: ${filePath}
111
+ DIFF:
112
+ ${diff}
113
+ END_DIFF
114
+ `.trim(),
115
+ )
116
+ .join("\n\n")}
117
+
118
+ Result:
119
+ `.trim();
120
+
121
+ export const buildDiffFileNamePrompt = (
122
+ diff: string,
123
+ ): string =>
124
+ `
125
+ Classify these staged changes and produce a concise structured filename basis.
126
+
127
+ Rules:
128
+ - Return exactly two lines and nothing else
129
+ - First line format: type: <value>
130
+ - Second line format: topic: <value>
131
+ - type must be one of: feat, fix, refactor, chore, docs, style, test, perf, bugfix
132
+ - topic must be 1 to 3 lowercase hyphen-separated words
133
+ - topic must describe the actual change area
134
+ - Do not include branch names
135
+ - Do not include dates
136
+ - Do not include the .md extension
137
+ - Do not include quotes, markdown, bullets, or explanations
138
+ - Avoid generic filler words like proposed, report, diff, and changes unless absolutely necessary
139
+
140
+ Git diff:
141
+ ${diff}
142
+
143
+ Result:
144
+ `.trim();