@dongowu/git-ai-cli 1.0.20 → 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.
Files changed (47) hide show
  1. package/README.md +158 -153
  2. package/README_EN.md +165 -141
  3. package/dist/cli_main.js +66 -0
  4. package/dist/cli_main.js.map +1 -1
  5. package/dist/commands/branch.d.ts +11 -0
  6. package/dist/commands/branch.d.ts.map +1 -0
  7. package/dist/commands/branch.js +279 -0
  8. package/dist/commands/branch.js.map +1 -0
  9. package/dist/commands/commit.d.ts.map +1 -1
  10. package/dist/commands/commit.js +26 -1
  11. package/dist/commands/commit.js.map +1 -1
  12. package/dist/commands/config_manage.d.ts.map +1 -1
  13. package/dist/commands/config_manage.js +212 -3
  14. package/dist/commands/config_manage.js.map +1 -1
  15. package/dist/commands/hook.d.ts.map +1 -1
  16. package/dist/commands/hook.js +76 -10
  17. package/dist/commands/hook.js.map +1 -1
  18. package/dist/commands/msg.d.ts.map +1 -1
  19. package/dist/commands/msg.js +12 -1
  20. package/dist/commands/msg.js.map +1 -1
  21. package/dist/commands/pr.d.ts +8 -0
  22. package/dist/commands/pr.d.ts.map +1 -0
  23. package/dist/commands/pr.js +96 -0
  24. package/dist/commands/pr.js.map +1 -0
  25. package/dist/commands/release.d.ts +8 -0
  26. package/dist/commands/release.d.ts.map +1 -0
  27. package/dist/commands/release.js +95 -0
  28. package/dist/commands/release.js.map +1 -0
  29. package/dist/commands/report.d.ts.map +1 -1
  30. package/dist/commands/report.js +9 -1
  31. package/dist/commands/report.js.map +1 -1
  32. package/dist/types.d.ts +26 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/utils/ai.d.ts +22 -0
  36. package/dist/utils/ai.d.ts.map +1 -1
  37. package/dist/utils/ai.js +699 -33
  38. package/dist/utils/ai.js.map +1 -1
  39. package/dist/utils/config.d.ts.map +1 -1
  40. package/dist/utils/config.js +93 -0
  41. package/dist/utils/config.js.map +1 -1
  42. package/dist/utils/git.d.ts +8 -0
  43. package/dist/utils/git.d.ts.map +1 -1
  44. package/dist/utils/git.js +88 -0
  45. package/dist/utils/git.js.map +1 -1
  46. package/package.json +1 -1
  47. package/release_notes.md +3 -4
package/dist/utils/ai.js CHANGED
@@ -26,6 +26,13 @@ function getMaxOutputTokens(numChoices) {
26
26
  const base = Number.isFinite(parsed) && parsed > 0 ? parsed : 500;
27
27
  return base * Math.max(numChoices, 1);
28
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
+ }
29
36
  function redactSecrets(input) {
30
37
  if (!input)
31
38
  return input;
@@ -118,6 +125,454 @@ function tryParseMessagesJson(content) {
118
125
  }
119
126
  return null;
120
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
+ }
121
576
  function normalizeAIError(error) {
122
577
  if (error instanceof Error) {
123
578
  const safe = redactSecrets(error.message || '');
@@ -151,6 +606,28 @@ function shouldFallbackFromAgent(error) {
151
606
  // Default: keep previous behavior (fallback), unless it's clearly an auth/endpoint issue.
152
607
  return true;
153
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
+ }
154
631
  const DEFAULT_SYSTEM_PROMPT_EN = `You are an expert at writing Git commit messages following the Conventional Commits specification.
155
632
 
156
633
  Based on the git diff provided, generate a concise and descriptive commit message.
@@ -225,6 +702,8 @@ export async function generateCommitMessage(client, input, config, numChoices =
225
702
  let diff = input.diff;
226
703
  let ignoredFiles = input.ignoredFiles;
227
704
  let truncated = input.truncated;
705
+ const rules = normalizeRules(config);
706
+ const outputFormat = config.outputFormat || 'text';
228
707
  const ensureDiff = async () => {
229
708
  if (diff !== undefined)
230
709
  return;
@@ -267,7 +746,9 @@ export async function generateCommitMessage(client, input, config, numChoices =
267
746
  const agentMessage = agentStrategy === 'tools'
268
747
  ? await runAgentLoop(client, config, stats, input.branchName, input.quiet, agentModel)
269
748
  : await runAgentLite(client, config, stats, input.branchName, input.quiet, agentModel);
270
- return [agentMessage];
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];
271
752
  }
272
753
  }
273
754
  catch (error) {
@@ -297,13 +778,38 @@ export async function generateCommitMessage(client, input, config, numChoices =
297
778
  }
298
779
  const isZh = config.locale === 'zh';
299
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'));
300
796
  if (numChoices > 1) {
301
797
  // Add instruction for multiple choices (JSON array for robustness)
302
- const multiInstruction = isZh
303
- ? `\n请仅输出 JSON 数组,包含 ${numChoices} 条不同的 commit message(字符串数组),不要输出其他内容。`
304
- : `\nRespond with a JSON array of ${numChoices} distinct commit message strings. Output JSON only.`;
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.`;
305
805
  systemPrompt += multiInstruction;
306
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
+ }
307
813
  if (input.recentCommits?.length) {
308
814
  const header = isZh
309
815
  ? '参考历史提交风格 (请模仿以下风格):'
@@ -342,41 +848,75 @@ export async function generateCommitMessage(client, input, config, numChoices =
342
848
  }
343
849
  const diffHeader = isZh ? 'Git Diff:' : 'Git diff:';
344
850
  lines.push(`${diffHeader}\n\n${diff || '(empty)'}`);
345
- const response = await client.chat.completions.create({
346
- model: config.model,
347
- messages: [
348
- { role: 'system', content: systemPrompt },
349
- { role: 'user', content: lines.join('\n\n') },
350
- ],
351
- temperature: 0.7,
352
- max_tokens: getMaxOutputTokens(numChoices),
353
- });
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
+ }
354
880
  const content = response.choices[0]?.message?.content?.trim();
355
881
  if (!content) {
356
882
  throw new Error('Failed to generate commit message: empty response');
357
883
  }
358
884
  const normalized = stripCodeFences(content);
359
885
  let messages = [];
360
- if (numChoices > 1) {
361
- const parsed = tryParseMessagesJson(normalized);
886
+ if (outputFormat === 'json') {
887
+ const parsed = parseCommitJson(normalized);
362
888
  if (parsed && parsed.length) {
363
- messages = parsed;
889
+ messages = formatFromCommitJson(parsed, rules, config.locale, input.branchName, input.stagedFiles);
364
890
  }
365
891
  else {
366
- messages = normalized
367
- .split('---')
368
- .map((msg) => msg.trim())
369
- .filter(Boolean);
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));
370
895
  }
371
896
  }
372
897
  else {
373
- const parsed = tryParseMessagesJson(normalized);
374
- if (parsed && parsed.length === 1) {
375
- messages = parsed;
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
+ }
376
909
  }
377
910
  else {
378
- messages = [normalized];
911
+ const parsed = tryParseMessagesJson(normalized);
912
+ if (parsed && parsed.length === 1) {
913
+ messages = parsed;
914
+ }
915
+ else {
916
+ messages = [normalized];
917
+ }
379
918
  }
919
+ messages = messages.map((msg) => enforceRulesOnTextMessage(msg, rules, config.locale, input.branchName, input.stagedFiles));
380
920
  }
381
921
  if (config.enableFooter) {
382
922
  return messages.map((msg) => `${msg}\n\n🤖 Generated by git-ai 🚀`);
@@ -418,20 +958,146 @@ Rules:
418
958
  4. **Filter Noise**: Ignore trivial or "wip" commits.
419
959
 
420
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
+ }
421
1070
  export async function generateWeeklyReport(client, commits, config) {
422
1071
  const isZh = config.locale === 'zh';
423
1072
  const systemPrompt = isZh ? REPORT_PROMPT_ZH : REPORT_PROMPT_EN;
424
1073
  if (commits.length === 0) {
425
1074
  return isZh ? '这段时间没有找到您的提交记录。' : 'No commits found for this period.';
426
1075
  }
427
- const response = await client.chat.completions.create({
428
- model: config.model,
429
- messages: [
430
- { role: 'system', content: systemPrompt },
431
- { role: 'user', content: `Commit History:\n${commits.join('\n')}` },
432
- ],
433
- temperature: 0.7,
434
- });
435
- return response.choices[0]?.message?.content?.trim() || '';
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() || '';
436
1102
  }
437
1103
  //# sourceMappingURL=ai.js.map