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.
@@ -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 };