@dongowu/git-ai-cli 1.0.21 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -157
- package/bin/git-ai.cjs +57 -0
- package/install.cjs +50 -0
- package/package.json +27 -28
- package/.claude/settings.local.json +0 -17
- package/CHANGELOG.md +0 -46
- package/README_EN.md +0 -264
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -17
- package/dist/cli.js.map +0 -1
- package/dist/cli_main.d.ts +0 -2
- package/dist/cli_main.d.ts.map +0 -1
- package/dist/cli_main.js +0 -255
- package/dist/cli_main.js.map +0 -1
- package/dist/commands/branch.d.ts +0 -11
- package/dist/commands/branch.d.ts.map +0 -1
- package/dist/commands/branch.js +0 -279
- package/dist/commands/branch.js.map +0 -1
- package/dist/commands/commit.d.ts +0 -9
- package/dist/commands/commit.d.ts.map +0 -1
- package/dist/commands/commit.js +0 -326
- package/dist/commands/commit.js.map +0 -1
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/config.d.ts.map +0 -1
- package/dist/commands/config.js +0 -164
- package/dist/commands/config.js.map +0 -1
- package/dist/commands/config_manage.d.ts +0 -14
- package/dist/commands/config_manage.d.ts.map +0 -1
- package/dist/commands/config_manage.js +0 -394
- package/dist/commands/config_manage.js.map +0 -1
- package/dist/commands/hook.d.ts +0 -5
- package/dist/commands/hook.d.ts.map +0 -1
- package/dist/commands/hook.js +0 -528
- package/dist/commands/hook.js.map +0 -1
- package/dist/commands/msg.d.ts +0 -20
- package/dist/commands/msg.d.ts.map +0 -1
- package/dist/commands/msg.js +0 -148
- package/dist/commands/msg.js.map +0 -1
- package/dist/commands/pr.d.ts +0 -8
- package/dist/commands/pr.d.ts.map +0 -1
- package/dist/commands/pr.js +0 -96
- package/dist/commands/pr.js.map +0 -1
- package/dist/commands/release.d.ts +0 -8
- package/dist/commands/release.d.ts.map +0 -1
- package/dist/commands/release.js +0 -95
- package/dist/commands/release.js.map +0 -1
- package/dist/commands/report.d.ts +0 -9
- package/dist/commands/report.d.ts.map +0 -1
- package/dist/commands/report.js +0 -162
- package/dist/commands/report.js.map +0 -1
- package/dist/types.d.ts +0 -46
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -86
- package/dist/types.js.map +0 -1
- package/dist/utils/agent.d.ts +0 -5
- package/dist/utils/agent.d.ts.map +0 -1
- package/dist/utils/agent.js +0 -308
- package/dist/utils/agent.js.map +0 -1
- package/dist/utils/agent_lite.d.ts +0 -5
- package/dist/utils/agent_lite.d.ts.map +0 -1
- package/dist/utils/agent_lite.js +0 -263
- package/dist/utils/agent_lite.js.map +0 -1
- package/dist/utils/ai.d.ts +0 -43
- package/dist/utils/ai.d.ts.map +0 -1
- package/dist/utils/ai.js +0 -1103
- package/dist/utils/ai.js.map +0 -1
- package/dist/utils/config.d.ts +0 -11
- package/dist/utils/config.d.ts.map +0 -1
- package/dist/utils/config.js +0 -239
- package/dist/utils/config.js.map +0 -1
- package/dist/utils/git.d.ts +0 -42
- package/dist/utils/git.d.ts.map +0 -1
- package/dist/utils/git.js +0 -456
- package/dist/utils/git.js.map +0 -1
- package/dist/utils/update.d.ts +0 -4
- package/dist/utils/update.d.ts.map +0 -1
- package/dist/utils/update.js +0 -122
- package/dist/utils/update.js.map +0 -1
- package/release_notes.md +0 -9
- package/scripts/release.sh +0 -34
- package/test_agent_feature.ts +0 -1
package/dist/utils/ai.js
DELETED
|
@@ -1,1103 +0,0 @@
|
|
|
1
|
-
import OpenAI from 'openai';
|
|
2
|
-
import { runAgentLoop } from './agent.js';
|
|
3
|
-
import { runAgentLite } from './agent_lite.js';
|
|
4
|
-
import { getFileStats } from './git.js';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
|
-
function getTimeoutMs() {
|
|
7
|
-
const raw = process.env.GIT_AI_TIMEOUT_MS;
|
|
8
|
-
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
9
|
-
if (Number.isFinite(parsed) && parsed > 0)
|
|
10
|
-
return parsed;
|
|
11
|
-
return 120_000; // 2 minutes
|
|
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
|
-
}
|
|
52
|
-
function formatAgentFailureReason(error) {
|
|
53
|
-
const err = error;
|
|
54
|
-
const status = typeof err?.status === 'number' ? String(err.status) : '';
|
|
55
|
-
const name = typeof err?.name === 'string' ? err.name : '';
|
|
56
|
-
const code = typeof err?.code === 'string' ? err.code : '';
|
|
57
|
-
const type = typeof err?.type === 'string' ? err.type : '';
|
|
58
|
-
const rawMsg = (typeof err?.error?.message === 'string' && err.error.message) ||
|
|
59
|
-
(typeof err?.message === 'string' && err.message) ||
|
|
60
|
-
'';
|
|
61
|
-
const compactMsg = redactSecrets(rawMsg).replace(/\s+/g, ' ').trim();
|
|
62
|
-
const shortMsg = compactMsg.length > 180 ? compactMsg.slice(0, 180) + '...' : compactMsg;
|
|
63
|
-
const parts = [status || name, code, type, shortMsg].filter(Boolean);
|
|
64
|
-
return parts.join(' ');
|
|
65
|
-
}
|
|
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) {
|
|
77
|
-
const envModel = process.env.GIT_AI_AGENT_MODEL;
|
|
78
|
-
if (envModel && envModel.trim())
|
|
79
|
-
return envModel.trim();
|
|
80
|
-
const configured = config.agentModel;
|
|
81
|
-
if (configured && configured.trim())
|
|
82
|
-
return configured.trim();
|
|
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') {
|
|
85
|
-
const base = (config.model || '').trim();
|
|
86
|
-
if (base.toLowerCase().includes('reasoner')) {
|
|
87
|
-
return 'deepseek-chat';
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return config.model;
|
|
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
|
-
}
|
|
631
|
-
const DEFAULT_SYSTEM_PROMPT_EN = `You are an expert at writing Git commit messages following the Conventional Commits specification.
|
|
632
|
-
|
|
633
|
-
Based on the git diff provided, generate a concise and descriptive commit message.
|
|
634
|
-
|
|
635
|
-
Rules:
|
|
636
|
-
1. Use the format: <type>(<scope>): <subject>
|
|
637
|
-
2. Types: feat, fix, docs, style, refactor, perf, test, chore, build, ci
|
|
638
|
-
3. Keep the subject line under 50 characters
|
|
639
|
-
4. Use imperative mood ("add" not "added")
|
|
640
|
-
5. Don't end the subject line with a period
|
|
641
|
-
6. If needed, add a blank line followed by a body for more details
|
|
642
|
-
7. Git Flow Branch Mapping (Priority):
|
|
643
|
-
- feature/* -> type: feat
|
|
644
|
-
- bugfix/* -> type: fix
|
|
645
|
-
- hotfix/* -> type: fix
|
|
646
|
-
- release/* -> type: chore
|
|
647
|
-
- docs/* -> type: docs
|
|
648
|
-
- If branch name matches, infer <scope> from it (e.g. feature/login -> feat(login): ...)
|
|
649
|
-
- If branch name doesn't match these patterns, ignore it and infer type/scope strictly from the code changes.
|
|
650
|
-
|
|
651
|
-
Only output the commit message, nothing else.`;
|
|
652
|
-
const DEFAULT_SYSTEM_PROMPT_ZH = `你是一个专业的 Git commit message 编写专家,遵循 Conventional Commits 规范。
|
|
653
|
-
|
|
654
|
-
根据提供的 git diff,生成简洁且描述性的提交信息。
|
|
655
|
-
|
|
656
|
-
规则:
|
|
657
|
-
1. 使用格式: <type>(<scope>): <subject>
|
|
658
|
-
2. type 类型: feat, fix, docs, style, refactor, perf, test, chore, build, ci
|
|
659
|
-
3. subject 保持在 50 字符以内
|
|
660
|
-
4. 使用祈使语气
|
|
661
|
-
5. subject 末尾不要加句号
|
|
662
|
-
6. 如需要,空一行后添加 body 提供更多细节
|
|
663
|
-
7. Git Flow 分支映射规则 (优先级最高):
|
|
664
|
-
- feature/* -> type: feat
|
|
665
|
-
- bugfix/* -> type: fix
|
|
666
|
-
- hotfix/* -> type: fix
|
|
667
|
-
- release/* -> type: chore
|
|
668
|
-
- docs/* -> type: docs
|
|
669
|
-
- 如果分支名匹配,请从中推断 <scope> (例如: feature/login -> feat(login): ...)
|
|
670
|
-
- 如果分支名不符合上述标准前缀,请忽略分支名,仅依据代码变更内容(diff)来决定 type 和 scope。
|
|
671
|
-
|
|
672
|
-
只输出 commit message,不要输出其他内容。`;
|
|
673
|
-
const DEEPSEEK_PROMPT_ZH = `你是一个智能编程助手,专注于生成高质量的 Git 提交信息。
|
|
674
|
-
|
|
675
|
-
请仔细分析下方的 Git Diff,理解代码变更的*意图*(不仅仅是修改了什么)。
|
|
676
|
-
|
|
677
|
-
规则:
|
|
678
|
-
1. 严格遵循 Conventional Commits 规范: <type>(<scope>): <subject>
|
|
679
|
-
2. 类型(type)必须是: feat, fix, docs, style, refactor, perf, test, chore, build, ci
|
|
680
|
-
3. 描述(subject)需简洁有力,50字符以内,使用中文。
|
|
681
|
-
4. 如果变更复杂,请在 subject 后空一行,添加详细的 body 说明。
|
|
682
|
-
5. 专注于*为什么*变更,而不仅仅是*改了什么*。
|
|
683
|
-
6. Git Flow 分支映射规则 (优先级最高):
|
|
684
|
-
- feature/* -> type: feat
|
|
685
|
-
- bugfix/* -> type: fix
|
|
686
|
-
- hotfix/* -> type: fix
|
|
687
|
-
- release/* -> type: chore
|
|
688
|
-
- docs/* -> type: docs
|
|
689
|
-
- 如果分支名匹配,请从中推断 <scope> (例如: feature/login -> feat(login): ...)
|
|
690
|
-
- 如果分支名不符合上述标准前缀,请忽略分支名,仅依据代码变更内容(diff)来决定 type 和 scope。
|
|
691
|
-
|
|
692
|
-
只输出最终的 Commit Message,不包含 Markdown 代码块或其他解释。`;
|
|
693
|
-
export function createAIClient(config) {
|
|
694
|
-
return new OpenAI({
|
|
695
|
-
apiKey: config.apiKey || 'ollama',
|
|
696
|
-
baseURL: config.baseUrl,
|
|
697
|
-
timeout: getTimeoutMs(),
|
|
698
|
-
maxRetries: 2, // Built-in retry support
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
export async function generateCommitMessage(client, input, config, numChoices = 1) {
|
|
702
|
-
let diff = input.diff;
|
|
703
|
-
let ignoredFiles = input.ignoredFiles;
|
|
704
|
-
let truncated = input.truncated;
|
|
705
|
-
const rules = normalizeRules(config);
|
|
706
|
-
const outputFormat = config.outputFormat || 'text';
|
|
707
|
-
const ensureDiff = async () => {
|
|
708
|
-
if (diff !== undefined)
|
|
709
|
-
return;
|
|
710
|
-
if (input.diffLoader) {
|
|
711
|
-
const loaded = await input.diffLoader();
|
|
712
|
-
diff = loaded.diff;
|
|
713
|
-
truncated = loaded.truncated;
|
|
714
|
-
ignoredFiles = loaded.ignoredFiles;
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
diff = '';
|
|
718
|
-
};
|
|
719
|
-
// Auto-enable Agent for critical branches in Git Flow
|
|
720
|
-
// Critical: release/hotfix/master/main - always use Agent
|
|
721
|
-
// Feature: feature/*/bugfix/*/dev/* - use Agent for impact analysis
|
|
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;
|
|
730
|
-
// Trigger Agent Mode if diff is truncated OR forced by user OR critical branch
|
|
731
|
-
if (shouldRunAgent) {
|
|
732
|
-
try {
|
|
733
|
-
const stats = await getFileStats();
|
|
734
|
-
if (stats.length > 0) {
|
|
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
|
-
}
|
|
745
|
-
}
|
|
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];
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
catch (error) {
|
|
755
|
-
if (!shouldFallbackFromAgent(error)) {
|
|
756
|
-
throw normalizeAIError(error);
|
|
757
|
-
}
|
|
758
|
-
if (!input.quiet) {
|
|
759
|
-
const reason = formatAgentFailureReason(error);
|
|
760
|
-
const suffix = reason ? ` (${reason})` : '';
|
|
761
|
-
console.error(chalk.yellow(`\n⚠️ Agent mode failed${suffix}, falling back to basic mode...`));
|
|
762
|
-
if (process.env.GIT_AI_DEBUG === '1') {
|
|
763
|
-
console.error(error);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
await ensureDiff();
|
|
769
|
-
let systemPrompt = config.customPrompt;
|
|
770
|
-
if (!systemPrompt) {
|
|
771
|
-
const isZh = config.locale === 'zh';
|
|
772
|
-
if (config.provider === 'deepseek' || config.provider === 'qwen') {
|
|
773
|
-
systemPrompt = isZh ? DEEPSEEK_PROMPT_ZH : DEFAULT_SYSTEM_PROMPT_EN; // Reuse EN for now or add DeepSeek EN later
|
|
774
|
-
}
|
|
775
|
-
else {
|
|
776
|
-
systemPrompt = isZh ? DEFAULT_SYSTEM_PROMPT_ZH : DEFAULT_SYSTEM_PROMPT_EN;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
const isZh = config.locale === 'zh';
|
|
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'));
|
|
796
|
-
if (numChoices > 1) {
|
|
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.`;
|
|
805
|
-
systemPrompt += multiInstruction;
|
|
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
|
-
}
|
|
813
|
-
if (input.recentCommits?.length) {
|
|
814
|
-
const header = isZh
|
|
815
|
-
? '参考历史提交风格 (请模仿以下风格):'
|
|
816
|
-
: 'Reference recent commits (please mimic the style):';
|
|
817
|
-
// Extract subject from "hash date subject" format
|
|
818
|
-
// Format is "%h %cd %s", so we take everything after the second space
|
|
819
|
-
const cleanCommits = input.recentCommits
|
|
820
|
-
.map((line) => {
|
|
821
|
-
const parts = line.split(' ');
|
|
822
|
-
if (parts.length >= 3) {
|
|
823
|
-
return parts.slice(2).join(' ');
|
|
824
|
-
}
|
|
825
|
-
return line;
|
|
826
|
-
})
|
|
827
|
-
.slice(0, 10); // Limit to 10 to save tokens
|
|
828
|
-
lines.push(`${header}\n${cleanCommits.map((c) => `- ${c}`).join('\n')}`);
|
|
829
|
-
}
|
|
830
|
-
if (input.branchName) {
|
|
831
|
-
const header = isZh ? '当前分支:' : 'Current branch:';
|
|
832
|
-
lines.push(`${header} ${input.branchName}`);
|
|
833
|
-
}
|
|
834
|
-
if (input.stagedFiles?.length) {
|
|
835
|
-
const header = isZh ? '已暂存文件:' : 'Staged files:';
|
|
836
|
-
lines.push(`${header}\n${input.stagedFiles.map((f) => `- ${f}`).join('\n')}`);
|
|
837
|
-
}
|
|
838
|
-
if (ignoredFiles?.length) {
|
|
839
|
-
const header = isZh
|
|
840
|
-
? '以下文件为节省 Token 已忽略 Diff:'
|
|
841
|
-
: 'Ignored files (diff omitted for token optimization):';
|
|
842
|
-
lines.push(`${header}\n${ignoredFiles.map((f) => `- ${f}`).join('\n')}`);
|
|
843
|
-
}
|
|
844
|
-
if (truncated) {
|
|
845
|
-
lines.push(isZh
|
|
846
|
-
? '注意:Diff 内容已因长度限制被截断。'
|
|
847
|
-
: 'Note: The diff was truncated due to size limits.');
|
|
848
|
-
}
|
|
849
|
-
const diffHeader = isZh ? 'Git Diff:' : 'Git diff:';
|
|
850
|
-
lines.push(`${diffHeader}\n\n${diff || '(empty)'}`);
|
|
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
|
-
}
|
|
880
|
-
const content = response.choices[0]?.message?.content?.trim();
|
|
881
|
-
if (!content) {
|
|
882
|
-
throw new Error('Failed to generate commit message: empty response');
|
|
883
|
-
}
|
|
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
|
-
}
|
|
921
|
-
if (config.enableFooter) {
|
|
922
|
-
return messages.map((msg) => `${msg}\n\n🤖 Generated by git-ai 🚀`);
|
|
923
|
-
}
|
|
924
|
-
return messages;
|
|
925
|
-
}
|
|
926
|
-
const REPORT_PROMPT_ZH = `你是一位资深技术专家,擅长撰写高质量的周报/日报。
|
|
927
|
-
|
|
928
|
-
请根据提供的 Git Commit 记录,整理出一份结构清晰、重点突出的工作汇报。
|
|
929
|
-
|
|
930
|
-
规则:
|
|
931
|
-
1. **分类汇总**:将提交记录归类(例如:✨ 新特性、🐛 问题修复、⚡️ 性能优化、📝 文档与其他)。
|
|
932
|
-
2. **价值导向**:不要只罗列代码变更,尝试用简练的语言描述业务价值或技术成果。
|
|
933
|
-
3. **格式美观**:使用 Markdown 格式,利用列表和 emoji 让阅读体验更佳。
|
|
934
|
-
4. **过滤噪音**:忽略无意义的测试提交或临时提交。
|
|
935
|
-
|
|
936
|
-
输出格式示例:
|
|
937
|
-
## 📅 工作汇报 (Time Range)
|
|
938
|
-
|
|
939
|
-
### ✨ 核心产出
|
|
940
|
-
- **功能 A**: 完成了...逻辑,提升了...体验
|
|
941
|
-
- **功能 B**: ...
|
|
942
|
-
|
|
943
|
-
### 🐛 问题修复
|
|
944
|
-
- 修复了...导致的崩溃问题
|
|
945
|
-
|
|
946
|
-
### 📝 其他
|
|
947
|
-
- ...
|
|
948
|
-
|
|
949
|
-
(结尾可加一句下周计划建议)`;
|
|
950
|
-
const REPORT_PROMPT_EN = `You are a senior technical lead expert at writing professional progress reports.
|
|
951
|
-
|
|
952
|
-
Based on the provided Git Commit logs, generate a structured and high-quality status report.
|
|
953
|
-
|
|
954
|
-
Rules:
|
|
955
|
-
1. **Categorize**: Group commits logically (e.g., ✨ Features, 🐛 Bug Fixes, ⚡️ Improvements, 📝 Other).
|
|
956
|
-
2. **Value-Driven**: Don't just list technical changes; briefly emphasize the value or outcome.
|
|
957
|
-
3. **Formatting**: Use Markdown with bullet points and emojis.
|
|
958
|
-
4. **Filter Noise**: Ignore trivial or "wip" commits.
|
|
959
|
-
|
|
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
|
-
}
|
|
1070
|
-
export async function generateWeeklyReport(client, commits, config) {
|
|
1071
|
-
const isZh = config.locale === 'zh';
|
|
1072
|
-
const systemPrompt = isZh ? REPORT_PROMPT_ZH : REPORT_PROMPT_EN;
|
|
1073
|
-
if (commits.length === 0) {
|
|
1074
|
-
return isZh ? '这段时间没有找到您的提交记录。' : 'No commits found for this period.';
|
|
1075
|
-
}
|
|
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() || '';
|
|
1102
|
-
}
|
|
1103
|
-
//# sourceMappingURL=ai.js.map
|