@dongowu/git-ai-cli 1.0.19 → 1.0.21
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/README.md +171 -94
- package/README_EN.md +179 -84
- package/dist/cli.js +2 -135
- package/dist/cli.js.map +1 -1
- package/dist/cli_main.d.ts +2 -0
- package/dist/cli_main.d.ts.map +1 -0
- package/dist/cli_main.js +255 -0
- package/dist/cli_main.js.map +1 -0
- package/dist/commands/branch.d.ts +11 -0
- package/dist/commands/branch.d.ts.map +1 -0
- package/dist/commands/branch.js +279 -0
- package/dist/commands/branch.js.map +1 -0
- package/dist/commands/commit.d.ts.map +1 -1
- package/dist/commands/commit.js +52 -9
- package/dist/commands/commit.js.map +1 -1
- package/dist/commands/config_manage.d.ts +14 -0
- package/dist/commands/config_manage.d.ts.map +1 -0
- package/dist/commands/config_manage.js +394 -0
- package/dist/commands/config_manage.js.map +1 -0
- package/dist/commands/hook.d.ts.map +1 -1
- package/dist/commands/hook.js +124 -6
- package/dist/commands/hook.js.map +1 -1
- package/dist/commands/msg.d.ts +1 -0
- package/dist/commands/msg.d.ts.map +1 -1
- package/dist/commands/msg.js +40 -6
- package/dist/commands/msg.js.map +1 -1
- package/dist/commands/pr.d.ts +8 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +96 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/release.d.ts +8 -0
- package/dist/commands/release.d.ts.map +1 -0
- package/dist/commands/release.js +95 -0
- package/dist/commands/release.js.map +1 -0
- package/dist/commands/report.d.ts +7 -2
- package/dist/commands/report.d.ts.map +1 -1
- package/dist/commands/report.js +99 -15
- package/dist/commands/report.js.map +1 -1
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/agent_lite.d.ts +5 -0
- package/dist/utils/agent_lite.d.ts.map +1 -0
- package/dist/utils/agent_lite.js +263 -0
- package/dist/utils/agent_lite.js.map +1 -0
- package/dist/utils/ai.d.ts +22 -0
- package/dist/utils/ai.d.ts.map +1 -1
- package/dist/utils/ai.js +852 -37
- package/dist/utils/ai.js.map +1 -1
- package/dist/utils/config.d.ts +5 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +157 -5
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/git.d.ts +17 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +275 -49
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/update.d.ts +3 -1
- package/dist/utils/update.d.ts.map +1 -1
- package/dist/utils/update.js +53 -2
- package/dist/utils/update.js.map +1 -1
- package/package.json +1 -1
- package/release_notes.md +3 -2
package/dist/utils/ai.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
2
|
import { runAgentLoop } from './agent.js';
|
|
3
|
+
import { runAgentLite } from './agent_lite.js';
|
|
3
4
|
import { getFileStats } from './git.js';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
function getTimeoutMs() {
|
|
@@ -9,6 +10,45 @@ function getTimeoutMs() {
|
|
|
9
10
|
return parsed;
|
|
10
11
|
return 120_000; // 2 minutes
|
|
11
12
|
}
|
|
13
|
+
function parseBooleanEnv(value) {
|
|
14
|
+
if (!value)
|
|
15
|
+
return undefined;
|
|
16
|
+
const normalized = value.trim().toLowerCase();
|
|
17
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized))
|
|
18
|
+
return true;
|
|
19
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(normalized))
|
|
20
|
+
return false;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function getMaxOutputTokens(numChoices) {
|
|
24
|
+
const raw = process.env.GIT_AI_MAX_OUTPUT_TOKENS || process.env.OCO_TOKENS_MAX_OUTPUT;
|
|
25
|
+
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
26
|
+
const base = Number.isFinite(parsed) && parsed > 0 ? parsed : 500;
|
|
27
|
+
return base * Math.max(numChoices, 1);
|
|
28
|
+
}
|
|
29
|
+
function getModelCandidates(config) {
|
|
30
|
+
const primary = (config.model || '').trim();
|
|
31
|
+
const fallbacks = Array.isArray(config.fallbackModels) ? config.fallbackModels : [];
|
|
32
|
+
const cleaned = fallbacks.map((m) => String(m).trim()).filter(Boolean);
|
|
33
|
+
const unique = [primary, ...cleaned.filter((m) => m && m !== primary)];
|
|
34
|
+
return unique.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
function redactSecrets(input) {
|
|
37
|
+
if (!input)
|
|
38
|
+
return input;
|
|
39
|
+
// Generic "api key: xxxx" patterns.
|
|
40
|
+
let out = input.replace(/(api[_ -]?key\s*[:=]\s*)([^\s,;]+)/gi, (_m, p1, p2) => {
|
|
41
|
+
const s = String(p2);
|
|
42
|
+
if (s.length <= 8)
|
|
43
|
+
return `${p1}********`;
|
|
44
|
+
return `${p1}${s.slice(0, 2)}****${s.slice(-2)}`;
|
|
45
|
+
});
|
|
46
|
+
// Common OpenAI-style keys: sk-...
|
|
47
|
+
out = out.replace(/\bsk-[A-Za-z0-9]{8,}\b/g, (m) => `${m.slice(0, 4)}****${m.slice(-2)}`);
|
|
48
|
+
// Avoid leaking long tokens in error messages.
|
|
49
|
+
out = out.replace(/\b[A-Za-z0-9_-]{24,}\b/g, (m) => `${m.slice(0, 3)}****${m.slice(-3)}`);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
12
52
|
function formatAgentFailureReason(error) {
|
|
13
53
|
const err = error;
|
|
14
54
|
const status = typeof err?.status === 'number' ? String(err.status) : '';
|
|
@@ -18,19 +58,30 @@ function formatAgentFailureReason(error) {
|
|
|
18
58
|
const rawMsg = (typeof err?.error?.message === 'string' && err.error.message) ||
|
|
19
59
|
(typeof err?.message === 'string' && err.message) ||
|
|
20
60
|
'';
|
|
21
|
-
const compactMsg = rawMsg.replace(/\s+/g, ' ').trim();
|
|
61
|
+
const compactMsg = redactSecrets(rawMsg).replace(/\s+/g, ' ').trim();
|
|
22
62
|
const shortMsg = compactMsg.length > 180 ? compactMsg.slice(0, 180) + '...' : compactMsg;
|
|
23
63
|
const parts = [status || name, code, type, shortMsg].filter(Boolean);
|
|
24
64
|
return parts.join(' ');
|
|
25
65
|
}
|
|
26
|
-
function
|
|
66
|
+
function resolveAgentStrategy(_config) {
|
|
67
|
+
const raw = (process.env.GIT_AI_AGENT_STRATEGY || '').trim().toLowerCase();
|
|
68
|
+
if (raw === 'tools' || raw === 'tool' || raw === 'function' || raw === 'functions')
|
|
69
|
+
return 'tools';
|
|
70
|
+
if (raw === 'lite' || raw === 'local' || raw === 'fast')
|
|
71
|
+
return 'lite';
|
|
72
|
+
// Default: lite (fewer API calls, works for providers without tool calling).
|
|
73
|
+
// Users can opt-in to tool calling with GIT_AI_AGENT_STRATEGY=tools.
|
|
74
|
+
return 'lite';
|
|
75
|
+
}
|
|
76
|
+
function resolveAgentModel(config, strategy) {
|
|
27
77
|
const envModel = process.env.GIT_AI_AGENT_MODEL;
|
|
28
78
|
if (envModel && envModel.trim())
|
|
29
79
|
return envModel.trim();
|
|
30
80
|
const configured = config.agentModel;
|
|
31
81
|
if (configured && configured.trim())
|
|
32
82
|
return configured.trim();
|
|
33
|
-
|
|
83
|
+
// DeepSeek reasoner models are often not tool-capable; switch to a tool-capable model only when needed.
|
|
84
|
+
if (strategy === 'tools' && config.provider === 'deepseek') {
|
|
34
85
|
const base = (config.model || '').trim();
|
|
35
86
|
if (base.toLowerCase().includes('reasoner')) {
|
|
36
87
|
return 'deepseek-chat';
|
|
@@ -38,6 +89,545 @@ function resolveAgentModel(config) {
|
|
|
38
89
|
}
|
|
39
90
|
return config.model;
|
|
40
91
|
}
|
|
92
|
+
function isAgentDisabled() {
|
|
93
|
+
return parseBooleanEnv(process.env.GIT_AI_DISABLE_AGENT) === true;
|
|
94
|
+
}
|
|
95
|
+
function isAutoAgentEnabled() {
|
|
96
|
+
const raw = parseBooleanEnv(process.env.GIT_AI_AUTO_AGENT);
|
|
97
|
+
if (raw === undefined)
|
|
98
|
+
return true;
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
function stripCodeFences(text) {
|
|
102
|
+
return text.replace(/^```[a-z]*\n?/i, '').replace(/```$/i, '').trim();
|
|
103
|
+
}
|
|
104
|
+
function tryParseMessagesJson(content) {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(content);
|
|
107
|
+
if (Array.isArray(parsed)) {
|
|
108
|
+
const arr = parsed.filter((item) => typeof item === 'string');
|
|
109
|
+
return arr.map((s) => s.trim()).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
if (parsed && typeof parsed === 'object') {
|
|
112
|
+
const anyObj = parsed;
|
|
113
|
+
if (typeof anyObj.message === 'string') {
|
|
114
|
+
const msg = anyObj.message.trim();
|
|
115
|
+
return msg ? [msg] : [];
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(anyObj.messages)) {
|
|
118
|
+
const arr = anyObj.messages.filter((item) => typeof item === 'string');
|
|
119
|
+
return arr.map((s) => s.trim()).filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const DEFAULT_COMMIT_RULES = {
|
|
129
|
+
types: ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'build', 'ci'],
|
|
130
|
+
maxSubjectLength: 50,
|
|
131
|
+
requireScope: false,
|
|
132
|
+
issuePattern: /([A-Z]+-\d+|#\d+)/,
|
|
133
|
+
issuePlacement: 'footer',
|
|
134
|
+
issueFooterPrefix: 'Refs',
|
|
135
|
+
requireIssue: false,
|
|
136
|
+
};
|
|
137
|
+
const RULES_PRESETS = {
|
|
138
|
+
conventional: {
|
|
139
|
+
types: ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'build', 'ci'],
|
|
140
|
+
maxSubjectLength: 50,
|
|
141
|
+
requireScope: false,
|
|
142
|
+
},
|
|
143
|
+
angular: {
|
|
144
|
+
types: ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'],
|
|
145
|
+
maxSubjectLength: 50,
|
|
146
|
+
requireScope: true,
|
|
147
|
+
},
|
|
148
|
+
minimal: {
|
|
149
|
+
types: ['feat', 'fix', 'docs', 'refactor', 'perf', 'test', 'chore'],
|
|
150
|
+
maxSubjectLength: 50,
|
|
151
|
+
requireScope: false,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
export function validateCommitRules(raw) {
|
|
155
|
+
const errors = [];
|
|
156
|
+
const warnings = [];
|
|
157
|
+
if (raw === undefined)
|
|
158
|
+
return { errors, warnings };
|
|
159
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
160
|
+
errors.push('rules must be an object');
|
|
161
|
+
return { errors, warnings };
|
|
162
|
+
}
|
|
163
|
+
const rules = raw;
|
|
164
|
+
if (rules.types !== undefined) {
|
|
165
|
+
if (!Array.isArray(rules.types)) {
|
|
166
|
+
errors.push('rules.types must be an array of strings');
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const cleaned = rules.types.filter((t) => typeof t === 'string' && t.trim());
|
|
170
|
+
if (cleaned.length === 0) {
|
|
171
|
+
errors.push('rules.types must not be empty');
|
|
172
|
+
}
|
|
173
|
+
if (cleaned.length !== rules.types.length) {
|
|
174
|
+
warnings.push('rules.types contains non-string entries');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (rules.scopes !== undefined) {
|
|
179
|
+
if (!Array.isArray(rules.scopes)) {
|
|
180
|
+
errors.push('rules.scopes must be an array of strings');
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const cleaned = rules.scopes.filter((s) => typeof s === 'string' && s.trim());
|
|
184
|
+
if (cleaned.length !== rules.scopes.length) {
|
|
185
|
+
warnings.push('rules.scopes contains non-string entries');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (rules.scopeMap !== undefined) {
|
|
190
|
+
if (!rules.scopeMap || typeof rules.scopeMap !== 'object' || Array.isArray(rules.scopeMap)) {
|
|
191
|
+
errors.push('rules.scopeMap must be an object map of path -> scope');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const entries = Object.entries(rules.scopeMap);
|
|
195
|
+
const valid = entries.filter(([k, v]) => typeof k === 'string' && k.trim() && typeof v === 'string' && v.trim());
|
|
196
|
+
if (valid.length !== entries.length) {
|
|
197
|
+
warnings.push('rules.scopeMap contains invalid entries');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (rules.maxSubjectLength !== undefined) {
|
|
202
|
+
if (typeof rules.maxSubjectLength !== 'number' || !Number.isFinite(rules.maxSubjectLength)) {
|
|
203
|
+
errors.push('rules.maxSubjectLength must be a number');
|
|
204
|
+
}
|
|
205
|
+
else if (rules.maxSubjectLength <= 10) {
|
|
206
|
+
warnings.push('rules.maxSubjectLength should be > 10');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (rules.requireScope !== undefined && typeof rules.requireScope !== 'boolean') {
|
|
210
|
+
errors.push('rules.requireScope must be a boolean');
|
|
211
|
+
}
|
|
212
|
+
if (rules.issuePattern !== undefined) {
|
|
213
|
+
if (typeof rules.issuePattern !== 'string' || !rules.issuePattern.trim()) {
|
|
214
|
+
errors.push('rules.issuePattern must be a non-empty string');
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
try {
|
|
218
|
+
// eslint-disable-next-line no-new
|
|
219
|
+
new RegExp(rules.issuePattern);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
errors.push('rules.issuePattern must be a valid regex string');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (rules.issuePlacement !== undefined) {
|
|
227
|
+
if (rules.issuePlacement !== 'scope' &&
|
|
228
|
+
rules.issuePlacement !== 'subject' &&
|
|
229
|
+
rules.issuePlacement !== 'footer') {
|
|
230
|
+
errors.push('rules.issuePlacement must be scope, subject, or footer');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (rules.issueFooterPrefix !== undefined && typeof rules.issueFooterPrefix !== 'string') {
|
|
234
|
+
errors.push('rules.issueFooterPrefix must be a string');
|
|
235
|
+
}
|
|
236
|
+
if (rules.requireIssue !== undefined && typeof rules.requireIssue !== 'boolean') {
|
|
237
|
+
errors.push('rules.requireIssue must be a boolean');
|
|
238
|
+
}
|
|
239
|
+
return { errors, warnings };
|
|
240
|
+
}
|
|
241
|
+
function normalizeRules(config) {
|
|
242
|
+
const presetName = (config.rulesPreset || '').trim().toLowerCase();
|
|
243
|
+
const preset = presetName ? RULES_PRESETS[presetName] : undefined;
|
|
244
|
+
const custom = config.rules;
|
|
245
|
+
const resolvedTypes = Array.isArray(custom?.types) && custom.types.length > 0
|
|
246
|
+
? custom.types.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim())
|
|
247
|
+
: Array.isArray(preset?.types) && preset?.types?.length
|
|
248
|
+
? preset.types
|
|
249
|
+
: DEFAULT_COMMIT_RULES.types;
|
|
250
|
+
const types = resolvedTypes.length ? resolvedTypes : DEFAULT_COMMIT_RULES.types;
|
|
251
|
+
const scopes = Array.isArray(custom?.scopes) && custom.scopes.length > 0
|
|
252
|
+
? custom.scopes.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
253
|
+
: Array.isArray(preset?.scopes) && preset?.scopes?.length
|
|
254
|
+
? preset.scopes
|
|
255
|
+
: undefined;
|
|
256
|
+
const presetScopeMap = preset?.scopeMap && typeof preset.scopeMap === 'object'
|
|
257
|
+
? Object.fromEntries(Object.entries(preset.scopeMap).filter(([k, v]) => typeof k === 'string' && typeof v === 'string' && k.trim() && v.trim()))
|
|
258
|
+
: undefined;
|
|
259
|
+
const customScopeMap = custom?.scopeMap && typeof custom.scopeMap === 'object'
|
|
260
|
+
? Object.fromEntries(Object.entries(custom.scopeMap).filter(([k, v]) => typeof k === 'string' && typeof v === 'string' && k.trim() && v.trim()))
|
|
261
|
+
: undefined;
|
|
262
|
+
const scopeMap = presetScopeMap || customScopeMap ? { ...(presetScopeMap || {}), ...(customScopeMap || {}) } : undefined;
|
|
263
|
+
const maxSubjectLength = typeof custom?.maxSubjectLength === 'number' && custom.maxSubjectLength > 10
|
|
264
|
+
? Math.floor(custom.maxSubjectLength)
|
|
265
|
+
: typeof preset?.maxSubjectLength === 'number' && preset.maxSubjectLength > 10
|
|
266
|
+
? Math.floor(preset.maxSubjectLength)
|
|
267
|
+
: DEFAULT_COMMIT_RULES.maxSubjectLength;
|
|
268
|
+
const requireScope = typeof custom?.requireScope === 'boolean'
|
|
269
|
+
? custom.requireScope
|
|
270
|
+
: typeof preset?.requireScope === 'boolean'
|
|
271
|
+
? preset.requireScope
|
|
272
|
+
: DEFAULT_COMMIT_RULES.requireScope;
|
|
273
|
+
const issuePatternRaw = typeof custom?.issuePattern === 'string' && custom.issuePattern.trim()
|
|
274
|
+
? custom.issuePattern.trim()
|
|
275
|
+
: typeof preset?.issuePattern === 'string' && preset.issuePattern.trim()
|
|
276
|
+
? preset.issuePattern.trim()
|
|
277
|
+
: undefined;
|
|
278
|
+
let issuePattern = DEFAULT_COMMIT_RULES.issuePattern;
|
|
279
|
+
if (issuePatternRaw) {
|
|
280
|
+
try {
|
|
281
|
+
issuePattern = new RegExp(issuePatternRaw);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
issuePattern = DEFAULT_COMMIT_RULES.issuePattern;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const issuePlacement = custom?.issuePlacement === 'scope' ||
|
|
288
|
+
custom?.issuePlacement === 'subject' ||
|
|
289
|
+
custom?.issuePlacement === 'footer'
|
|
290
|
+
? custom.issuePlacement
|
|
291
|
+
: preset?.issuePlacement === 'scope' ||
|
|
292
|
+
preset?.issuePlacement === 'subject' ||
|
|
293
|
+
preset?.issuePlacement === 'footer'
|
|
294
|
+
? preset.issuePlacement
|
|
295
|
+
: DEFAULT_COMMIT_RULES.issuePlacement;
|
|
296
|
+
const issueFooterPrefix = typeof custom?.issueFooterPrefix === 'string' && custom.issueFooterPrefix.trim()
|
|
297
|
+
? custom.issueFooterPrefix.trim()
|
|
298
|
+
: typeof preset?.issueFooterPrefix === 'string' && preset.issueFooterPrefix.trim()
|
|
299
|
+
? preset.issueFooterPrefix.trim()
|
|
300
|
+
: DEFAULT_COMMIT_RULES.issueFooterPrefix;
|
|
301
|
+
const requireIssue = typeof custom?.requireIssue === 'boolean'
|
|
302
|
+
? custom.requireIssue
|
|
303
|
+
: typeof preset?.requireIssue === 'boolean'
|
|
304
|
+
? preset.requireIssue
|
|
305
|
+
: DEFAULT_COMMIT_RULES.requireIssue;
|
|
306
|
+
return {
|
|
307
|
+
types,
|
|
308
|
+
scopes,
|
|
309
|
+
scopeMap,
|
|
310
|
+
maxSubjectLength,
|
|
311
|
+
requireScope,
|
|
312
|
+
issuePattern,
|
|
313
|
+
issuePlacement,
|
|
314
|
+
issueFooterPrefix,
|
|
315
|
+
requireIssue,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function normalizePath(path) {
|
|
319
|
+
return path.replace(/\\/g, '/');
|
|
320
|
+
}
|
|
321
|
+
function inferTypeFromBranch(branchName) {
|
|
322
|
+
const branch = branchName || '';
|
|
323
|
+
if (/^feature\//.test(branch))
|
|
324
|
+
return 'feat';
|
|
325
|
+
if (/^bugfix\//.test(branch))
|
|
326
|
+
return 'fix';
|
|
327
|
+
if (/^hotfix\//.test(branch))
|
|
328
|
+
return 'fix';
|
|
329
|
+
if (/^release\//.test(branch))
|
|
330
|
+
return 'chore';
|
|
331
|
+
if (/^docs\//.test(branch))
|
|
332
|
+
return 'docs';
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
function inferScopeFromBranch(branchName) {
|
|
336
|
+
if (!branchName)
|
|
337
|
+
return null;
|
|
338
|
+
const patterns = [
|
|
339
|
+
/^(?:feature|bugfix|hotfix|release|dev)\/([a-zA-Z0-9_-]+)/,
|
|
340
|
+
/^(?:feat|fix|chore|docs)\/([a-zA-Z0-9_-]+)/,
|
|
341
|
+
/^[A-Z]+-\d+/,
|
|
342
|
+
];
|
|
343
|
+
for (const pattern of patterns) {
|
|
344
|
+
const match = branchName.match(pattern);
|
|
345
|
+
if (match) {
|
|
346
|
+
return match[1].toLowerCase().replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
function inferScopeFromPaths(stagedFiles, scopeMap) {
|
|
352
|
+
if (!stagedFiles || stagedFiles.length === 0)
|
|
353
|
+
return null;
|
|
354
|
+
if (scopeMap) {
|
|
355
|
+
const normalizedMap = Object.entries(scopeMap)
|
|
356
|
+
.map(([prefix, scope]) => [normalizePath(prefix), scope])
|
|
357
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
358
|
+
for (const file of stagedFiles) {
|
|
359
|
+
const normalized = normalizePath(file);
|
|
360
|
+
for (const [prefix, scope] of normalizedMap) {
|
|
361
|
+
if (!prefix)
|
|
362
|
+
continue;
|
|
363
|
+
const anchor = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
|
364
|
+
if (normalized.startsWith(prefix) || normalized.includes(anchor)) {
|
|
365
|
+
return scope;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Monorepo auto-scope (packages/apps/services/libs)
|
|
371
|
+
for (const file of stagedFiles) {
|
|
372
|
+
const normalized = normalizePath(file);
|
|
373
|
+
const match = normalized.match(/^(?:packages|apps|services|libs)\/([^/]+)/);
|
|
374
|
+
if (match) {
|
|
375
|
+
return match[1];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
function pickScope(inputScope, rules, branchName, stagedFiles) {
|
|
381
|
+
let scope = inputScope?.trim();
|
|
382
|
+
if (!scope) {
|
|
383
|
+
scope = inferScopeFromBranch(branchName) || inferScopeFromPaths(stagedFiles, rules.scopeMap) || undefined;
|
|
384
|
+
}
|
|
385
|
+
if (scope && rules.scopes && !rules.scopes.includes(scope)) {
|
|
386
|
+
scope = undefined;
|
|
387
|
+
}
|
|
388
|
+
if (!scope && rules.requireScope) {
|
|
389
|
+
scope = inferScopeFromBranch(branchName) || inferScopeFromPaths(stagedFiles, rules.scopeMap) || 'core';
|
|
390
|
+
}
|
|
391
|
+
return scope || null;
|
|
392
|
+
}
|
|
393
|
+
function normalizeType(inputType, rules, branchName) {
|
|
394
|
+
const trimmed = inputType?.trim();
|
|
395
|
+
if (trimmed && rules.types.includes(trimmed))
|
|
396
|
+
return trimmed;
|
|
397
|
+
const inferred = inferTypeFromBranch(branchName);
|
|
398
|
+
if (inferred && rules.types.includes(inferred))
|
|
399
|
+
return inferred;
|
|
400
|
+
return rules.types[0] || 'chore';
|
|
401
|
+
}
|
|
402
|
+
function cleanSubject(subject, rules, locale) {
|
|
403
|
+
let out = (subject || '').trim();
|
|
404
|
+
out = out.replace(/[。.]$/, '').trim();
|
|
405
|
+
if (!out) {
|
|
406
|
+
out = locale === 'zh' ? '更新代码' : 'update code';
|
|
407
|
+
}
|
|
408
|
+
if (out.length > rules.maxSubjectLength) {
|
|
409
|
+
out = out.slice(0, rules.maxSubjectLength).trim();
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
function findIssue(text, pattern) {
|
|
414
|
+
if (!text)
|
|
415
|
+
return null;
|
|
416
|
+
const match = text.match(pattern);
|
|
417
|
+
return match ? match[0] : null;
|
|
418
|
+
}
|
|
419
|
+
function resolveIssueId(message, branchName, rules) {
|
|
420
|
+
const fromMessage = findIssue(message, rules.issuePattern);
|
|
421
|
+
if (fromMessage)
|
|
422
|
+
return { issue: fromMessage, fromMessage: true };
|
|
423
|
+
const fromBranch = findIssue(branchName, rules.issuePattern);
|
|
424
|
+
if (fromBranch)
|
|
425
|
+
return { issue: fromBranch, fromMessage: false };
|
|
426
|
+
return { issue: null, fromMessage: false };
|
|
427
|
+
}
|
|
428
|
+
function normalizeIssueId(issue, placement) {
|
|
429
|
+
if (placement === 'scope') {
|
|
430
|
+
return issue.replace(/^#/, '');
|
|
431
|
+
}
|
|
432
|
+
return issue;
|
|
433
|
+
}
|
|
434
|
+
function formatCommitMessage(type, scope, subject, locale, body, risks, footer) {
|
|
435
|
+
const head = `${type}${scope ? `(${scope})` : ''}: ${subject}`;
|
|
436
|
+
const bodyParts = [];
|
|
437
|
+
if (body && body.trim())
|
|
438
|
+
bodyParts.push(body.trim());
|
|
439
|
+
if (risks && risks.trim()) {
|
|
440
|
+
bodyParts.push(`${locale === 'zh' ? '风险' : 'Risks'}: ${risks.trim()}`);
|
|
441
|
+
}
|
|
442
|
+
if (footer && footer.trim()) {
|
|
443
|
+
bodyParts.push(footer.trim());
|
|
444
|
+
}
|
|
445
|
+
if (bodyParts.length === 0)
|
|
446
|
+
return head;
|
|
447
|
+
return `${head}\n\n${bodyParts.join('\n\n')}`;
|
|
448
|
+
}
|
|
449
|
+
function enforceRulesOnTextMessage(message, rules, locale, branchName, stagedFiles) {
|
|
450
|
+
const lines = message.split('\n');
|
|
451
|
+
const head = lines[0]?.trim() || '';
|
|
452
|
+
const body = lines.slice(1).join('\n').trim() || undefined;
|
|
453
|
+
const match = head.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/);
|
|
454
|
+
const type = normalizeType(match?.[1], rules, branchName);
|
|
455
|
+
let scope = pickScope(match?.[2], rules, branchName, stagedFiles);
|
|
456
|
+
let subject = cleanSubject(match?.[3] || head, rules, locale);
|
|
457
|
+
const issueInfo = resolveIssueId(message, branchName, rules);
|
|
458
|
+
let footer;
|
|
459
|
+
if (issueInfo.issue && !issueInfo.fromMessage) {
|
|
460
|
+
const issue = normalizeIssueId(issueInfo.issue, rules.issuePlacement);
|
|
461
|
+
if (rules.issuePlacement === 'scope') {
|
|
462
|
+
if (!scope || scope === 'core') {
|
|
463
|
+
if (!rules.scopes || rules.scopes.includes(issue)) {
|
|
464
|
+
scope = issue;
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else if (rules.issuePlacement === 'subject') {
|
|
475
|
+
subject = cleanSubject(`${issue} ${subject}`, rules, locale);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return formatCommitMessage(type, scope, subject, locale, body, undefined, footer);
|
|
482
|
+
}
|
|
483
|
+
export function validateCommitMessage(message, config, context) {
|
|
484
|
+
const errors = [];
|
|
485
|
+
const warnings = [];
|
|
486
|
+
const rules = normalizeRules(config);
|
|
487
|
+
const lines = message.split('\n');
|
|
488
|
+
const head = lines[0]?.trim() || '';
|
|
489
|
+
const match = head.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/);
|
|
490
|
+
if (!match) {
|
|
491
|
+
errors.push('Commit message must match "<type>(<scope>): <subject>"');
|
|
492
|
+
return { errors, warnings };
|
|
493
|
+
}
|
|
494
|
+
const type = match[1];
|
|
495
|
+
const scope = match[2];
|
|
496
|
+
const subject = match[3]?.trim() || '';
|
|
497
|
+
if (!rules.types.includes(type)) {
|
|
498
|
+
errors.push(`type "${type}" is not allowed`);
|
|
499
|
+
}
|
|
500
|
+
if (rules.requireScope && (!scope || !scope.trim())) {
|
|
501
|
+
errors.push('scope is required');
|
|
502
|
+
}
|
|
503
|
+
if (scope && rules.scopes && !rules.scopes.includes(scope)) {
|
|
504
|
+
errors.push(`scope "${scope}" is not in allowed scopes`);
|
|
505
|
+
}
|
|
506
|
+
if (!subject) {
|
|
507
|
+
errors.push('subject must not be empty');
|
|
508
|
+
}
|
|
509
|
+
if (subject.length > rules.maxSubjectLength) {
|
|
510
|
+
errors.push(`subject exceeds max length ${rules.maxSubjectLength}`);
|
|
511
|
+
}
|
|
512
|
+
if (rules.requireIssue) {
|
|
513
|
+
const issue = findIssue(message, rules.issuePattern) ||
|
|
514
|
+
findIssue(context?.branchName, rules.issuePattern);
|
|
515
|
+
if (!issue) {
|
|
516
|
+
errors.push('issue id is required but not found');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { errors, warnings };
|
|
520
|
+
}
|
|
521
|
+
function parseCommitJson(normalized) {
|
|
522
|
+
try {
|
|
523
|
+
const parsed = JSON.parse(normalized);
|
|
524
|
+
if (Array.isArray(parsed)) {
|
|
525
|
+
return parsed
|
|
526
|
+
.map((item) => (typeof item === 'object' && item ? item : null))
|
|
527
|
+
.filter(Boolean);
|
|
528
|
+
}
|
|
529
|
+
if (parsed && typeof parsed === 'object') {
|
|
530
|
+
return [parsed];
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// ignore
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
function formatFromCommitJson(items, rules, locale, branchName, stagedFiles) {
|
|
539
|
+
return items.map((item) => {
|
|
540
|
+
if (item.message && typeof item.message === 'string') {
|
|
541
|
+
return enforceRulesOnTextMessage(item.message, rules, locale, branchName, stagedFiles);
|
|
542
|
+
}
|
|
543
|
+
const type = normalizeType(item.type, rules, branchName);
|
|
544
|
+
let scope = pickScope(item.scope, rules, branchName, stagedFiles);
|
|
545
|
+
let subject = cleanSubject(item.subject, rules, locale);
|
|
546
|
+
const raw = [item.type, item.scope, item.subject, item.body, item.risks]
|
|
547
|
+
.filter((v) => typeof v === 'string' && v.trim())
|
|
548
|
+
.join('\n');
|
|
549
|
+
const issueInfo = resolveIssueId(raw, branchName, rules);
|
|
550
|
+
let footer;
|
|
551
|
+
if (issueInfo.issue && !issueInfo.fromMessage) {
|
|
552
|
+
const issue = normalizeIssueId(issueInfo.issue, rules.issuePlacement);
|
|
553
|
+
if (rules.issuePlacement === 'scope') {
|
|
554
|
+
if (!scope || scope === 'core') {
|
|
555
|
+
if (!rules.scopes || rules.scopes.includes(issue)) {
|
|
556
|
+
scope = issue;
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
else if (rules.issuePlacement === 'subject') {
|
|
567
|
+
subject = cleanSubject(`${issue} ${subject}`, rules, locale);
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return formatCommitMessage(type, scope, subject, locale, item.body, item.risks, footer);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
function normalizeAIError(error) {
|
|
577
|
+
if (error instanceof Error) {
|
|
578
|
+
const safe = redactSecrets(error.message || '');
|
|
579
|
+
const e = new Error(safe);
|
|
580
|
+
e.cause = error;
|
|
581
|
+
return e;
|
|
582
|
+
}
|
|
583
|
+
return new Error(redactSecrets(String(error)));
|
|
584
|
+
}
|
|
585
|
+
function shouldFallbackFromAgent(error) {
|
|
586
|
+
const err = error;
|
|
587
|
+
const status = typeof err?.status === 'number' ? err.status : undefined;
|
|
588
|
+
const type = typeof err?.type === 'string' ? err.type : '';
|
|
589
|
+
// If auth/endpoint is wrong, basic mode will fail too: don't spam the user with a second failure.
|
|
590
|
+
if (status === 401 || status === 403 || type === 'authentication_error')
|
|
591
|
+
return false;
|
|
592
|
+
if (status === 404)
|
|
593
|
+
return false;
|
|
594
|
+
// Rate limits / transient errors: agent uses more calls; basic mode may succeed.
|
|
595
|
+
if (status === 429)
|
|
596
|
+
return true;
|
|
597
|
+
if (status && status >= 500)
|
|
598
|
+
return true;
|
|
599
|
+
const msg = (typeof err?.error?.message === 'string' && err.error.message) ||
|
|
600
|
+
(typeof err?.message === 'string' && err.message) ||
|
|
601
|
+
'';
|
|
602
|
+
const lowered = String(msg).toLowerCase();
|
|
603
|
+
// Tool calling compatibility issues: fall back to basic mode.
|
|
604
|
+
if (lowered.includes('tool') || lowered.includes('tool_choice') || lowered.includes('function'))
|
|
605
|
+
return true;
|
|
606
|
+
// Default: keep previous behavior (fallback), unless it's clearly an auth/endpoint issue.
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
function shouldFallbackModel(error) {
|
|
610
|
+
const err = error;
|
|
611
|
+
const status = typeof err?.status === 'number' ? err.status : undefined;
|
|
612
|
+
const type = typeof err?.type === 'string' ? err.type : '';
|
|
613
|
+
if (status === 401 || status === 403 || type === 'authentication_error')
|
|
614
|
+
return false;
|
|
615
|
+
if (status === 404)
|
|
616
|
+
return false;
|
|
617
|
+
if (status === 429)
|
|
618
|
+
return true;
|
|
619
|
+
if (status && status >= 500)
|
|
620
|
+
return true;
|
|
621
|
+
const msg = (typeof err?.error?.message === 'string' && err.error.message) ||
|
|
622
|
+
(typeof err?.message === 'string' && err.message) ||
|
|
623
|
+
'';
|
|
624
|
+
const lowered = String(msg).toLowerCase();
|
|
625
|
+
if (lowered.includes('timeout') || lowered.includes('timed out'))
|
|
626
|
+
return true;
|
|
627
|
+
if (lowered.includes('rate limit'))
|
|
628
|
+
return true;
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
41
631
|
const DEFAULT_SYSTEM_PROMPT_EN = `You are an expert at writing Git commit messages following the Conventional Commits specification.
|
|
42
632
|
|
|
43
633
|
Based on the git diff provided, generate a concise and descriptive commit message.
|
|
@@ -112,6 +702,8 @@ export async function generateCommitMessage(client, input, config, numChoices =
|
|
|
112
702
|
let diff = input.diff;
|
|
113
703
|
let ignoredFiles = input.ignoredFiles;
|
|
114
704
|
let truncated = input.truncated;
|
|
705
|
+
const rules = normalizeRules(config);
|
|
706
|
+
const outputFormat = config.outputFormat || 'text';
|
|
115
707
|
const ensureDiff = async () => {
|
|
116
708
|
if (diff !== undefined)
|
|
117
709
|
return;
|
|
@@ -127,23 +719,42 @@ export async function generateCommitMessage(client, input, config, numChoices =
|
|
|
127
719
|
// Auto-enable Agent for critical branches in Git Flow
|
|
128
720
|
// Critical: release/hotfix/master/main - always use Agent
|
|
129
721
|
// Feature: feature/*/bugfix/*/dev/* - use Agent for impact analysis
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const
|
|
722
|
+
const branch = input.branchName || '';
|
|
723
|
+
const isCriticalBranch = /^(release|hotfix)\//.test(branch) || /^(master|main)$/.test(branch);
|
|
724
|
+
const isFeatureBranch = /^(feature|bugfix|dev)\//.test(branch);
|
|
725
|
+
const autoAgentEnabled = isAutoAgentEnabled();
|
|
726
|
+
const agentDisabled = isAgentDisabled();
|
|
727
|
+
const shouldRunAgent = !agentDisabled &&
|
|
728
|
+
(input.forceAgent || (autoAgentEnabled && (input.truncated || isCriticalBranch || isFeatureBranch))) &&
|
|
729
|
+
numChoices === 1;
|
|
133
730
|
// Trigger Agent Mode if diff is truncated OR forced by user OR critical branch
|
|
134
731
|
if (shouldRunAgent) {
|
|
135
732
|
try {
|
|
136
733
|
const stats = await getFileStats();
|
|
137
734
|
if (stats.length > 0) {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
735
|
+
const agentStrategy = resolveAgentStrategy(config);
|
|
736
|
+
const agentModel = resolveAgentModel(config, agentStrategy);
|
|
737
|
+
if (!input.quiet) {
|
|
738
|
+
const label = agentStrategy === 'tools' ? 'tools' : 'lite';
|
|
739
|
+
if (agentModel !== config.model) {
|
|
740
|
+
console.log(chalk.gray(`\n🧠 Agent (${label}) model: ${agentModel} (base model: ${config.model})`));
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
console.log(chalk.gray(`\n🧠 Agent strategy: ${label}`));
|
|
744
|
+
}
|
|
141
745
|
}
|
|
142
|
-
const agentMessage =
|
|
143
|
-
|
|
746
|
+
const agentMessage = agentStrategy === 'tools'
|
|
747
|
+
? await runAgentLoop(client, config, stats, input.branchName, input.quiet, agentModel)
|
|
748
|
+
: await runAgentLite(client, config, stats, input.branchName, input.quiet, agentModel);
|
|
749
|
+
const enforced = enforceRulesOnTextMessage(agentMessage, rules, config.locale, input.branchName, input.stagedFiles);
|
|
750
|
+
const out = config.enableFooter ? `${enforced}\n\n🤖 Generated by git-ai 🚀` : enforced;
|
|
751
|
+
return [out];
|
|
144
752
|
}
|
|
145
753
|
}
|
|
146
754
|
catch (error) {
|
|
755
|
+
if (!shouldFallbackFromAgent(error)) {
|
|
756
|
+
throw normalizeAIError(error);
|
|
757
|
+
}
|
|
147
758
|
if (!input.quiet) {
|
|
148
759
|
const reason = formatAgentFailureReason(error);
|
|
149
760
|
const suffix = reason ? ` (${reason})` : '';
|
|
@@ -167,13 +778,38 @@ export async function generateCommitMessage(client, input, config, numChoices =
|
|
|
167
778
|
}
|
|
168
779
|
const isZh = config.locale === 'zh';
|
|
169
780
|
const lines = [];
|
|
781
|
+
const rulesHeader = isZh ? 'Commit 规则:' : 'Commit rules:';
|
|
782
|
+
const rulesLines = [
|
|
783
|
+
`${rulesHeader}`,
|
|
784
|
+
`types: ${rules.types.join(', ')}`,
|
|
785
|
+
`maxSubjectLength: ${rules.maxSubjectLength}`,
|
|
786
|
+
`requireScope: ${rules.requireScope ? 'true' : 'false'}`,
|
|
787
|
+
`issuePlacement: ${rules.issuePlacement}`,
|
|
788
|
+
`requireIssue: ${rules.requireIssue ? 'true' : 'false'}`,
|
|
789
|
+
`issuePattern: ${rules.issuePattern.source}`,
|
|
790
|
+
`issueFooterPrefix: ${rules.issueFooterPrefix}`,
|
|
791
|
+
];
|
|
792
|
+
if (rules.scopes && rules.scopes.length) {
|
|
793
|
+
rulesLines.push(`scopes: ${rules.scopes.join(', ')}`);
|
|
794
|
+
}
|
|
795
|
+
lines.push(rulesLines.join('\n'));
|
|
170
796
|
if (numChoices > 1) {
|
|
171
|
-
// Add instruction for multiple choices
|
|
172
|
-
const multiInstruction =
|
|
173
|
-
?
|
|
174
|
-
|
|
797
|
+
// Add instruction for multiple choices (JSON array for robustness)
|
|
798
|
+
const multiInstruction = outputFormat === 'json'
|
|
799
|
+
? isZh
|
|
800
|
+
? `\n请仅输出 JSON 数组,包含 ${numChoices} 个对象,每个对象字段为 type, scope, subject, body?, risks?;不要输出其他内容。`
|
|
801
|
+
: `\nRespond with a JSON array of ${numChoices} objects with fields type, scope, subject, body?, risks?. Output JSON only.`
|
|
802
|
+
: isZh
|
|
803
|
+
? `\n请仅输出 JSON 数组,包含 ${numChoices} 条不同的 commit message(字符串数组),不要输出其他内容。`
|
|
804
|
+
: `\nRespond with a JSON array of ${numChoices} distinct commit message strings. Output JSON only.`;
|
|
175
805
|
systemPrompt += multiInstruction;
|
|
176
806
|
}
|
|
807
|
+
else if (outputFormat === 'json') {
|
|
808
|
+
const singleInstruction = isZh
|
|
809
|
+
? `\n请仅输出一个 JSON 对象,字段为 type, scope, subject, body?, risks?;不要输出其他内容。`
|
|
810
|
+
: `\nRespond with a single JSON object with fields type, scope, subject, body?, risks?. Output JSON only.`;
|
|
811
|
+
systemPrompt += singleInstruction;
|
|
812
|
+
}
|
|
177
813
|
if (input.recentCommits?.length) {
|
|
178
814
|
const header = isZh
|
|
179
815
|
? '参考历史提交风格 (请模仿以下风格):'
|
|
@@ -212,23 +848,76 @@ export async function generateCommitMessage(client, input, config, numChoices =
|
|
|
212
848
|
}
|
|
213
849
|
const diffHeader = isZh ? 'Git Diff:' : 'Git diff:';
|
|
214
850
|
lines.push(`${diffHeader}\n\n${diff || '(empty)'}`);
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
851
|
+
const modelCandidates = getModelCandidates(config);
|
|
852
|
+
let response = null;
|
|
853
|
+
let lastError = null;
|
|
854
|
+
for (const model of modelCandidates) {
|
|
855
|
+
try {
|
|
856
|
+
response = await client.chat.completions.create({
|
|
857
|
+
model,
|
|
858
|
+
messages: [
|
|
859
|
+
{ role: 'system', content: systemPrompt },
|
|
860
|
+
{ role: 'user', content: lines.join('\n\n') },
|
|
861
|
+
],
|
|
862
|
+
temperature: 0.7,
|
|
863
|
+
max_tokens: getMaxOutputTokens(numChoices),
|
|
864
|
+
});
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
lastError = error;
|
|
869
|
+
if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
|
|
870
|
+
throw normalizeAIError(error);
|
|
871
|
+
}
|
|
872
|
+
if (process.env.GIT_AI_DEBUG === '1') {
|
|
873
|
+
console.error(chalk.yellow(`⚠️ Model ${model} failed, trying fallback...`));
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (!response) {
|
|
878
|
+
throw normalizeAIError(lastError);
|
|
879
|
+
}
|
|
224
880
|
const content = response.choices[0]?.message?.content?.trim();
|
|
225
881
|
if (!content) {
|
|
226
882
|
throw new Error('Failed to generate commit message: empty response');
|
|
227
883
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
884
|
+
const normalized = stripCodeFences(content);
|
|
885
|
+
let messages = [];
|
|
886
|
+
if (outputFormat === 'json') {
|
|
887
|
+
const parsed = parseCommitJson(normalized);
|
|
888
|
+
if (parsed && parsed.length) {
|
|
889
|
+
messages = formatFromCommitJson(parsed, rules, config.locale, input.branchName, input.stagedFiles);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
const fallback = tryParseMessagesJson(normalized);
|
|
893
|
+
const rawMessages = fallback && fallback.length ? fallback : [normalized];
|
|
894
|
+
messages = rawMessages.map((msg) => enforceRulesOnTextMessage(msg, rules, config.locale, input.branchName, input.stagedFiles));
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
if (numChoices > 1) {
|
|
899
|
+
const parsed = tryParseMessagesJson(normalized);
|
|
900
|
+
if (parsed && parsed.length) {
|
|
901
|
+
messages = parsed;
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
messages = normalized
|
|
905
|
+
.split('---')
|
|
906
|
+
.map((msg) => msg.trim())
|
|
907
|
+
.filter(Boolean);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
const parsed = tryParseMessagesJson(normalized);
|
|
912
|
+
if (parsed && parsed.length === 1) {
|
|
913
|
+
messages = parsed;
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
messages = [normalized];
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
messages = messages.map((msg) => enforceRulesOnTextMessage(msg, rules, config.locale, input.branchName, input.stagedFiles));
|
|
920
|
+
}
|
|
232
921
|
if (config.enableFooter) {
|
|
233
922
|
return messages.map((msg) => `${msg}\n\n🤖 Generated by git-ai 🚀`);
|
|
234
923
|
}
|
|
@@ -269,20 +958,146 @@ Rules:
|
|
|
269
958
|
4. **Filter Noise**: Ignore trivial or "wip" commits.
|
|
270
959
|
|
|
271
960
|
Output structured markdown text only.`;
|
|
961
|
+
const PR_PROMPT_EN = `You are a senior engineer writing a pull request description.
|
|
962
|
+
|
|
963
|
+
Based on the commit list and diff summary, produce a concise PR description.
|
|
964
|
+
|
|
965
|
+
Rules:
|
|
966
|
+
1) Use Markdown
|
|
967
|
+
2) Include sections: Summary, Changes, Testing, Risks
|
|
968
|
+
3) Be concise and value-focused
|
|
969
|
+
4) If testing info is unknown, write "Not run"
|
|
970
|
+
|
|
971
|
+
Output Markdown only.`;
|
|
972
|
+
const PR_PROMPT_ZH = `你是资深工程师,负责撰写 PR 描述。
|
|
973
|
+
|
|
974
|
+
根据提交记录和 diff 概要,生成简洁的 PR 描述。
|
|
975
|
+
|
|
976
|
+
规则:
|
|
977
|
+
1) 使用 Markdown
|
|
978
|
+
2) 包含板块:Summary, Changes, Testing, Risks
|
|
979
|
+
3) 聚焦价值与影响,避免啰嗦
|
|
980
|
+
4) 如未知测试情况,写 "Not run"
|
|
981
|
+
|
|
982
|
+
仅输出 Markdown。`;
|
|
983
|
+
const RELEASE_PROMPT_EN = `You are a release manager writing release notes.
|
|
984
|
+
|
|
985
|
+
Based on the commit list, generate structured release notes.
|
|
986
|
+
|
|
987
|
+
Rules:
|
|
988
|
+
1) Use Markdown
|
|
989
|
+
2) Group changes by type (Features, Fixes, Improvements, Docs/Chore)
|
|
990
|
+
3) Be concise and user-facing when possible
|
|
991
|
+
4) Exclude trivial/wip commits
|
|
992
|
+
|
|
993
|
+
Output Markdown only.`;
|
|
994
|
+
const RELEASE_PROMPT_ZH = `你是发布负责人,负责撰写 Release Notes。
|
|
995
|
+
|
|
996
|
+
根据提交记录生成结构化的发布说明。
|
|
997
|
+
|
|
998
|
+
规则:
|
|
999
|
+
1) 使用 Markdown
|
|
1000
|
+
2) 按类型分组(Features / Fixes / Improvements / Docs/Chore)
|
|
1001
|
+
3) 尽量面向用户价值表达
|
|
1002
|
+
4) 过滤无意义提交
|
|
1003
|
+
|
|
1004
|
+
仅输出 Markdown。`;
|
|
1005
|
+
export async function generatePullRequestDescription(client, input, config) {
|
|
1006
|
+
const isZh = config.locale === 'zh';
|
|
1007
|
+
const systemPrompt = isZh ? PR_PROMPT_ZH : PR_PROMPT_EN;
|
|
1008
|
+
const diffBlock = input.diffStat ? `\nDiff Summary:\n${input.diffStat}` : '';
|
|
1009
|
+
const modelCandidates = getModelCandidates(config);
|
|
1010
|
+
let result = null;
|
|
1011
|
+
let lastError = null;
|
|
1012
|
+
for (const model of modelCandidates) {
|
|
1013
|
+
try {
|
|
1014
|
+
result = await client.chat.completions.create({
|
|
1015
|
+
model,
|
|
1016
|
+
messages: [
|
|
1017
|
+
{ role: 'system', content: systemPrompt },
|
|
1018
|
+
{
|
|
1019
|
+
role: 'user',
|
|
1020
|
+
content: `Base: ${input.base}\nHead: ${input.head}\nCommits:\n${input.commits.join('\n')}${diffBlock}`,
|
|
1021
|
+
},
|
|
1022
|
+
],
|
|
1023
|
+
temperature: 0.4,
|
|
1024
|
+
});
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
lastError = error;
|
|
1029
|
+
if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
|
|
1030
|
+
throw normalizeAIError(error);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (!result)
|
|
1035
|
+
throw normalizeAIError(lastError);
|
|
1036
|
+
return result.choices[0]?.message?.content?.trim() || '';
|
|
1037
|
+
}
|
|
1038
|
+
export async function generateReleaseNotes(client, input, config) {
|
|
1039
|
+
const isZh = config.locale === 'zh';
|
|
1040
|
+
const systemPrompt = isZh ? RELEASE_PROMPT_ZH : RELEASE_PROMPT_EN;
|
|
1041
|
+
const modelCandidates = getModelCandidates(config);
|
|
1042
|
+
let result = null;
|
|
1043
|
+
let lastError = null;
|
|
1044
|
+
for (const model of modelCandidates) {
|
|
1045
|
+
try {
|
|
1046
|
+
result = await client.chat.completions.create({
|
|
1047
|
+
model,
|
|
1048
|
+
messages: [
|
|
1049
|
+
{ role: 'system', content: systemPrompt },
|
|
1050
|
+
{
|
|
1051
|
+
role: 'user',
|
|
1052
|
+
content: `Range: ${input.from} → ${input.to}\nCommits:\n${input.commits.join('\n')}`,
|
|
1053
|
+
},
|
|
1054
|
+
],
|
|
1055
|
+
temperature: 0.4,
|
|
1056
|
+
});
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
catch (error) {
|
|
1060
|
+
lastError = error;
|
|
1061
|
+
if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
|
|
1062
|
+
throw normalizeAIError(error);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (!result)
|
|
1067
|
+
throw normalizeAIError(lastError);
|
|
1068
|
+
return result.choices[0]?.message?.content?.trim() || '';
|
|
1069
|
+
}
|
|
272
1070
|
export async function generateWeeklyReport(client, commits, config) {
|
|
273
1071
|
const isZh = config.locale === 'zh';
|
|
274
1072
|
const systemPrompt = isZh ? REPORT_PROMPT_ZH : REPORT_PROMPT_EN;
|
|
275
1073
|
if (commits.length === 0) {
|
|
276
1074
|
return isZh ? '这段时间没有找到您的提交记录。' : 'No commits found for this period.';
|
|
277
1075
|
}
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
1076
|
+
const modelCandidates = getModelCandidates(config);
|
|
1077
|
+
let result = null;
|
|
1078
|
+
let lastError = null;
|
|
1079
|
+
for (const model of modelCandidates) {
|
|
1080
|
+
try {
|
|
1081
|
+
result = await client.chat.completions.create({
|
|
1082
|
+
model,
|
|
1083
|
+
messages: [
|
|
1084
|
+
{ role: 'system', content: systemPrompt },
|
|
1085
|
+
{ role: 'user', content: `Commit History:\n${commits.join('\n')}` },
|
|
1086
|
+
],
|
|
1087
|
+
temperature: 0.7,
|
|
1088
|
+
});
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
catch (error) {
|
|
1092
|
+
lastError = error;
|
|
1093
|
+
if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
|
|
1094
|
+
throw normalizeAIError(error);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (!result) {
|
|
1099
|
+
throw normalizeAIError(lastError);
|
|
1100
|
+
}
|
|
1101
|
+
return result.choices[0]?.message?.content?.trim() || '';
|
|
287
1102
|
}
|
|
288
1103
|
//# sourceMappingURL=ai.js.map
|