cli-changescribe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/bin/changescribe.js +64 -0
- package/package.json +32 -0
- package/src/commit.js +714 -0
- package/src/init.js +61 -0
- package/src/pr-summary.js +954 -0
package/src/commit.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { config } = require('dotenv');
|
|
6
|
+
const Groq = require('groq-sdk').default;
|
|
7
|
+
|
|
8
|
+
// Load environment variables from .env.local
|
|
9
|
+
config({ path: '.env.local' });
|
|
10
|
+
|
|
11
|
+
// Regex patterns for cleaning commit messages
|
|
12
|
+
const OPENING_CODE_BLOCK_REGEX = /^```[\s\S]*?\n/;
|
|
13
|
+
const CLOSING_CODE_BLOCK_REGEX = /\n```$/;
|
|
14
|
+
const NEWLINE_SPLIT_RE = /\r?\n/;
|
|
15
|
+
const BULLET_LINE_RE = /^[-*]\s+/;
|
|
16
|
+
const CODE_FILE_EXTENSION_RE = /\.(ts|tsx|js|jsx|css|json|md)$/;
|
|
17
|
+
const TITLE_RE =
|
|
18
|
+
/^(?<type>chore|deprecate|feat|fix|release)(?<breaking>!)?:\s*(?<subject>.+)$/gim;
|
|
19
|
+
const TITLE_VALIDATION_RE = /^(chore|deprecate|feat|fix|release)!?:\s+/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Analyze all code changes comprehensively for AI review
|
|
23
|
+
*/
|
|
24
|
+
function analyzeAllChanges() {
|
|
25
|
+
console.log('🔍 Analyzing all code changes...');
|
|
26
|
+
|
|
27
|
+
// Use larger buffer for git commands that may produce large output
|
|
28
|
+
const LARGE_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB
|
|
29
|
+
|
|
30
|
+
// Check if there are any changes to analyze
|
|
31
|
+
let gitStatus;
|
|
32
|
+
try {
|
|
33
|
+
gitStatus = execSync('git status --porcelain', {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
36
|
+
});
|
|
37
|
+
if (!gitStatus.trim()) {
|
|
38
|
+
return { hasChanges: false };
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`Failed to get git status: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Stage all changes if nothing is staged
|
|
45
|
+
let gitDiff = execSync('git diff --staged --name-status', {
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
48
|
+
});
|
|
49
|
+
if (!gitDiff.trim()) {
|
|
50
|
+
console.log('📝 Staging all changes for analysis...');
|
|
51
|
+
execSync('git add .', {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
54
|
+
});
|
|
55
|
+
gitDiff = execSync('git diff --staged --name-status', {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Get list of modified files first (needed for other operations)
|
|
62
|
+
const modifiedFiles = execSync('git diff --staged --name-only', {
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
65
|
+
})
|
|
66
|
+
.trim()
|
|
67
|
+
.split('\n')
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
|
|
70
|
+
// Try to get detailed diff, but handle buffer overflow gracefully
|
|
71
|
+
let detailedDiff = '';
|
|
72
|
+
try {
|
|
73
|
+
// Use minimal context (U3) and limit to text files to reduce size
|
|
74
|
+
detailedDiff = execSync('git diff --staged -U3 --diff-filter=ACMRT', {
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
77
|
+
});
|
|
78
|
+
// Truncate if still too large (keep first 5MB)
|
|
79
|
+
if (detailedDiff.length > 5 * 1024 * 1024) {
|
|
80
|
+
detailedDiff = `${detailedDiff.slice(0, 5 * 1024 * 1024)}\n...[truncated due to size]...`;
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (
|
|
84
|
+
error.message.includes('ENOBUFS') ||
|
|
85
|
+
error.message.includes('maxBuffer')
|
|
86
|
+
) {
|
|
87
|
+
console.log('⚠️ Diff too large, using summary only');
|
|
88
|
+
// Fallback: get diff stats only
|
|
89
|
+
detailedDiff =
|
|
90
|
+
'Diff too large to include. See file changes summary above.';
|
|
91
|
+
} else {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get comprehensive change information
|
|
97
|
+
const changes = {
|
|
98
|
+
// Summary of file changes
|
|
99
|
+
fileChanges: gitDiff,
|
|
100
|
+
|
|
101
|
+
// Detailed code diff (may be truncated or empty if too large)
|
|
102
|
+
detailedDiff,
|
|
103
|
+
|
|
104
|
+
// List of modified files
|
|
105
|
+
modifiedFiles,
|
|
106
|
+
|
|
107
|
+
// Get diff stats (additions/deletions)
|
|
108
|
+
diffStats: execSync('git diff --staged --stat', {
|
|
109
|
+
encoding: 'utf8',
|
|
110
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
// Get commit info context
|
|
114
|
+
lastCommit: execSync('git log -1 --oneline', {
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
117
|
+
}).trim(),
|
|
118
|
+
|
|
119
|
+
// Branch information
|
|
120
|
+
currentBranch: execSync('git branch --show-current', {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
123
|
+
}).trim(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Analyze each modified file individually for better context
|
|
127
|
+
const fileAnalysis = [];
|
|
128
|
+
for (const file of changes.modifiedFiles.slice(0, 10)) {
|
|
129
|
+
// Limit to 10 files and skip binary/large files
|
|
130
|
+
const fileType = getFileType(file);
|
|
131
|
+
if (fileType === 'Code' && !CODE_FILE_EXTENSION_RE.test(file)) {
|
|
132
|
+
continue; // Skip non-code files
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const fileDiff = execSync(`git diff --staged -U3 -- "${file}"`, {
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
maxBuffer: 1024 * 1024, // 1MB per file
|
|
139
|
+
});
|
|
140
|
+
fileAnalysis.push({
|
|
141
|
+
file,
|
|
142
|
+
changes: fileDiff.slice(0, 2000), // Limit per file for API
|
|
143
|
+
type: fileType,
|
|
144
|
+
});
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (
|
|
147
|
+
error.message.includes('ENOBUFS') ||
|
|
148
|
+
error.message.includes('maxBuffer')
|
|
149
|
+
) {
|
|
150
|
+
// File too large, skip it
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
console.error(`⚠️ Failed to analyze file ${file}:`, error.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
hasChanges: true,
|
|
159
|
+
summary: {
|
|
160
|
+
totalFiles: changes.modifiedFiles.length,
|
|
161
|
+
stats: changes.diffStats,
|
|
162
|
+
branch: changes.currentBranch,
|
|
163
|
+
lastCommit: changes.lastCommit,
|
|
164
|
+
},
|
|
165
|
+
fileChanges: changes.fileChanges,
|
|
166
|
+
detailedDiff: changes.detailedDiff,
|
|
167
|
+
fileAnalysis,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Determine file type for better AI analysis
|
|
173
|
+
*/
|
|
174
|
+
function getFileType(filename) {
|
|
175
|
+
const ext = filename.split('.').pop()?.toLowerCase();
|
|
176
|
+
const typeMap = {
|
|
177
|
+
tsx: 'React TypeScript Component',
|
|
178
|
+
ts: 'TypeScript',
|
|
179
|
+
jsx: 'React JavaScript Component',
|
|
180
|
+
js: 'JavaScript',
|
|
181
|
+
css: 'Stylesheet',
|
|
182
|
+
json: 'Configuration',
|
|
183
|
+
md: 'Documentation',
|
|
184
|
+
yml: 'Configuration',
|
|
185
|
+
yaml: 'Configuration',
|
|
186
|
+
};
|
|
187
|
+
return typeMap[ext] || 'Code';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Generate an AI-powered commit message based on git changes
|
|
192
|
+
*/
|
|
193
|
+
async function generateCommitMessage(argv) {
|
|
194
|
+
try {
|
|
195
|
+
const isDryRun = argv.includes('--dry-run');
|
|
196
|
+
// Check if GROQ_API_KEY is set
|
|
197
|
+
if (!process.env.GROQ_API_KEY || process.env.GROQ_API_KEY === '') {
|
|
198
|
+
console.error('❌ GROQ_API_KEY environment variable is required');
|
|
199
|
+
console.log('💡 Get your API key from: https://console.groq.com/keys');
|
|
200
|
+
console.log('💡 Set it in .env.local file: GROQ_API_KEY="your-key-here"');
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Initialize Groq client
|
|
205
|
+
const groq = new Groq({
|
|
206
|
+
apiKey: process.env.GROQ_API_KEY,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Get comprehensive analysis of all changes
|
|
210
|
+
let changeAnalysis;
|
|
211
|
+
try {
|
|
212
|
+
changeAnalysis = analyzeAllChanges();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error('❌ Failed to analyze changes:', error.message);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!changeAnalysis.hasChanges) {
|
|
219
|
+
console.log('✅ No changes to commit');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
console.log('🤖 Generating commit message with AI...');
|
|
224
|
+
|
|
225
|
+
// Generate commit message using Groq with comprehensive analysis
|
|
226
|
+
const completion = await createCompletionSafe(
|
|
227
|
+
groq,
|
|
228
|
+
buildChatMessages(changeAnalysis),
|
|
229
|
+
'openai/gpt-oss-120b'
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const rawContent = completion?.choices?.[0]?.message?.content
|
|
233
|
+
?.trim()
|
|
234
|
+
.replace(OPENING_CODE_BLOCK_REGEX, '') // Remove opening code block
|
|
235
|
+
.replace(CLOSING_CODE_BLOCK_REGEX, '') // Remove closing code block
|
|
236
|
+
.trim();
|
|
237
|
+
|
|
238
|
+
// Some models put useful text in a nonstandard `reasoning` field
|
|
239
|
+
const reasoning =
|
|
240
|
+
completion?.choices?.[0]?.message?.reasoning?.toString?.().trim?.() ?? '';
|
|
241
|
+
|
|
242
|
+
// Build a robust Conventional Commit from either content or reasoning
|
|
243
|
+
let built = buildConventionalCommit(rawContent || '', reasoning || '');
|
|
244
|
+
|
|
245
|
+
if (!built) {
|
|
246
|
+
logCompletionFailureAndExit(completion);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const violations = findCommitViolations(built);
|
|
250
|
+
if (violations.length > 0) {
|
|
251
|
+
const repairCompletion = await createCompletionSafe(
|
|
252
|
+
groq,
|
|
253
|
+
buildRepairMessages(rawContent || '', reasoning || '', built, violations),
|
|
254
|
+
'openai/gpt-oss-120b'
|
|
255
|
+
);
|
|
256
|
+
const repairedContent = repairCompletion?.choices?.[0]?.message?.content
|
|
257
|
+
?.trim()
|
|
258
|
+
.replace(OPENING_CODE_BLOCK_REGEX, '')
|
|
259
|
+
.replace(CLOSING_CODE_BLOCK_REGEX, '')
|
|
260
|
+
.trim();
|
|
261
|
+
const repairedReasoning =
|
|
262
|
+
repairCompletion?.choices?.[0]?.message?.reasoning
|
|
263
|
+
?.toString?.()
|
|
264
|
+
.trim?.() ?? '';
|
|
265
|
+
built = buildConventionalCommit(repairedContent || '', repairedReasoning || '');
|
|
266
|
+
if (!built) {
|
|
267
|
+
logCompletionFailureAndExit(repairCompletion);
|
|
268
|
+
}
|
|
269
|
+
const remaining = findCommitViolations(built);
|
|
270
|
+
if (remaining.length > 0) {
|
|
271
|
+
console.error('❌ Commit message failed validation:');
|
|
272
|
+
console.error(formatViolations(remaining));
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(`✨ Generated commit message: "${built.title}"`);
|
|
278
|
+
if (isDryRun) {
|
|
279
|
+
const preview = buildFullMessage(built);
|
|
280
|
+
console.log('\n--- Commit message preview (dry run) ---');
|
|
281
|
+
console.log(preview);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Write full commit message to a temporary file to avoid shell quoting issues
|
|
286
|
+
const tmpFile = path.join(
|
|
287
|
+
os.tmpdir(),
|
|
288
|
+
`commit-msg-${Date.now().toString()}.txt`
|
|
289
|
+
);
|
|
290
|
+
const fullMessage = buildFullMessage(built);
|
|
291
|
+
fs.writeFileSync(tmpFile, fullMessage, 'utf8');
|
|
292
|
+
|
|
293
|
+
// Commit using -F to read message from file
|
|
294
|
+
try {
|
|
295
|
+
execSync(`git commit -F "${tmpFile}"`, { encoding: 'utf8' });
|
|
296
|
+
console.log('✅ Changes committed successfully');
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error('❌ Failed to commit changes:', error.message);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
// Cleanup temp file (best-effort)
|
|
302
|
+
try {
|
|
303
|
+
fs.unlinkSync(tmpFile);
|
|
304
|
+
} catch {
|
|
305
|
+
// ignore cleanup errors
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Push to remote
|
|
309
|
+
try {
|
|
310
|
+
const currentBranch = execSync('git branch --show-current', {
|
|
311
|
+
encoding: 'utf8',
|
|
312
|
+
}).trim();
|
|
313
|
+
execSync(`git push origin ${currentBranch}`, { encoding: 'utf8' });
|
|
314
|
+
console.log(`🚀 Changes pushed to origin/${currentBranch}`);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error('❌ Failed to push changes:', error.message);
|
|
317
|
+
console.log('💡 You may need to push manually with: git push');
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error('❌ Error generating commit message:');
|
|
322
|
+
console.error(formatError(error));
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function createCompletionSafe(groq, messages, model) {
|
|
328
|
+
try {
|
|
329
|
+
return await groq.chat.completions.create({
|
|
330
|
+
messages,
|
|
331
|
+
model,
|
|
332
|
+
temperature: 0.3,
|
|
333
|
+
max_tokens: 16_384,
|
|
334
|
+
reasoning_effort: 'high',
|
|
335
|
+
});
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error('❌ Groq API error while creating completion');
|
|
338
|
+
console.error(formatError(error));
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function buildChatMessages(changeAnalysis) {
|
|
344
|
+
const filesBlock = changeAnalysis.fileAnalysis
|
|
345
|
+
.map((file) => `### ${file.file} (${file.type})\n${file.changes}`)
|
|
346
|
+
.join('\n\n');
|
|
347
|
+
|
|
348
|
+
// Estimate token count: ~4 characters per token, leave room for system prompt and other content
|
|
349
|
+
// Target: ~6000 tokens max for user content (leaving ~2000 for system prompt and overhead)
|
|
350
|
+
const MAX_USER_CONTENT_CHARS = 24_000; // ~6000 tokens
|
|
351
|
+
|
|
352
|
+
// Build user content
|
|
353
|
+
const summarySection =
|
|
354
|
+
'Summary:\n' +
|
|
355
|
+
`- Modified ${changeAnalysis.summary.totalFiles} files\n` +
|
|
356
|
+
`- Branch: ${changeAnalysis.summary.branch}\n` +
|
|
357
|
+
`- Previous commit: ${changeAnalysis.summary.lastCommit}\n\n`;
|
|
358
|
+
|
|
359
|
+
const filesSection = `Files:\n${changeAnalysis.fileChanges}\n\n`;
|
|
360
|
+
const statsSection = `Stats:\n${changeAnalysis.summary.stats}\n\n`;
|
|
361
|
+
const detailsSection = `Details:\n${filesBlock}\n\n`;
|
|
362
|
+
|
|
363
|
+
// Calculate remaining space for diff
|
|
364
|
+
const usedChars =
|
|
365
|
+
summarySection.length +
|
|
366
|
+
filesSection.length +
|
|
367
|
+
statsSection.length +
|
|
368
|
+
detailsSection.length;
|
|
369
|
+
const diffMaxChars = Math.max(0, MAX_USER_CONTENT_CHARS - usedChars - 500); // 500 char buffer
|
|
370
|
+
|
|
371
|
+
const diffSection =
|
|
372
|
+
diffMaxChars > 0
|
|
373
|
+
? `Full diff (truncated):\n${changeAnalysis.detailedDiff.slice(0, diffMaxChars)}\n`
|
|
374
|
+
: '';
|
|
375
|
+
|
|
376
|
+
const userContent =
|
|
377
|
+
'Analyze these changes and produce a single conventional commit:\n\n' +
|
|
378
|
+
summarySection +
|
|
379
|
+
filesSection +
|
|
380
|
+
statsSection +
|
|
381
|
+
detailsSection +
|
|
382
|
+
diffSection;
|
|
383
|
+
|
|
384
|
+
return [
|
|
385
|
+
{
|
|
386
|
+
role: 'system',
|
|
387
|
+
content:
|
|
388
|
+
'You are an AI assistant tasked with generating a git commit message based on the staged code changes provided below. Follow these rules.\n\n' +
|
|
389
|
+
'Commit message format: <type>: <description>\n' +
|
|
390
|
+
'Types (lowercase, required): chore, deprecate, feat, fix, release\n' +
|
|
391
|
+
'Breaking changes: append ! after the type (e.g., fix!: ...)\n' +
|
|
392
|
+
'Description: required, under 256 characters, imperative mood, no trailing period\n' +
|
|
393
|
+
'Body: optional. If present, use exactly three bullets in this order:\n' +
|
|
394
|
+
'- change: ...\n' +
|
|
395
|
+
'- why: ...\n' +
|
|
396
|
+
'- risk: ...\n' +
|
|
397
|
+
'Body lines must be under 256 characters each.\n' +
|
|
398
|
+
'Footer: optional lines after a blank line following the body. Each line must be under 256 characters.\n' +
|
|
399
|
+
'Do not include a scope.\n' +
|
|
400
|
+
'Output only the final commit text, no markdown or code fences.',
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
role: 'user',
|
|
404
|
+
content: userContent,
|
|
405
|
+
},
|
|
406
|
+
];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function logCompletionFailureAndExit(completion) {
|
|
410
|
+
console.error(
|
|
411
|
+
'❌ Failed to generate commit message. Raw completion payload:'
|
|
412
|
+
);
|
|
413
|
+
const raw = safeStringify(completion);
|
|
414
|
+
console.error(
|
|
415
|
+
raw.length > 10_000 ? `${raw.slice(0, 10_000)}\n...[truncated]...` : raw
|
|
416
|
+
);
|
|
417
|
+
if (completion?.usage) {
|
|
418
|
+
console.error('Usage:', safeStringify(completion.usage));
|
|
419
|
+
}
|
|
420
|
+
if (completion?.choices) {
|
|
421
|
+
console.error(
|
|
422
|
+
'Choices meta:',
|
|
423
|
+
safeStringify(
|
|
424
|
+
completion.choices.map((c) => ({
|
|
425
|
+
finish_reason: c.finish_reason,
|
|
426
|
+
index: c.index,
|
|
427
|
+
}))
|
|
428
|
+
)
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function buildConventionalCommit(content, reasoning) {
|
|
435
|
+
const text = sanitizeText([content, reasoning].filter(Boolean).join('\n'));
|
|
436
|
+
if (!text.trim()) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let titleMatch = null;
|
|
441
|
+
for (const m of text.matchAll(TITLE_RE)) {
|
|
442
|
+
if (m?.groups?.subject) {
|
|
443
|
+
titleMatch = m;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!titleMatch) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const { type } = titleMatch.groups;
|
|
453
|
+
const breaking = titleMatch.groups.breaking ? '!' : '';
|
|
454
|
+
let subject = titleMatch.groups.subject.trim();
|
|
455
|
+
subject = stripWrappingQuotes(subject);
|
|
456
|
+
|
|
457
|
+
const title = `${type}${breaking}: ${subject}`;
|
|
458
|
+
|
|
459
|
+
const { body, footer } = buildStructuredBody(text);
|
|
460
|
+
|
|
461
|
+
return { title, body, footer };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function sanitizeText(s) {
|
|
465
|
+
if (!s) {
|
|
466
|
+
return '';
|
|
467
|
+
}
|
|
468
|
+
return s
|
|
469
|
+
.replace(OPENING_CODE_BLOCK_REGEX, '')
|
|
470
|
+
.replace(CLOSING_CODE_BLOCK_REGEX, '')
|
|
471
|
+
.replace(/`/g, '') // remove inline backticks to prevent shell substitutions in logs
|
|
472
|
+
.trim();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function stripWrappingQuotes(s) {
|
|
476
|
+
if (!s) {
|
|
477
|
+
return s;
|
|
478
|
+
}
|
|
479
|
+
// remove wrapping single or double quotes if present
|
|
480
|
+
if (
|
|
481
|
+
(s.startsWith('"') && s.endsWith('"')) ||
|
|
482
|
+
(s.startsWith("'") && s.endsWith("'"))
|
|
483
|
+
) {
|
|
484
|
+
return s.slice(1, -1);
|
|
485
|
+
}
|
|
486
|
+
return s;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function buildStructuredBody(text) {
|
|
490
|
+
const lines = text.split(NEWLINE_SPLIT_RE);
|
|
491
|
+
const extracted = {
|
|
492
|
+
change: '',
|
|
493
|
+
why: '',
|
|
494
|
+
risk: '',
|
|
495
|
+
};
|
|
496
|
+
const footerLines = [];
|
|
497
|
+
|
|
498
|
+
for (const line of lines) {
|
|
499
|
+
const trimmed = line.trim();
|
|
500
|
+
if (!trimmed) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const withoutBullet = BULLET_LINE_RE.test(trimmed)
|
|
504
|
+
? trimmed.replace(BULLET_LINE_RE, '')
|
|
505
|
+
: trimmed;
|
|
506
|
+
const lower = withoutBullet.toLowerCase();
|
|
507
|
+
if (lower.startsWith('change:')) {
|
|
508
|
+
extracted.change = withoutBullet.slice('change:'.length).trim();
|
|
509
|
+
} else if (lower.startsWith('why:')) {
|
|
510
|
+
extracted.why = withoutBullet.slice('why:'.length).trim();
|
|
511
|
+
} else if (lower.startsWith('risk:')) {
|
|
512
|
+
extracted.risk = withoutBullet.slice('risk:'.length).trim();
|
|
513
|
+
} else if (lower.startsWith('testing:') && !extracted.risk) {
|
|
514
|
+
extracted.risk = `testing: ${withoutBullet.slice('testing:'.length).trim()}`;
|
|
515
|
+
} else if (
|
|
516
|
+
withoutBullet.startsWith('Footer:') ||
|
|
517
|
+
withoutBullet.startsWith('Refs:') ||
|
|
518
|
+
withoutBullet.startsWith('Ref:') ||
|
|
519
|
+
withoutBullet.startsWith('Fixes:') ||
|
|
520
|
+
withoutBullet.startsWith('BREAKING CHANGE:')
|
|
521
|
+
) {
|
|
522
|
+
footerLines.push(withoutBullet);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const hasBody =
|
|
527
|
+
Boolean(extracted.change) ||
|
|
528
|
+
Boolean(extracted.why) ||
|
|
529
|
+
Boolean(extracted.risk);
|
|
530
|
+
if (!hasBody && footerLines.length === 0) {
|
|
531
|
+
return { body: '', footer: '' };
|
|
532
|
+
}
|
|
533
|
+
const change = extracted.change || '(not provided)';
|
|
534
|
+
const why = extracted.why || '(not provided)';
|
|
535
|
+
const risk = extracted.risk || '(not provided)';
|
|
536
|
+
|
|
537
|
+
const body = [`- change: ${change}`, `- why: ${why}`, `- risk: ${risk}`].join(
|
|
538
|
+
'\n'
|
|
539
|
+
);
|
|
540
|
+
const footer = footerLines.join('\n');
|
|
541
|
+
return { body, footer };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function buildRepairMessages(content, reasoning, built, violations) {
|
|
545
|
+
const issueList = violations.map((v) => `- ${v}`).join('\n');
|
|
546
|
+
const raw = sanitizeText([content, reasoning].filter(Boolean).join('\n'));
|
|
547
|
+
return [
|
|
548
|
+
{
|
|
549
|
+
role: 'system',
|
|
550
|
+
content:
|
|
551
|
+
'You fix commit messages to match these rules exactly.\n\n' +
|
|
552
|
+
'Commit message format: <type>: <description>\n' +
|
|
553
|
+
'Types (lowercase, required): chore, deprecate, feat, fix, release\n' +
|
|
554
|
+
'Breaking changes: append ! after the type (e.g., fix!: ...)\n' +
|
|
555
|
+
'Description: required, under 256 characters, imperative mood, no trailing period\n' +
|
|
556
|
+
'Body: optional. If present, use exactly three bullets in this order:\n' +
|
|
557
|
+
'- change: ...\n' +
|
|
558
|
+
'- why: ...\n' +
|
|
559
|
+
'- risk: ...\n' +
|
|
560
|
+
'Body lines must be under 256 characters each.\n' +
|
|
561
|
+
'Footer: optional lines after a blank line following the body. Each line must be under 256 characters.\n' +
|
|
562
|
+
'Do not include a scope.\n' +
|
|
563
|
+
'Output only the final commit text, no markdown or code fences.',
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
role: 'user',
|
|
567
|
+
content: [
|
|
568
|
+
'Violations:',
|
|
569
|
+
issueList,
|
|
570
|
+
'',
|
|
571
|
+
'Current commit:',
|
|
572
|
+
buildFullMessage(built),
|
|
573
|
+
'',
|
|
574
|
+
'Original model output (for context):',
|
|
575
|
+
raw || '(not provided)',
|
|
576
|
+
].join('\n'),
|
|
577
|
+
},
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function findCommitViolations(built) {
|
|
582
|
+
const violations = [];
|
|
583
|
+
const title = built.title || '';
|
|
584
|
+
if (!title) {
|
|
585
|
+
violations.push('title is missing');
|
|
586
|
+
}
|
|
587
|
+
if (title.length > 256) {
|
|
588
|
+
violations.push('title exceeds 256 characters');
|
|
589
|
+
}
|
|
590
|
+
if (!TITLE_VALIDATION_RE.test(title)) {
|
|
591
|
+
violations.push('title does not match required type format');
|
|
592
|
+
}
|
|
593
|
+
if (title.includes('(')) {
|
|
594
|
+
violations.push('title includes a scope');
|
|
595
|
+
}
|
|
596
|
+
const subject = title.split(':').slice(1).join(':').trim();
|
|
597
|
+
if (!subject) {
|
|
598
|
+
violations.push('description is empty');
|
|
599
|
+
}
|
|
600
|
+
if (subject.endsWith('.')) {
|
|
601
|
+
violations.push('description ends with a period');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (built.body) {
|
|
605
|
+
const lines = built.body.split(NEWLINE_SPLIT_RE).filter(Boolean);
|
|
606
|
+
if (lines.length !== 3) {
|
|
607
|
+
violations.push('body must have exactly three bullets');
|
|
608
|
+
}
|
|
609
|
+
const expectedPrefixes = ['- change:', '- why:', '- risk:'];
|
|
610
|
+
for (const [index, line] of lines.entries()) {
|
|
611
|
+
if (line.length > 256) {
|
|
612
|
+
violations.push('body line exceeds 256 characters');
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
const expected = expectedPrefixes[index];
|
|
616
|
+
if (!(expected && line.startsWith(expected))) {
|
|
617
|
+
violations.push('body bullets must be change/why/risk in order');
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (built.footer) {
|
|
624
|
+
const footerLines = built.footer.split(NEWLINE_SPLIT_RE).filter(Boolean);
|
|
625
|
+
for (const line of footerLines) {
|
|
626
|
+
if (line.length > 256) {
|
|
627
|
+
violations.push('footer line exceeds 256 characters');
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return violations;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function formatViolations(violations) {
|
|
637
|
+
return violations.map((violation) => `- ${violation}`).join('\n');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function buildFullMessage(built) {
|
|
641
|
+
const sections = [built.title];
|
|
642
|
+
if (built.body) {
|
|
643
|
+
sections.push(built.body);
|
|
644
|
+
}
|
|
645
|
+
if (built.footer) {
|
|
646
|
+
sections.push(built.footer);
|
|
647
|
+
}
|
|
648
|
+
return sections.join('\n\n');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function safeStringify(obj) {
|
|
652
|
+
try {
|
|
653
|
+
const seen = new WeakSet();
|
|
654
|
+
return JSON.stringify(
|
|
655
|
+
obj,
|
|
656
|
+
(_key, value) => {
|
|
657
|
+
if (typeof value === 'object' && value !== null) {
|
|
658
|
+
if (seen.has(value)) {
|
|
659
|
+
return '[Circular]';
|
|
660
|
+
}
|
|
661
|
+
seen.add(value);
|
|
662
|
+
}
|
|
663
|
+
if (typeof value === 'bigint') {
|
|
664
|
+
return value.toString();
|
|
665
|
+
}
|
|
666
|
+
return value;
|
|
667
|
+
},
|
|
668
|
+
2
|
|
669
|
+
);
|
|
670
|
+
} catch {
|
|
671
|
+
// Fallback to best-effort string conversion when JSON serialization fails
|
|
672
|
+
try {
|
|
673
|
+
return String(obj);
|
|
674
|
+
} catch {
|
|
675
|
+
return '[Unstringifiable]';
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function formatError(error) {
|
|
681
|
+
// Try to include as much structured information as possible
|
|
682
|
+
const plain = {};
|
|
683
|
+
for (const key of Object.getOwnPropertyNames(error)) {
|
|
684
|
+
// include standard fields: name, message, stack, cause, status, code, response
|
|
685
|
+
// avoid huge nested response bodies without control
|
|
686
|
+
if (key === 'response' && error.response) {
|
|
687
|
+
plain.response = {
|
|
688
|
+
status: error.response.status,
|
|
689
|
+
statusText: error.response.statusText,
|
|
690
|
+
headers: error.response.headers || undefined,
|
|
691
|
+
data: error.response.data || undefined,
|
|
692
|
+
};
|
|
693
|
+
} else {
|
|
694
|
+
try {
|
|
695
|
+
// Copy other enumerable properties safely
|
|
696
|
+
// eslint-disable-next-line no-param-reassign
|
|
697
|
+
plain[key] = error[key];
|
|
698
|
+
} catch {
|
|
699
|
+
// ignore non-readable props
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return safeStringify(plain);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function runCommit(argv = process.argv.slice(2)) {
|
|
707
|
+
await generateCommitMessage(argv);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (require.main === module) {
|
|
711
|
+
runCommit();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
module.exports = { runCommit };
|