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
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
const { execSync, spawnSync } = 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
|
+
config({ path: '.env.local' });
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BASE = process.env.PR_SUMMARY_BASE || 'main';
|
|
11
|
+
const DEFAULT_OUT = process.env.PR_SUMMARY_OUT || '.pr-summaries/PR_SUMMARY.md';
|
|
12
|
+
const DEFAULT_MODEL =
|
|
13
|
+
process.env.GROQ_PR_MODEL || process.env.GROQ_MODEL || 'openai/gpt-oss-120b';
|
|
14
|
+
const DEFAULT_LIMIT = Number.parseInt(
|
|
15
|
+
process.env.PR_SUMMARY_LIMIT || '400',
|
|
16
|
+
10
|
|
17
|
+
);
|
|
18
|
+
const DEFAULT_ISSUE = process.env.PR_SUMMARY_ISSUE || '';
|
|
19
|
+
const LARGE_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
20
|
+
const BODY_TRUNCATION = 4000;
|
|
21
|
+
const CHUNK_SIZE_CHARS = 8000;
|
|
22
|
+
const NEWLINE_SPLIT_RE = /\r?\n/;
|
|
23
|
+
|
|
24
|
+
const ui = {
|
|
25
|
+
reset: '\x1b[0m',
|
|
26
|
+
bold: '\x1b[1m',
|
|
27
|
+
dim: '\x1b[2m',
|
|
28
|
+
cyan: '\x1b[38;2;0;255;255m',
|
|
29
|
+
magenta: '\x1b[38;2;255;0;255m',
|
|
30
|
+
purple: '\x1b[38;2;148;87;235m',
|
|
31
|
+
blue: '\x1b[38;2;64;160;255m',
|
|
32
|
+
green: '\x1b[38;2;64;255;186m',
|
|
33
|
+
yellow: '\x1b[38;2;255;221;87m',
|
|
34
|
+
red: '\x1b[38;2;255;99;132m',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function paint(text, color) {
|
|
38
|
+
return `${color}${text}${ui.reset}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function banner(branch, base) {
|
|
42
|
+
const title = paint('PR SYNTHESIZER', ui.magenta);
|
|
43
|
+
const line = paint('═'.repeat(36), ui.purple);
|
|
44
|
+
const meta = `${paint('branch', ui.cyan)} ${branch} ${paint(
|
|
45
|
+
'base',
|
|
46
|
+
ui.cyan
|
|
47
|
+
)} ${base}`;
|
|
48
|
+
return `${line}\n${title}\n${meta}\n${line}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function step(label) {
|
|
52
|
+
process.stdout.write(`${paint('◆', ui.blue)} ${label}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function success(label) {
|
|
56
|
+
process.stdout.write(`${paint('✓', ui.green)} ${label}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function warn(label) {
|
|
60
|
+
process.stdout.write(`${paint('◷', ui.yellow)} ${label}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fail(label) {
|
|
64
|
+
process.stdout.write(`${paint('✕', ui.red)} ${label}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function runGit(command) {
|
|
68
|
+
try {
|
|
69
|
+
return execSync(command, {
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new Error(`Git command failed: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectCommits(baseRef, limit) {
|
|
79
|
+
const range = `${baseRef}..HEAD`;
|
|
80
|
+
let rawLog = '';
|
|
81
|
+
try {
|
|
82
|
+
rawLog = runGit(
|
|
83
|
+
`git log ${range} --reverse --pretty=format:%H%x1f%s%x1f%b%x1e`
|
|
84
|
+
);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error.message.includes('unknown revision')) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Base ref "${baseRef}" not found. Use --base to set a valid branch.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!rawLog.trim()) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const commits = rawLog
|
|
99
|
+
.split('\x1e')
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.map((entry) => {
|
|
102
|
+
const [sha = '', title = '', bodyRaw = ''] = entry.split('\x1f');
|
|
103
|
+
const body = bodyRaw.trim().slice(0, BODY_TRUNCATION);
|
|
104
|
+
return { sha: sha.trim(), title: title.trim(), body };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (Number.isFinite(limit) && limit > 0 && commits.length > limit) {
|
|
108
|
+
return commits.slice(-limit);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return commits;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function tryFetchBase(baseBranch) {
|
|
115
|
+
try {
|
|
116
|
+
execSync(`git fetch origin ${baseBranch}`, {
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
stdio: 'ignore',
|
|
119
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
120
|
+
});
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveBaseRef(baseBranch) {
|
|
128
|
+
try {
|
|
129
|
+
execSync(`git show-ref --verify refs/remotes/origin/${baseBranch}`, {
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
stdio: 'ignore',
|
|
132
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
133
|
+
});
|
|
134
|
+
return `origin/${baseBranch}`;
|
|
135
|
+
} catch {
|
|
136
|
+
try {
|
|
137
|
+
execSync(`git show-ref --verify refs/heads/${baseBranch}`, {
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
stdio: 'ignore',
|
|
140
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
141
|
+
});
|
|
142
|
+
return baseBranch;
|
|
143
|
+
} catch {
|
|
144
|
+
return baseBranch;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function chunkCommits(commits, maxChars) {
|
|
150
|
+
const chunks = [];
|
|
151
|
+
let current = [];
|
|
152
|
+
let currentSize = 0;
|
|
153
|
+
|
|
154
|
+
for (const commit of commits) {
|
|
155
|
+
const serialized = serializeCommit(commit);
|
|
156
|
+
if (currentSize + serialized.length > maxChars && current.length > 0) {
|
|
157
|
+
chunks.push(current);
|
|
158
|
+
current = [];
|
|
159
|
+
currentSize = 0;
|
|
160
|
+
}
|
|
161
|
+
current.push(commit);
|
|
162
|
+
currentSize += serialized.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (current.length > 0) {
|
|
166
|
+
chunks.push(current);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return chunks;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function serializeCommit(commit) {
|
|
173
|
+
return `${commit.sha}\n${commit.title}\n${commit.body}\n---\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function createCompletionSafe(groq, messages, model, maxTokens) {
|
|
177
|
+
try {
|
|
178
|
+
return await groq.chat.completions.create({
|
|
179
|
+
messages,
|
|
180
|
+
model,
|
|
181
|
+
temperature: 0.3,
|
|
182
|
+
max_tokens: maxTokens,
|
|
183
|
+
});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
fail('Groq API error while creating completion');
|
|
186
|
+
step(formatError(error));
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildPass1Messages(commits, branch, base) {
|
|
192
|
+
const titles = commits
|
|
193
|
+
.map((commit) => `- ${commit.title || '(no title)'}`)
|
|
194
|
+
.join('\n');
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
role: 'system',
|
|
198
|
+
content:
|
|
199
|
+
'You summarize commit headlines using a concise five-Cs style (Category, Context, Correctness, Contributions, Clarity). Keep it short and actionable.',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
role: 'user',
|
|
203
|
+
content: [
|
|
204
|
+
`Branch: ${branch}`,
|
|
205
|
+
`Base: ${base}`,
|
|
206
|
+
'Commit titles (oldest to newest):',
|
|
207
|
+
titles || '(no commits)',
|
|
208
|
+
'',
|
|
209
|
+
'Return:',
|
|
210
|
+
'- A 5Cs snapshot of the branch.',
|
|
211
|
+
'- 1-2 bullet headlines per commit.',
|
|
212
|
+
].join('\n'),
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildPass2Messages(commitsChunk) {
|
|
218
|
+
const body = commitsChunk
|
|
219
|
+
.map((commit) =>
|
|
220
|
+
[
|
|
221
|
+
`SHA: ${commit.sha}`,
|
|
222
|
+
`Title: ${commit.title || '(no title)'}`,
|
|
223
|
+
`Body:\n${commit.body || '(no body)'}`,
|
|
224
|
+
'---',
|
|
225
|
+
].join('\n')
|
|
226
|
+
)
|
|
227
|
+
.join('\n');
|
|
228
|
+
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
role: 'system',
|
|
232
|
+
content:
|
|
233
|
+
'You are producing compact, high-signal summaries per commit: 2-3 bullets each (change, rationale, risk/test note). Flag any breaking changes or migrations.',
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
role: 'user',
|
|
237
|
+
content: [
|
|
238
|
+
'Commits (oldest to newest):',
|
|
239
|
+
body,
|
|
240
|
+
'',
|
|
241
|
+
'Return for each commit:',
|
|
242
|
+
'- Title-aligned bullet: what changed + why.',
|
|
243
|
+
'- Risk or test note if visible.',
|
|
244
|
+
'Keep outputs brief; do not restate bodies.',
|
|
245
|
+
].join('\n'),
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildPass3Messages(
|
|
251
|
+
pass2Summaries,
|
|
252
|
+
branch,
|
|
253
|
+
base,
|
|
254
|
+
issue,
|
|
255
|
+
pass1Text,
|
|
256
|
+
commitTitles
|
|
257
|
+
) {
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
role: 'system',
|
|
261
|
+
content:
|
|
262
|
+
'You write PR summaries that are easy to review. Be concise, specific, and action-oriented. Do not include markdown fences.',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
role: 'user',
|
|
266
|
+
content: [
|
|
267
|
+
`Branch: ${branch}`,
|
|
268
|
+
`Base: ${base}`,
|
|
269
|
+
'',
|
|
270
|
+
'Inputs (condensed commit summaries):',
|
|
271
|
+
pass2Summaries.join('\n\n') || '(not provided)',
|
|
272
|
+
'',
|
|
273
|
+
'Additional context (5Cs snapshot):',
|
|
274
|
+
pass1Text || '(not provided)',
|
|
275
|
+
'',
|
|
276
|
+
'Commit titles:',
|
|
277
|
+
commitTitles || '(not provided)',
|
|
278
|
+
'',
|
|
279
|
+
'Write a PR summary in this exact order (use these exact headings):',
|
|
280
|
+
'What issue is this PR related to?',
|
|
281
|
+
'What change does this PR add?',
|
|
282
|
+
'How did you test your change?',
|
|
283
|
+
'Anything you want reviewers to scrutinize?',
|
|
284
|
+
'Other notes reviewers should know (risks + follow-ups)',
|
|
285
|
+
'',
|
|
286
|
+
'Rules:',
|
|
287
|
+
'- If the issue is unknown, write: "Related: (not provided)".',
|
|
288
|
+
'- If testing is unknown, write: "Testing: (not provided)".',
|
|
289
|
+
'- Prefer bullets. Be thorough but not rambly.',
|
|
290
|
+
'',
|
|
291
|
+
`Issue hint: ${issue || '(not provided)'}`,
|
|
292
|
+
].join('\n'),
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildReleaseMessages(
|
|
298
|
+
pass2Summaries,
|
|
299
|
+
branch,
|
|
300
|
+
base,
|
|
301
|
+
issue,
|
|
302
|
+
pass1Text,
|
|
303
|
+
commitTitles
|
|
304
|
+
) {
|
|
305
|
+
return [
|
|
306
|
+
{
|
|
307
|
+
role: 'system',
|
|
308
|
+
content:
|
|
309
|
+
'You write release PR summaries for QA to production. Be concise, concrete, and action-oriented. Do not include markdown fences.',
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
role: 'user',
|
|
313
|
+
content: [
|
|
314
|
+
`Branch: ${branch}`,
|
|
315
|
+
`Base: ${base}`,
|
|
316
|
+
'',
|
|
317
|
+
'Inputs (condensed commit summaries):',
|
|
318
|
+
pass2Summaries.join('\n\n') || '(not provided)',
|
|
319
|
+
'',
|
|
320
|
+
'Additional context (5Cs snapshot):',
|
|
321
|
+
pass1Text || '(not provided)',
|
|
322
|
+
'',
|
|
323
|
+
'Commit titles:',
|
|
324
|
+
commitTitles || '(not provided)',
|
|
325
|
+
'',
|
|
326
|
+
'Write a release PR summary in this exact order (use these exact headings):',
|
|
327
|
+
'Release summary',
|
|
328
|
+
'Notable user-facing changes',
|
|
329
|
+
'Risk / breaking changes',
|
|
330
|
+
'QA / verification',
|
|
331
|
+
'Operational notes / rollout',
|
|
332
|
+
'Follow-ups / TODOs',
|
|
333
|
+
'',
|
|
334
|
+
'Rules:',
|
|
335
|
+
'- If unknown, write: "Unknown".',
|
|
336
|
+
'- Prefer bullets. Be thorough but not rambly.',
|
|
337
|
+
'',
|
|
338
|
+
`Issue hint: ${issue || '(not provided)'}`,
|
|
339
|
+
].join('\n'),
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function formatError(error) {
|
|
345
|
+
const plain = {};
|
|
346
|
+
for (const key of Object.getOwnPropertyNames(error)) {
|
|
347
|
+
if (key === 'response' && error.response) {
|
|
348
|
+
plain.response = {
|
|
349
|
+
status: error.response.status,
|
|
350
|
+
statusText: error.response.statusText,
|
|
351
|
+
headers: error.response.headers || undefined,
|
|
352
|
+
data: error.response.data || undefined,
|
|
353
|
+
};
|
|
354
|
+
} else {
|
|
355
|
+
try {
|
|
356
|
+
plain[key] = error[key];
|
|
357
|
+
} catch {
|
|
358
|
+
// ignore
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return safeStringify(plain);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function safeStringify(obj) {
|
|
366
|
+
try {
|
|
367
|
+
const seen = new WeakSet();
|
|
368
|
+
return JSON.stringify(
|
|
369
|
+
obj,
|
|
370
|
+
(_key, value) => {
|
|
371
|
+
if (typeof value === 'object' && value !== null) {
|
|
372
|
+
if (seen.has(value)) {
|
|
373
|
+
return '[Circular]';
|
|
374
|
+
}
|
|
375
|
+
seen.add(value);
|
|
376
|
+
}
|
|
377
|
+
if (typeof value === 'bigint') {
|
|
378
|
+
return value.toString();
|
|
379
|
+
}
|
|
380
|
+
return value;
|
|
381
|
+
},
|
|
382
|
+
2
|
|
383
|
+
);
|
|
384
|
+
} catch {
|
|
385
|
+
try {
|
|
386
|
+
return String(obj);
|
|
387
|
+
} catch {
|
|
388
|
+
return '[Unstringifiable]';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function formatCommitTitles(commits, limit) {
|
|
394
|
+
const items = commits.slice(-limit).map((commit) => {
|
|
395
|
+
const title = commit.title?.trim() || '(no title)';
|
|
396
|
+
return `- ${title}`;
|
|
397
|
+
});
|
|
398
|
+
return items.join('\n');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function isUnknownSummary(summary, mode) {
|
|
402
|
+
const trimmed = summary.trim();
|
|
403
|
+
if (!trimmed) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
if (mode !== 'release') {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
const headings = [
|
|
410
|
+
'Release summary',
|
|
411
|
+
'Notable user-facing changes',
|
|
412
|
+
'Risk / breaking changes',
|
|
413
|
+
'QA / verification',
|
|
414
|
+
'Operational notes / rollout',
|
|
415
|
+
'Follow-ups / TODOs',
|
|
416
|
+
];
|
|
417
|
+
const lines = trimmed.split(NEWLINE_SPLIT_RE);
|
|
418
|
+
let unknownCount = 0;
|
|
419
|
+
for (const heading of headings) {
|
|
420
|
+
const headingIndex = lines.findIndex(
|
|
421
|
+
(line) => line.trim().toLowerCase() === heading.toLowerCase()
|
|
422
|
+
);
|
|
423
|
+
if (headingIndex === -1) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
let nextLine = '';
|
|
427
|
+
for (let i = headingIndex + 1; i < lines.length; i += 1) {
|
|
428
|
+
const candidate = lines[i].trim();
|
|
429
|
+
if (candidate) {
|
|
430
|
+
nextLine = candidate;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (nextLine.toLowerCase() === 'unknown') {
|
|
435
|
+
unknownCount += 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return unknownCount === headings.length;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function checkGhCli() {
|
|
442
|
+
try {
|
|
443
|
+
execSync('gh --version', { encoding: 'utf8', stdio: 'ignore' });
|
|
444
|
+
return true;
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function checkUncommittedChanges() {
|
|
451
|
+
try {
|
|
452
|
+
const status = execSync('git status --porcelain', {
|
|
453
|
+
encoding: 'utf8',
|
|
454
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
455
|
+
}).trim();
|
|
456
|
+
return status.length > 0;
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function checkExistingPr(base, head) {
|
|
463
|
+
try {
|
|
464
|
+
// Check for open PRs from head to base
|
|
465
|
+
const result = spawnSync(
|
|
466
|
+
'gh',
|
|
467
|
+
[
|
|
468
|
+
'pr',
|
|
469
|
+
'list',
|
|
470
|
+
'--base',
|
|
471
|
+
base,
|
|
472
|
+
'--head',
|
|
473
|
+
`${head}`,
|
|
474
|
+
'--state',
|
|
475
|
+
'open',
|
|
476
|
+
'--json',
|
|
477
|
+
'number,title,url',
|
|
478
|
+
],
|
|
479
|
+
{
|
|
480
|
+
encoding: 'utf8',
|
|
481
|
+
stdio: 'pipe',
|
|
482
|
+
}
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
if (result.error || result.status !== 0) {
|
|
486
|
+
// If gh command fails, assume no PR exists (might be auth issue, but we'll catch that later)
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const prs = JSON.parse(result.stdout || '[]');
|
|
491
|
+
return prs.length > 0 ? prs[0] : null;
|
|
492
|
+
} catch {
|
|
493
|
+
// If parsing fails or command fails, assume no PR exists
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function extractPrTitle(summary, mode) {
|
|
499
|
+
const targetSection = mode === 'release' ? 'release summary' : 'what change';
|
|
500
|
+
// Try to extract a title from the summary
|
|
501
|
+
const lines = summary.split('\n');
|
|
502
|
+
let inChangesSection = false;
|
|
503
|
+
|
|
504
|
+
for (const line of lines) {
|
|
505
|
+
const trimmed = line.trim();
|
|
506
|
+
|
|
507
|
+
// Detect the target section
|
|
508
|
+
if (trimmed.toLowerCase().includes(targetSection)) {
|
|
509
|
+
inChangesSection = true;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// If we hit another section heading, stop looking
|
|
514
|
+
if (inChangesSection && trimmed && trimmed.endsWith('?')) {
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// If we're in the changes section, look for first bullet point
|
|
519
|
+
if (inChangesSection && trimmed.startsWith('-')) {
|
|
520
|
+
// Extract bullet content, clean it up
|
|
521
|
+
const bullet = trimmed.slice(1).trim();
|
|
522
|
+
// Remove markdown formatting and truncate
|
|
523
|
+
const clean = bullet
|
|
524
|
+
.replace(/`([^`]+)`/g, '$1') // Remove backticks
|
|
525
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
|
|
526
|
+
.replace(/\*([^*]+)\*/g, '$1') // Remove italic
|
|
527
|
+
.slice(0, 100);
|
|
528
|
+
if (clean.length > 10) {
|
|
529
|
+
return clean;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Fallback: use first commit title or branch name
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function createPrWithGh(base, branch, title, body) {
|
|
539
|
+
try {
|
|
540
|
+
// Ensure branch is pushed first
|
|
541
|
+
step('Pushing branch to remote...');
|
|
542
|
+
try {
|
|
543
|
+
execSync(`git push -u origin ${branch}`, {
|
|
544
|
+
encoding: 'utf8',
|
|
545
|
+
stdio: 'pipe',
|
|
546
|
+
});
|
|
547
|
+
success('Branch pushed to remote');
|
|
548
|
+
} catch (error) {
|
|
549
|
+
// Branch might already be pushed, check if it exists
|
|
550
|
+
const remoteBranches = execSync('git branch -r', {
|
|
551
|
+
encoding: 'utf8',
|
|
552
|
+
});
|
|
553
|
+
if (remoteBranches.includes(`origin/${branch}`)) {
|
|
554
|
+
warn('Branch already exists on remote, skipping push');
|
|
555
|
+
} else {
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Create PR using gh CLI
|
|
561
|
+
step('Creating PR with GitHub CLI...');
|
|
562
|
+
const prTitle = title || branch;
|
|
563
|
+
|
|
564
|
+
// Write body to temp file to avoid shell escaping issues
|
|
565
|
+
const bodyFile = path.join(
|
|
566
|
+
os.tmpdir(),
|
|
567
|
+
`pr-body-${Date.now().toString()}.md`
|
|
568
|
+
);
|
|
569
|
+
fs.writeFileSync(bodyFile, body, 'utf8');
|
|
570
|
+
|
|
571
|
+
const ghArgs = [
|
|
572
|
+
'pr',
|
|
573
|
+
'create',
|
|
574
|
+
'--base',
|
|
575
|
+
base,
|
|
576
|
+
'--head',
|
|
577
|
+
branch,
|
|
578
|
+
'--title',
|
|
579
|
+
prTitle,
|
|
580
|
+
'--body-file',
|
|
581
|
+
bodyFile,
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
// Add issue reference if provided via environment
|
|
585
|
+
const issueEnv = process.env.PR_SUMMARY_ISSUE || '';
|
|
586
|
+
if (issueEnv) {
|
|
587
|
+
ghArgs.push('--issue', issueEnv);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Use spawnSync for better argument handling
|
|
591
|
+
const result = spawnSync('gh', ghArgs, {
|
|
592
|
+
encoding: 'utf8',
|
|
593
|
+
stdio: 'pipe',
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
if (result.error) {
|
|
597
|
+
throw result.error;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (result.status !== 0) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
`gh pr create failed: ${result.stderr || result.stdout || 'Unknown error'}`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const prUrl = result.stdout.trim();
|
|
607
|
+
|
|
608
|
+
// Cleanup temp file
|
|
609
|
+
try {
|
|
610
|
+
fs.unlinkSync(bodyFile);
|
|
611
|
+
} catch {
|
|
612
|
+
// ignore cleanup errors
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
success(`PR created: ${prUrl}`);
|
|
616
|
+
return prUrl;
|
|
617
|
+
} catch (error) {
|
|
618
|
+
fail(`Failed to create PR: ${error.message}`);
|
|
619
|
+
warn('You can create the PR manually using the generated summary file');
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function updatePrWithGh(prNumber, title, body) {
|
|
625
|
+
try {
|
|
626
|
+
step(`Updating existing PR #${prNumber}...`);
|
|
627
|
+
const bodyFile = path.join(
|
|
628
|
+
os.tmpdir(),
|
|
629
|
+
`pr-body-${Date.now().toString()}.md`
|
|
630
|
+
);
|
|
631
|
+
fs.writeFileSync(bodyFile, body, 'utf8');
|
|
632
|
+
|
|
633
|
+
const args = ['pr', 'edit', String(prNumber), '--body-file', bodyFile];
|
|
634
|
+
if (title) {
|
|
635
|
+
args.push('--title', title);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const result = spawnSync('gh', args, {
|
|
639
|
+
encoding: 'utf8',
|
|
640
|
+
stdio: 'pipe',
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (result.error) {
|
|
644
|
+
throw result.error;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (result.status !== 0) {
|
|
648
|
+
throw new Error(
|
|
649
|
+
`gh pr edit failed: ${result.stderr || result.stdout || 'Unknown error'}`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
fs.unlinkSync(bodyFile);
|
|
655
|
+
} catch {
|
|
656
|
+
// ignore cleanup errors
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
success(`PR #${prNumber} updated`);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
fail(`Failed to update PR: ${error.message}`);
|
|
662
|
+
warn('You can update the PR manually using the generated summary file');
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function parseArgs(argv) {
|
|
668
|
+
const args = {
|
|
669
|
+
base: DEFAULT_BASE,
|
|
670
|
+
out: DEFAULT_OUT,
|
|
671
|
+
limit: DEFAULT_LIMIT,
|
|
672
|
+
dryRun: false,
|
|
673
|
+
issue: DEFAULT_ISSUE,
|
|
674
|
+
createPr: false,
|
|
675
|
+
mode: '',
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
679
|
+
const current = argv[i];
|
|
680
|
+
if (current === '--base' && argv[i + 1]) {
|
|
681
|
+
args.base = argv[i + 1];
|
|
682
|
+
i += 1;
|
|
683
|
+
} else if (current === '--out' && argv[i + 1]) {
|
|
684
|
+
args.out = argv[i + 1];
|
|
685
|
+
i += 1;
|
|
686
|
+
} else if (current === '--limit' && argv[i + 1]) {
|
|
687
|
+
args.limit = Number.parseInt(argv[i + 1], 10);
|
|
688
|
+
i += 1;
|
|
689
|
+
} else if (current === '--issue' && argv[i + 1]) {
|
|
690
|
+
args.issue = argv[i + 1];
|
|
691
|
+
i += 1;
|
|
692
|
+
} else if (current === '--dry-run') {
|
|
693
|
+
args.dryRun = true;
|
|
694
|
+
} else if (current === '--create-pr') {
|
|
695
|
+
args.createPr = true;
|
|
696
|
+
} else if (current === '--mode' && argv[i + 1]) {
|
|
697
|
+
args.mode = argv[i + 1];
|
|
698
|
+
i += 1;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return args;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function main(argv) {
|
|
706
|
+
if (!process.env.GROQ_API_KEY || process.env.GROQ_API_KEY === '') {
|
|
707
|
+
fail('GROQ_API_KEY environment variable is required');
|
|
708
|
+
step('Set it in .env.local: GROQ_API_KEY="your-key-here"');
|
|
709
|
+
process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const args = parseArgs(argv);
|
|
713
|
+
const branch = runGit('git branch --show-current').trim();
|
|
714
|
+
const mode =
|
|
715
|
+
args.mode ||
|
|
716
|
+
(branch === 'staging' && args.base === 'main' ? 'release' : 'feature');
|
|
717
|
+
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
|
|
718
|
+
|
|
719
|
+
process.stdout.write(`${banner(branch, args.base)}\n`);
|
|
720
|
+
|
|
721
|
+
const didFetch = tryFetchBase(args.base);
|
|
722
|
+
if (!didFetch) {
|
|
723
|
+
warn(`Could not fetch origin/${args.base}; using local refs`);
|
|
724
|
+
}
|
|
725
|
+
const baseRef = resolveBaseRef(args.base);
|
|
726
|
+
const commits = collectCommits(baseRef, args.limit);
|
|
727
|
+
if (commits.length === 0) {
|
|
728
|
+
success(`No commits found in range ${baseRef}..HEAD`);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (args.dryRun) {
|
|
733
|
+
warn('Dry run (no API calls)');
|
|
734
|
+
step(`Base: ${args.base}`);
|
|
735
|
+
step(`Branch: ${branch}`);
|
|
736
|
+
step(`Commits: ${commits.length}`);
|
|
737
|
+
step(`Limit: ${args.limit}`);
|
|
738
|
+
step(`Output: ${args.out}`);
|
|
739
|
+
step(`Issue: ${args.issue || '(not provided)'}`);
|
|
740
|
+
step(`Create PR: ${args.createPr ? 'yes' : 'no'}`);
|
|
741
|
+
step(`Mode: ${mode}`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (args.createPr && !checkGhCli()) {
|
|
746
|
+
fail('GitHub CLI (gh) is required for --create-pr but not found');
|
|
747
|
+
step('Install it: https://cli.github.com/');
|
|
748
|
+
step('Then authenticate: gh auth login');
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Safety checks before creating PR
|
|
753
|
+
if (args.createPr) {
|
|
754
|
+
// Run format as a last-minute verification step
|
|
755
|
+
step('Running npm run format before PR creation...');
|
|
756
|
+
try {
|
|
757
|
+
execSync('npm run format', { encoding: 'utf8', stdio: 'inherit' });
|
|
758
|
+
} catch (_error) {
|
|
759
|
+
fail('npm run format failed; fix formatting errors first.');
|
|
760
|
+
process.exit(1);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Run tests as an extra verification step
|
|
764
|
+
step('Running npm test before PR creation...');
|
|
765
|
+
try {
|
|
766
|
+
execSync('npm test', { encoding: 'utf8', stdio: 'inherit' });
|
|
767
|
+
} catch (_error) {
|
|
768
|
+
fail('npm test failed; fix test failures first.');
|
|
769
|
+
process.exit(1);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Run build as a final verification step
|
|
773
|
+
step('Running npm run build before PR creation...');
|
|
774
|
+
try {
|
|
775
|
+
execSync('npm run build', { encoding: 'utf8', stdio: 'inherit' });
|
|
776
|
+
} catch (_error) {
|
|
777
|
+
fail('npm run build failed; fix build errors first.');
|
|
778
|
+
process.exit(1);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Check for uncommitted changes after formatting
|
|
782
|
+
if (checkUncommittedChanges()) {
|
|
783
|
+
fail('You have uncommitted changes; please commit them first.');
|
|
784
|
+
step('Run: git add . && git commit -m "your message"');
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Check for existing open PR
|
|
789
|
+
const existingPr = checkExistingPr(args.base, branch);
|
|
790
|
+
if (existingPr) {
|
|
791
|
+
warn(`Found existing PR #${existingPr.number}: ${existingPr.title}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
step(`Collecting ${commits.length} commits from ${baseRef}..HEAD`);
|
|
796
|
+
|
|
797
|
+
const pass1Messages = buildPass1Messages(commits, branch, baseRef);
|
|
798
|
+
const pass1 = await createCompletionSafe(
|
|
799
|
+
groq,
|
|
800
|
+
pass1Messages,
|
|
801
|
+
DEFAULT_MODEL,
|
|
802
|
+
2048
|
|
803
|
+
);
|
|
804
|
+
const pass1Text = pass1?.choices?.[0]?.message?.content?.trim() || '';
|
|
805
|
+
success('Pass 1 complete (5Cs snapshot)');
|
|
806
|
+
|
|
807
|
+
const chunks = chunkCommits(commits, CHUNK_SIZE_CHARS);
|
|
808
|
+
step(`Pass 2 across ${chunks.length} chunk(s)`);
|
|
809
|
+
const pass2Outputs = [];
|
|
810
|
+
for (const chunk of chunks) {
|
|
811
|
+
const messages = buildPass2Messages(chunk);
|
|
812
|
+
// biome-ignore lint/nursery/noAwaitInLoop: sequential LLM calls to avoid rate limits and keep output order predictable
|
|
813
|
+
const completion = await createCompletionSafe(
|
|
814
|
+
groq,
|
|
815
|
+
messages,
|
|
816
|
+
DEFAULT_MODEL,
|
|
817
|
+
2048
|
|
818
|
+
);
|
|
819
|
+
const chunkText = completion?.choices?.[0]?.message?.content?.trim();
|
|
820
|
+
if (chunkText) {
|
|
821
|
+
pass2Outputs.push(chunkText);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
success('Pass 2 complete (per-commit condensation)');
|
|
825
|
+
|
|
826
|
+
const pass3Messages =
|
|
827
|
+
mode === 'release'
|
|
828
|
+
? buildReleaseMessages(
|
|
829
|
+
pass2Outputs,
|
|
830
|
+
branch,
|
|
831
|
+
baseRef,
|
|
832
|
+
args.issue,
|
|
833
|
+
pass1Text,
|
|
834
|
+
formatCommitTitles(commits, 40)
|
|
835
|
+
)
|
|
836
|
+
: buildPass3Messages(
|
|
837
|
+
pass2Outputs,
|
|
838
|
+
branch,
|
|
839
|
+
baseRef,
|
|
840
|
+
args.issue,
|
|
841
|
+
pass1Text,
|
|
842
|
+
formatCommitTitles(commits, 40)
|
|
843
|
+
);
|
|
844
|
+
const pass3 = await createCompletionSafe(
|
|
845
|
+
groq,
|
|
846
|
+
pass3Messages,
|
|
847
|
+
DEFAULT_MODEL,
|
|
848
|
+
2048
|
|
849
|
+
);
|
|
850
|
+
let finalSummary = pass3?.choices?.[0]?.message?.content?.trim() || '';
|
|
851
|
+
if (isUnknownSummary(finalSummary, mode)) {
|
|
852
|
+
warn('Pass 3 summary returned Unknown; retrying with fallback context...');
|
|
853
|
+
const retryMessages =
|
|
854
|
+
mode === 'release'
|
|
855
|
+
? buildReleaseMessages(
|
|
856
|
+
pass2Outputs.length > 0 ? pass2Outputs : ['(pass2 unavailable)'],
|
|
857
|
+
branch,
|
|
858
|
+
baseRef,
|
|
859
|
+
args.issue,
|
|
860
|
+
pass1Text || '(not provided)',
|
|
861
|
+
formatCommitTitles(commits, 80)
|
|
862
|
+
)
|
|
863
|
+
: buildPass3Messages(
|
|
864
|
+
pass2Outputs.length > 0 ? pass2Outputs : ['(pass2 unavailable)'],
|
|
865
|
+
branch,
|
|
866
|
+
baseRef,
|
|
867
|
+
args.issue,
|
|
868
|
+
pass1Text || '(not provided)',
|
|
869
|
+
formatCommitTitles(commits, 80)
|
|
870
|
+
);
|
|
871
|
+
const retry = await createCompletionSafe(
|
|
872
|
+
groq,
|
|
873
|
+
retryMessages,
|
|
874
|
+
DEFAULT_MODEL,
|
|
875
|
+
2048
|
|
876
|
+
);
|
|
877
|
+
finalSummary =
|
|
878
|
+
retry?.choices?.[0]?.message?.content?.trim() || finalSummary;
|
|
879
|
+
}
|
|
880
|
+
success('Pass 3 complete (PR synthesis)');
|
|
881
|
+
|
|
882
|
+
const prBlock = [
|
|
883
|
+
`PR Summary for ${branch} (base: ${baseRef})`,
|
|
884
|
+
'',
|
|
885
|
+
'--- PR Summary (paste into GitHub PR) ---',
|
|
886
|
+
finalSummary,
|
|
887
|
+
].join('\n');
|
|
888
|
+
|
|
889
|
+
const appendix = [
|
|
890
|
+
'',
|
|
891
|
+
'--- Pass 1 (5Cs snapshot) ---',
|
|
892
|
+
pass1Text,
|
|
893
|
+
'',
|
|
894
|
+
'--- Pass 2 (per-commit condensed) ---',
|
|
895
|
+
pass2Outputs.join('\n\n'),
|
|
896
|
+
].join('\n');
|
|
897
|
+
|
|
898
|
+
const fullOutput = `${prBlock}\n${appendix}`;
|
|
899
|
+
|
|
900
|
+
const resolvedOut = path.isAbsolute(args.out)
|
|
901
|
+
? args.out
|
|
902
|
+
: path.join(process.cwd(), args.out);
|
|
903
|
+
|
|
904
|
+
// Ensure output directory exists
|
|
905
|
+
const outDir = path.dirname(resolvedOut);
|
|
906
|
+
if (!fs.existsSync(outDir)) {
|
|
907
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
fs.writeFileSync(resolvedOut, fullOutput, 'utf8');
|
|
911
|
+
success(`PR summary written to ${resolvedOut}`);
|
|
912
|
+
|
|
913
|
+
// Write a slim, PR-ready version without appendices to avoid GH body limits
|
|
914
|
+
const finalOutPath = path.join(
|
|
915
|
+
path.dirname(resolvedOut),
|
|
916
|
+
`${path.basename(resolvedOut, path.extname(resolvedOut))}.final.md`
|
|
917
|
+
);
|
|
918
|
+
fs.writeFileSync(finalOutPath, prBlock, 'utf8');
|
|
919
|
+
success(`PR-ready (slim) summary written to ${finalOutPath}`);
|
|
920
|
+
|
|
921
|
+
// Also stash an autosave in tmp for debugging
|
|
922
|
+
const tmpPath = path.join(
|
|
923
|
+
os.tmpdir(),
|
|
924
|
+
`pr-summary-${Date.now().toString()}-${path.basename(resolvedOut)}`
|
|
925
|
+
);
|
|
926
|
+
fs.writeFileSync(tmpPath, fullOutput, 'utf8');
|
|
927
|
+
warn(`Backup copy saved to ${tmpPath}`);
|
|
928
|
+
|
|
929
|
+
// Create PR if requested
|
|
930
|
+
if (args.createPr) {
|
|
931
|
+
const prTitle = extractPrTitle(finalSummary, mode) || branch;
|
|
932
|
+
try {
|
|
933
|
+
const existingPr = checkExistingPr(args.base, branch);
|
|
934
|
+
if (existingPr) {
|
|
935
|
+
updatePrWithGh(existingPr.number, prTitle, finalSummary);
|
|
936
|
+
} else {
|
|
937
|
+
createPrWithGh(args.base, branch, prTitle, finalSummary);
|
|
938
|
+
}
|
|
939
|
+
} catch (_error) {
|
|
940
|
+
// Error already logged in createPrWithGh
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function runPrSummary(argv = process.argv.slice(2)) {
|
|
947
|
+
await main(argv);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (require.main === module) {
|
|
951
|
+
runPrSummary();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
module.exports = { runPrSummary };
|