@ksw8954/git-ai-commit 1.1.5 → 1.1.7
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/CHANGELOG.md +25 -0
- package/dist/commands/ai.d.ts +9 -1
- package/dist/commands/ai.d.ts.map +1 -1
- package/dist/commands/ai.js +104 -46
- package/dist/commands/ai.js.map +1 -1
- package/dist/commands/commit.d.ts.map +1 -1
- package/dist/commands/commit.js +2 -0
- package/dist/commands/commit.js.map +1 -1
- package/dist/commands/completion.js +1 -1
- package/dist/commands/config.d.ts +6 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +13 -0
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/configCommand.d.ts +2 -0
- package/dist/commands/configCommand.d.ts.map +1 -1
- package/dist/commands/configCommand.js +16 -0
- package/dist/commands/configCommand.js.map +1 -1
- package/dist/commands/git.d.ts +2 -0
- package/dist/commands/git.d.ts.map +1 -1
- package/dist/commands/git.js +30 -0
- package/dist/commands/git.js.map +1 -1
- package/dist/commands/prCommand.d.ts.map +1 -1
- package/dist/commands/prCommand.js +2 -0
- package/dist/commands/prCommand.js.map +1 -1
- package/dist/commands/tag.d.ts +4 -0
- package/dist/commands/tag.d.ts.map +1 -1
- package/dist/commands/tag.js +128 -2
- package/dist/commands/tag.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/ai.test.ts +45 -68
- package/src/__tests__/tagCommand.test.ts +9 -2
- package/src/commands/ai.ts +131 -53
- package/src/commands/commit.ts +2 -0
- package/src/commands/completion.ts +1 -1
- package/src/commands/config.ts +19 -0
- package/src/commands/configCommand.ts +22 -0
- package/src/commands/git.ts +35 -0
- package/src/commands/prCommand.ts +2 -0
- package/src/commands/tag.ts +157 -2
|
@@ -15,7 +15,10 @@ jest.mock('../commands/git', () => ({
|
|
|
15
15
|
deleteLocalTag: jest.fn(),
|
|
16
16
|
deleteRemoteTag: jest.fn(),
|
|
17
17
|
forcePushTag: jest.fn(),
|
|
18
|
-
getRemotes: jest.fn()
|
|
18
|
+
getRemotes: jest.fn(),
|
|
19
|
+
getTagMessage: jest.fn(),
|
|
20
|
+
getTagBefore: jest.fn(),
|
|
21
|
+
getRecentTags: jest.fn()
|
|
19
22
|
}
|
|
20
23
|
}));
|
|
21
24
|
|
|
@@ -55,6 +58,10 @@ describe('TagCommand', () => {
|
|
|
55
58
|
(GitService.tagExists as jest.Mock).mockResolvedValue(false);
|
|
56
59
|
(GitService.remoteTagExists as jest.Mock).mockResolvedValue(false);
|
|
57
60
|
|
|
61
|
+
(GitService.getTagMessage as jest.Mock).mockResolvedValue(null);
|
|
62
|
+
(GitService.getTagBefore as jest.Mock).mockResolvedValue({ success: false, error: 'No earlier tag found.' });
|
|
63
|
+
(GitService.getRecentTags as jest.Mock).mockResolvedValue([]);
|
|
64
|
+
|
|
58
65
|
// Default: no remotes configured (skip push flow)
|
|
59
66
|
(GitService.getRemotes as jest.Mock).mockResolvedValue([]);
|
|
60
67
|
|
|
@@ -120,7 +127,7 @@ describe('TagCommand', () => {
|
|
|
120
127
|
model: 'gpt-test',
|
|
121
128
|
language: 'ko'
|
|
122
129
|
});
|
|
123
|
-
expect(mockGenerateTagNotes).toHaveBeenCalledWith('v1.3.0', '- feat: add feature\n- fix: bug fix', undefined);
|
|
130
|
+
expect(mockGenerateTagNotes).toHaveBeenCalledWith('v1.3.0', '- feat: add feature\n- fix: bug fix', undefined, null, null);
|
|
124
131
|
expect(GitService.createAnnotatedTag).toHaveBeenCalledWith('v1.3.0', '- Added feature\n- Fixed bug');
|
|
125
132
|
expect(GitService.pushTag).not.toHaveBeenCalled();
|
|
126
133
|
});
|
package/src/commands/ai.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
+
import { type ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions';
|
|
2
3
|
import { generateCommitPrompt } from '../prompts/commit';
|
|
3
4
|
import { generateTagPrompt } from '../prompts/tag';
|
|
4
5
|
import { generatePullRequestPrompt } from '../prompts/pr';
|
|
5
6
|
import { SupportedLanguage } from './config';
|
|
6
7
|
|
|
8
|
+
export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high';
|
|
9
|
+
|
|
7
10
|
export interface AIServiceConfig {
|
|
8
11
|
apiKey: string;
|
|
9
12
|
baseURL?: string;
|
|
10
13
|
model?: string;
|
|
14
|
+
fallbackModel?: string;
|
|
15
|
+
reasoningEffort?: ReasoningEffort;
|
|
11
16
|
language?: SupportedLanguage;
|
|
12
17
|
verbose?: boolean;
|
|
13
18
|
}
|
|
@@ -33,6 +38,8 @@ export interface PullRequestGenerationResult {
|
|
|
33
38
|
export class AIService {
|
|
34
39
|
private openai: OpenAI;
|
|
35
40
|
private model: string;
|
|
41
|
+
private fallbackModel?: string;
|
|
42
|
+
private reasoningEffort?: ReasoningEffort;
|
|
36
43
|
private language: SupportedLanguage;
|
|
37
44
|
private verbose: boolean;
|
|
38
45
|
|
|
@@ -42,6 +49,8 @@ export class AIService {
|
|
|
42
49
|
baseURL: config.baseURL
|
|
43
50
|
});
|
|
44
51
|
this.model = config.model || 'zai-org/GLM-4.5-FP8';
|
|
52
|
+
this.fallbackModel = config.fallbackModel;
|
|
53
|
+
this.reasoningEffort = config.reasoningEffort;
|
|
45
54
|
this.language = config.language || 'ko';
|
|
46
55
|
this.verbose = config.verbose ?? true;
|
|
47
56
|
}
|
|
@@ -52,6 +61,12 @@ export class AIService {
|
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
private isRateLimitError(error: unknown): boolean {
|
|
65
|
+
if (!error || typeof error !== 'object') return false;
|
|
66
|
+
const status = (error as { status?: number }).status;
|
|
67
|
+
return status === 429;
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
private isUnsupportedTokenParamError(
|
|
56
71
|
error: unknown,
|
|
57
72
|
param: 'max_tokens' | 'max_completion_tokens'
|
|
@@ -134,29 +149,132 @@ export class AIService {
|
|
|
134
149
|
return { ...rest };
|
|
135
150
|
}
|
|
136
151
|
|
|
137
|
-
private
|
|
138
|
-
|
|
152
|
+
private readonly spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
153
|
+
|
|
154
|
+
private formatElapsed(ms: number): string {
|
|
155
|
+
const seconds = Math.floor(ms / 1000);
|
|
156
|
+
if (seconds < 1) return '0s';
|
|
157
|
+
return `${seconds}s`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async createStreamingCompletion(
|
|
161
|
+
request: ChatCompletionCreateParamsNonStreaming,
|
|
139
162
|
attempt = 0
|
|
140
|
-
): Promise<
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
let waitingTimer: ReturnType<typeof setInterval> | null = null;
|
|
165
|
+
|
|
141
166
|
try {
|
|
142
|
-
|
|
167
|
+
const startTime = Date.now();
|
|
168
|
+
let frameIndex = 0;
|
|
169
|
+
|
|
170
|
+
if (this.verbose) {
|
|
171
|
+
waitingTimer = setInterval(() => {
|
|
172
|
+
const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
|
|
173
|
+
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
174
|
+
process.stdout.write(`\r${frame} Waiting for response... (${elapsed})`);
|
|
175
|
+
}, 100);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const streamParams = {
|
|
179
|
+
...request,
|
|
180
|
+
stream: true as const,
|
|
181
|
+
...(this.reasoningEffort ? { reasoning_effort: this.reasoningEffort } : {})
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const stream = await this.openai.chat.completions.create(streamParams);
|
|
185
|
+
|
|
186
|
+
const contentChunks: string[] = [];
|
|
187
|
+
let reasoningTokens = 0;
|
|
188
|
+
let contentTokens = 0;
|
|
189
|
+
let phase: 'waiting' | 'thinking' | 'content' = 'waiting';
|
|
190
|
+
|
|
191
|
+
for await (const chunk of stream) {
|
|
192
|
+
const delta = chunk.choices[0]?.delta;
|
|
193
|
+
const content = delta?.content;
|
|
194
|
+
const reasoning = (delta as Record<string, unknown>)?.reasoning_content as string | undefined;
|
|
195
|
+
|
|
196
|
+
if (reasoning) {
|
|
197
|
+
reasoningTokens++;
|
|
198
|
+
|
|
199
|
+
if (phase === 'waiting' && waitingTimer) {
|
|
200
|
+
clearInterval(waitingTimer);
|
|
201
|
+
waitingTimer = null;
|
|
202
|
+
phase = 'thinking';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (this.verbose && phase === 'thinking') {
|
|
206
|
+
const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
|
|
207
|
+
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
208
|
+
process.stdout.write(`\r${frame} Thinking... (${reasoningTokens} tokens, ${elapsed})`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (content) {
|
|
213
|
+
contentChunks.push(content);
|
|
214
|
+
contentTokens++;
|
|
215
|
+
|
|
216
|
+
if (phase !== 'content') {
|
|
217
|
+
if (waitingTimer) {
|
|
218
|
+
clearInterval(waitingTimer);
|
|
219
|
+
waitingTimer = null;
|
|
220
|
+
}
|
|
221
|
+
phase = 'content';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (this.verbose) {
|
|
225
|
+
const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
|
|
226
|
+
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
227
|
+
process.stdout.write(`\r${frame} Streaming response... (${contentTokens} tokens, ${elapsed})`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (waitingTimer) {
|
|
233
|
+
clearInterval(waitingTimer);
|
|
234
|
+
waitingTimer = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (this.verbose) {
|
|
238
|
+
const totalTokens = reasoningTokens + contentTokens;
|
|
239
|
+
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
240
|
+
const detail = reasoningTokens > 0
|
|
241
|
+
? `${totalTokens} tokens (thinking: ${reasoningTokens}, response: ${contentTokens}), ${elapsed}`
|
|
242
|
+
: `${contentTokens} tokens, ${elapsed}`;
|
|
243
|
+
process.stdout.write(`\r✅ Complete (${detail})\n`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return contentChunks.join('');
|
|
143
247
|
} catch (error) {
|
|
248
|
+
if (waitingTimer) {
|
|
249
|
+
clearInterval(waitingTimer);
|
|
250
|
+
waitingTimer = null;
|
|
251
|
+
}
|
|
252
|
+
if (this.verbose) {
|
|
253
|
+
process.stdout.write('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
144
256
|
if (attempt < 3 && this.isUnsupportedValueError(error, 'temperature')) {
|
|
145
257
|
const fallbackRequest = this.removeTemperature(request);
|
|
146
258
|
this.debugLog('Retrying without temperature due to unsupported value error.');
|
|
147
|
-
return await this.
|
|
259
|
+
return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
|
|
148
260
|
}
|
|
149
261
|
|
|
150
262
|
if (this.isUnsupportedTokenParamError(error, 'max_completion_tokens')) {
|
|
151
263
|
const fallbackRequest = this.swapTokenParam(request, 'max_tokens');
|
|
152
264
|
this.debugLog('Retrying with max_tokens due to unsupported max_completion_tokens error.');
|
|
153
|
-
return await this.
|
|
265
|
+
return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
|
|
154
266
|
}
|
|
155
267
|
|
|
156
268
|
if (this.isUnsupportedTokenParamError(error, 'max_tokens')) {
|
|
157
269
|
const fallbackRequest = this.swapTokenParam(request, 'max_completion_tokens');
|
|
158
270
|
this.debugLog('Retrying with max_completion_tokens due to unsupported max_tokens error.');
|
|
159
|
-
return await this.
|
|
271
|
+
return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (this.isRateLimitError(error) && this.fallbackModel && request.model !== this.fallbackModel) {
|
|
275
|
+
this.debugLog(`Rate limited (429). Retrying with fallback model: ${this.fallbackModel}`);
|
|
276
|
+
const fallbackRequest = { ...request, model: this.fallbackModel };
|
|
277
|
+
return await this.createStreamingCompletion(fallbackRequest, attempt + 1);
|
|
160
278
|
}
|
|
161
279
|
|
|
162
280
|
throw error;
|
|
@@ -205,7 +323,7 @@ export class AIService {
|
|
|
205
323
|
? `Git diff will be provided separately in the user message.\n\n## Additional User Instructions\n${extraInstructions.trim()}`
|
|
206
324
|
: 'Git diff will be provided separately in the user message.';
|
|
207
325
|
|
|
208
|
-
const
|
|
326
|
+
const content = await this.createStreamingCompletion({
|
|
209
327
|
model: this.model,
|
|
210
328
|
messages: [
|
|
211
329
|
{
|
|
@@ -224,36 +342,8 @@ export class AIService {
|
|
|
224
342
|
max_completion_tokens: 3000
|
|
225
343
|
});
|
|
226
344
|
|
|
227
|
-
|
|
345
|
+
let finalMessage = content.trim() || null;
|
|
228
346
|
|
|
229
|
-
const choice = response.choices[0];
|
|
230
|
-
const message = choice?.message?.content?.trim();
|
|
231
|
-
|
|
232
|
-
// Handle reasoning content if available (type assertion for custom API response)
|
|
233
|
-
const messageAny = choice?.message as any;
|
|
234
|
-
const reasoningMessage = messageAny?.reasoning_content?.trim();
|
|
235
|
-
|
|
236
|
-
// Try to extract commit message from reasoning content if regular content is null
|
|
237
|
-
let finalMessage = message;
|
|
238
|
-
if (!finalMessage && reasoningMessage) {
|
|
239
|
-
// Look for commit message pattern in reasoning content
|
|
240
|
-
const commitMatch = reasoningMessage.match(/(?:feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert): .+/);
|
|
241
|
-
if (commitMatch) {
|
|
242
|
-
finalMessage = commitMatch[0].trim();
|
|
243
|
-
} else {
|
|
244
|
-
// Look for any line that starts with conventional commit types
|
|
245
|
-
const typeMatch = reasoningMessage.match(/(?:feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)[^:]*: .+/);
|
|
246
|
-
if (typeMatch) {
|
|
247
|
-
finalMessage = typeMatch[0].trim();
|
|
248
|
-
} else {
|
|
249
|
-
// Try to find a short descriptive line
|
|
250
|
-
const lines = reasoningMessage.split('\n').filter((line: string) => line.trim().length > 0);
|
|
251
|
-
const shortLine = lines.find((line: string) => line.length < 100 && line.includes('version'));
|
|
252
|
-
finalMessage = shortLine ? `chore: ${shortLine.trim()}` : `chore: update files`;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
347
|
if (!finalMessage) {
|
|
258
348
|
this.debugLog('No message found in response');
|
|
259
349
|
return {
|
|
@@ -381,7 +471,7 @@ export class AIService {
|
|
|
381
471
|
userContent += `\n\n---\nPrevious release notes for this tag (improve upon this):\n${previousMessage}`;
|
|
382
472
|
}
|
|
383
473
|
|
|
384
|
-
const
|
|
474
|
+
const content = await this.createStreamingCompletion({
|
|
385
475
|
model: this.model,
|
|
386
476
|
messages: [
|
|
387
477
|
{
|
|
@@ -396,13 +486,7 @@ export class AIService {
|
|
|
396
486
|
max_completion_tokens: 3000
|
|
397
487
|
});
|
|
398
488
|
|
|
399
|
-
const
|
|
400
|
-
const message = choice?.message?.content?.trim();
|
|
401
|
-
|
|
402
|
-
const messageAny = choice?.message as any;
|
|
403
|
-
const reasoningMessage = messageAny?.reasoning_content?.trim();
|
|
404
|
-
|
|
405
|
-
const finalNotes = message || reasoningMessage;
|
|
489
|
+
const finalNotes = content.trim() || null;
|
|
406
490
|
|
|
407
491
|
if (!finalNotes) {
|
|
408
492
|
this.debugLog('No notes found in response');
|
|
@@ -435,7 +519,7 @@ export class AIService {
|
|
|
435
519
|
this.debugLog('Model:', this.model);
|
|
436
520
|
this.debugLog('Base URL:', this.openai.baseURL);
|
|
437
521
|
|
|
438
|
-
const
|
|
522
|
+
const content = await this.createStreamingCompletion({
|
|
439
523
|
model: this.model,
|
|
440
524
|
messages: [
|
|
441
525
|
{
|
|
@@ -455,13 +539,7 @@ export class AIService {
|
|
|
455
539
|
max_completion_tokens: 4000
|
|
456
540
|
});
|
|
457
541
|
|
|
458
|
-
const
|
|
459
|
-
const message = choice?.message?.content?.trim();
|
|
460
|
-
|
|
461
|
-
const messageAny = choice?.message as any;
|
|
462
|
-
const reasoningMessage = messageAny?.reasoning_content?.trim();
|
|
463
|
-
|
|
464
|
-
const finalMessage = message || reasoningMessage;
|
|
542
|
+
const finalMessage = content.trim() || null;
|
|
465
543
|
|
|
466
544
|
if (!finalMessage) {
|
|
467
545
|
return {
|
package/src/commands/commit.ts
CHANGED
|
@@ -161,6 +161,8 @@ export class CommitCommand {
|
|
|
161
161
|
apiKey: mergedApiKey!,
|
|
162
162
|
baseURL: mergedBaseURL,
|
|
163
163
|
model: mergedModel,
|
|
164
|
+
fallbackModel: existingConfig.fallbackModel,
|
|
165
|
+
reasoningEffort: existingConfig.reasoningEffort,
|
|
164
166
|
language: existingConfig.language,
|
|
165
167
|
verbose: !messageOnly,
|
|
166
168
|
};
|
|
@@ -40,7 +40,7 @@ _git_ai_commit() {
|
|
|
40
40
|
|
|
41
41
|
# Command-specific options
|
|
42
42
|
local commit_opts="-k --api-key -b --base-url --model -m --message-only -p --push --prompt --no-verify"
|
|
43
|
-
local config_opts="-s --show -l --language --auto-push --no-auto-push -k --api-key -b --base-url -m --model --mode"
|
|
43
|
+
local config_opts="-s --show -l --language --auto-push --no-auto-push -k --api-key -b --base-url -m --model --fallback-model --reasoning-effort --mode"
|
|
44
44
|
local pr_opts="--base --compare -k --api-key -b --base-url --model"
|
|
45
45
|
local tag_opts="-k --api-key --base-url -m --model --message -t --base-tag --prompt"
|
|
46
46
|
local history_opts="-l --limit --json --clear"
|
package/src/commands/config.ts
CHANGED
|
@@ -5,10 +5,14 @@ import path from 'path';
|
|
|
5
5
|
|
|
6
6
|
export type SupportedLanguage = 'ko' | 'en';
|
|
7
7
|
|
|
8
|
+
export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high';
|
|
9
|
+
|
|
8
10
|
export interface EnvironmentConfig {
|
|
9
11
|
apiKey?: string;
|
|
10
12
|
baseURL?: string;
|
|
11
13
|
model?: string;
|
|
14
|
+
fallbackModel?: string;
|
|
15
|
+
reasoningEffort?: ReasoningEffort;
|
|
12
16
|
mode: 'custom' | 'openai';
|
|
13
17
|
language: SupportedLanguage;
|
|
14
18
|
autoPush: boolean;
|
|
@@ -18,6 +22,8 @@ interface StoredConfig {
|
|
|
18
22
|
apiKey?: string;
|
|
19
23
|
baseURL?: string;
|
|
20
24
|
model?: string;
|
|
25
|
+
fallbackModel?: string;
|
|
26
|
+
reasoningEffort?: ReasoningEffort | string;
|
|
21
27
|
mode?: 'custom' | 'openai';
|
|
22
28
|
language?: SupportedLanguage | string;
|
|
23
29
|
autoPush?: boolean;
|
|
@@ -68,6 +74,15 @@ export class ConfigService {
|
|
|
68
74
|
return normalized === 'en' ? 'en' : 'ko';
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
private static normalizeReasoningEffort(effort?: string): ReasoningEffort | undefined {
|
|
78
|
+
if (!effort) return undefined;
|
|
79
|
+
const normalized = effort.toLowerCase();
|
|
80
|
+
if (['minimal', 'low', 'medium', 'high'].includes(normalized)) {
|
|
81
|
+
return normalized as ReasoningEffort;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
71
86
|
private static normalizeMode(mode?: string): 'custom' | 'openai' {
|
|
72
87
|
if (!mode) {
|
|
73
88
|
return DEFAULT_MODE;
|
|
@@ -111,6 +126,8 @@ export class ConfigService {
|
|
|
111
126
|
const apiKey = fileConfig.apiKey ?? envConfig.apiKey;
|
|
112
127
|
const baseURL = fileConfig.baseURL ?? envConfig.baseURL;
|
|
113
128
|
const model = fileConfig.model ?? envConfig.model ?? DEFAULT_MODEL;
|
|
129
|
+
const fallbackModel = fileConfig.fallbackModel;
|
|
130
|
+
const reasoningEffort = this.normalizeReasoningEffort(fileConfig.reasoningEffort);
|
|
114
131
|
const language = this.normalizeLanguage(fileConfig.language ?? envConfig.language);
|
|
115
132
|
const autoPush = typeof fileConfig.autoPush === 'boolean' ? fileConfig.autoPush : envConfig.autoPush;
|
|
116
133
|
|
|
@@ -118,6 +135,8 @@ export class ConfigService {
|
|
|
118
135
|
apiKey,
|
|
119
136
|
baseURL,
|
|
120
137
|
model,
|
|
138
|
+
fallbackModel,
|
|
139
|
+
reasoningEffort,
|
|
121
140
|
mode,
|
|
122
141
|
language,
|
|
123
142
|
autoPush
|
|
@@ -8,6 +8,8 @@ export interface ConfigOptions {
|
|
|
8
8
|
apiKey?: string;
|
|
9
9
|
baseUrl?: string;
|
|
10
10
|
model?: string;
|
|
11
|
+
fallbackModel?: string;
|
|
12
|
+
reasoningEffort?: string;
|
|
11
13
|
mode?: 'custom' | 'openai';
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -24,6 +26,8 @@ export class ConfigCommand {
|
|
|
24
26
|
.option('-k, --api-key <key>', 'Persist API key for AI requests (overrides environment variables)')
|
|
25
27
|
.option('-b, --base-url <url>', 'Persist API base URL (overrides environment variables)')
|
|
26
28
|
.option('-m, --model <model>', 'Persist default AI model')
|
|
29
|
+
.option('--fallback-model <model>', 'Persist fallback model for rate limit (429) retry')
|
|
30
|
+
.option('--reasoning-effort <level>', 'Thinking effort for reasoning models (minimal | low | medium | high)')
|
|
27
31
|
.option('--mode <mode>', 'Persist AI mode (custom | openai)')
|
|
28
32
|
.action(this.handleConfig.bind(this));
|
|
29
33
|
}
|
|
@@ -62,6 +66,8 @@ export class ConfigCommand {
|
|
|
62
66
|
apiKey?: string;
|
|
63
67
|
baseURL?: string;
|
|
64
68
|
model?: string;
|
|
69
|
+
fallbackModel?: string;
|
|
70
|
+
reasoningEffort?: string;
|
|
65
71
|
mode?: 'custom' | 'openai';
|
|
66
72
|
language?: SupportedLanguage;
|
|
67
73
|
autoPush?: boolean;
|
|
@@ -87,6 +93,19 @@ export class ConfigCommand {
|
|
|
87
93
|
updates.model = this.sanitizeStringValue(options.model);
|
|
88
94
|
}
|
|
89
95
|
|
|
96
|
+
if (options.fallbackModel !== undefined) {
|
|
97
|
+
updates.fallbackModel = this.sanitizeStringValue(options.fallbackModel);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (options.reasoningEffort !== undefined) {
|
|
101
|
+
const effort = options.reasoningEffort.toLowerCase();
|
|
102
|
+
if (!['minimal', 'low', 'medium', 'high'].includes(effort)) {
|
|
103
|
+
console.error('Reasoning effort must be one of: minimal, low, medium, high');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
updates.reasoningEffort = effort;
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
if (options.mode) {
|
|
91
110
|
updates.mode = this.validateMode(options.mode);
|
|
92
111
|
}
|
|
@@ -102,6 +121,7 @@ export class ConfigCommand {
|
|
|
102
121
|
console.log(' git-ai-commit config -b https://api.test # Persist custom API base URL');
|
|
103
122
|
console.log(' git-ai-commit config --mode openai # Use OpenAI-compatible environment defaults');
|
|
104
123
|
console.log(' git-ai-commit config --model gpt-4o-mini # Persist preferred AI model');
|
|
124
|
+
console.log(' git-ai-commit config --fallback-model glm-4-flash # Fallback model for 429 retry');
|
|
105
125
|
return;
|
|
106
126
|
}
|
|
107
127
|
|
|
@@ -125,6 +145,8 @@ export class ConfigCommand {
|
|
|
125
145
|
console.log(`API Key: ${config.apiKey ? '***' + config.apiKey.slice(-4) : 'Not set'}`);
|
|
126
146
|
console.log(`Base URL: ${config.baseURL || 'Not set (using provider default)'}`);
|
|
127
147
|
console.log(`Model: ${config.model || 'zai-org/GLM-4.5-FP8 (default)'}`);
|
|
148
|
+
console.log(`Fallback Model: ${config.fallbackModel || 'Not set'}`);
|
|
149
|
+
console.log(`Reasoning Effort: ${config.reasoningEffort || 'Not set (model default)'}`);
|
|
128
150
|
console.log(`Mode: ${config.mode || 'custom (default)'}`);
|
|
129
151
|
} catch (error) {
|
|
130
152
|
console.error('Error reading configuration:', error instanceof Error ? error.message : error);
|
package/src/commands/git.ts
CHANGED
|
@@ -210,6 +210,41 @@ export class GitService {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
static async getRecentTags(count = 10): Promise<string[]> {
|
|
214
|
+
try {
|
|
215
|
+
const { stdout } = await execAsync(
|
|
216
|
+
`git tag --sort=-creatordate | head -n ${count}`
|
|
217
|
+
);
|
|
218
|
+
return stdout
|
|
219
|
+
.split('\n')
|
|
220
|
+
.map(line => line.trim())
|
|
221
|
+
.filter(line => line.length > 0);
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
static async getTagBefore(tagName: string): Promise<GitTagResult> {
|
|
228
|
+
try {
|
|
229
|
+
const { stdout: tagCommit } = await execAsync(`git rev-list -n 1 ${tagName}`);
|
|
230
|
+
const commit = tagCommit.trim();
|
|
231
|
+
if (!commit) {
|
|
232
|
+
return { success: false, error: 'Could not resolve tag to a commit.' };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { stdout } = await execAsync(`git describe --tags --abbrev=0 ${commit}~1`);
|
|
236
|
+
const tag = stdout.trim();
|
|
237
|
+
|
|
238
|
+
if (!tag) {
|
|
239
|
+
return { success: false, error: 'No earlier tag found.' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { success: true, tag };
|
|
243
|
+
} catch {
|
|
244
|
+
return { success: false, error: 'No earlier tag found.' };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
213
248
|
static async getCommitSummariesSince(tag?: string): Promise<GitLogResult> {
|
|
214
249
|
try {
|
|
215
250
|
const logCommand = tag
|
|
@@ -61,6 +61,8 @@ export class PullRequestCommand {
|
|
|
61
61
|
apiKey: mergedApiKey!,
|
|
62
62
|
baseURL: mergedBaseURL,
|
|
63
63
|
model: mergedModel,
|
|
64
|
+
fallbackModel: existingConfig.fallbackModel,
|
|
65
|
+
reasoningEffort: existingConfig.reasoningEffort,
|
|
64
66
|
language: existingConfig.language,
|
|
65
67
|
verbose: false
|
|
66
68
|
});
|