@wipcomputer/wip-release 1.9.65 → 1.9.67

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 (3) hide show
  1. package/cli.js +30 -2
  2. package/core.mjs +155 -0
  3. package/package.json +1 -1
package/cli.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Release tool CLI. Bumps version, updates docs, publishes.
6
6
  */
7
7
 
8
- import { release, detectCurrentVersion } from './core.mjs';
8
+ import { release, detectCurrentVersion, collectMergedPRNotes } from './core.mjs';
9
9
 
10
10
  const args = process.argv.slice(2);
11
11
  const level = args.find(a => ['patch', 'minor', 'major'].includes(a));
@@ -31,6 +31,7 @@ let notesSource = (notes !== null && notes !== undefined && notes !== '') ? 'fla
31
31
  // Release notes priority (highest wins):
32
32
  // 1. --notes-file=path Explicit file path (always wins)
33
33
  // 2. RELEASE-NOTES-v{ver}.md In repo root (always wins over --notes flag)
34
+ // 2.5. Merged PR notes Auto-combined from git history (#237)
34
35
  // 3. ai/dev-updates/YYYY-MM-DD* Today's dev update (wins over --notes flag if longer)
35
36
  // 4. --notes="text" Flag fallback (only if nothing better exists)
36
37
  //
@@ -71,6 +72,31 @@ let notesSource = (notes !== null && notes !== undefined && notes !== '') ? 'fla
71
72
  } catch {}
72
73
  }
73
74
 
75
+ // 2.5. Auto-combine release notes from merged PRs since last tag (#237)
76
+ // Only runs when no single RELEASE-NOTES file was found on disk.
77
+ // Scans git merge history for RELEASE-NOTES files committed on PR branches.
78
+ if (level && notesSource !== 'file') {
79
+ try {
80
+ const { collectMergedPRNotes, detectCurrentVersion: dcv, bumpSemver: bs } = await import('./core.mjs');
81
+ const cwd = process.cwd();
82
+ const cv = dcv(cwd);
83
+ const nv = bs(cv, level);
84
+ const combined = collectMergedPRNotes(cwd, cv, nv);
85
+ if (combined) {
86
+ if (flagNotes && flagNotes !== combined.notes) {
87
+ console.log(` ! --notes flag ignored: merged PR notes take priority`);
88
+ }
89
+ notes = combined.notes;
90
+ notesSource = combined.notesSource;
91
+ if (combined.prCount > 1) {
92
+ console.log(` \u2713 Combined release notes from ${combined.prCount} merged PRs`);
93
+ } else {
94
+ console.log(` \u2713 Found release notes from merged PR`);
95
+ }
96
+ }
97
+ } catch {}
98
+ }
99
+
74
100
  // 3. Auto-detect dev update from ai/dev-updates/ (wins over --notes flag if longer)
75
101
  if (level && (!notes || (notesSource === 'flag' && notes.length < 200))) {
76
102
  try {
@@ -134,9 +160,11 @@ Flags:
134
160
  Release notes (REQUIRED, must be a file on disk):
135
161
  1. --notes-file=path Explicit file path
136
162
  2. RELEASE-NOTES-v{ver}.md In repo root (auto-detected)
137
- 3. ai/dev-updates/YYYY-MM-DD* Today's dev update (auto-detected)
163
+ 3. Merged PR notes Auto-combined from git history (#237)
164
+ 4. ai/dev-updates/YYYY-MM-DD* Today's dev update (auto-detected)
138
165
  The --notes flag is NOT accepted. Write a file. Commit it on your branch.
139
166
  The file shows up in the PR diff so it can be reviewed before merge.
167
+ When batching multiple PRs, each PR's RELEASE-NOTES are auto-combined.
140
168
 
141
169
  Skill publish to website:
142
170
  Add .publish-skill.json to repo root: { "name": "my-tool" }
package/core.mjs CHANGED
@@ -368,6 +368,161 @@ ${issueRefs || '- #XX (replace with actual issue numbers)'}
368
368
  return notesPath;
369
369
  }
370
370
 
371
+ /**
372
+ * Collect release notes from merged PRs since the last tag.
373
+ *
374
+ * When multiple PRs are batched into a single release, each PR may have
375
+ * committed its own RELEASE-NOTES-v*.md file. This function finds those
376
+ * notes in git history and combines them into one document.
377
+ *
378
+ * Steps:
379
+ * 1. git log v{prev}..HEAD --merges --oneline to find merge commits
380
+ * 2. Extract PR number from "Merge pull request #XX from ..."
381
+ * 3. Check each merge commit's diff for RELEASE-NOTES*.md files
382
+ * 4. Read content via git show {sha}:{path}
383
+ * 5. Combine into a single document (newest first)
384
+ *
385
+ * Returns { notes, notesSource, prCount } or null if nothing found.
386
+ */
387
+ export function collectMergedPRNotes(repoPath, currentVersion, newVersion) {
388
+ let lastTag;
389
+ try {
390
+ lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'],
391
+ { cwd: repoPath, encoding: 'utf8' }).trim();
392
+ } catch {
393
+ return null; // No tags yet
394
+ }
395
+
396
+ // Find merge commits since last tag
397
+ let mergeLog;
398
+ try {
399
+ mergeLog = execFileSync('git', [
400
+ 'log', `${lastTag}..HEAD`, '--merges', '--oneline'
401
+ ], { cwd: repoPath, encoding: 'utf8' }).trim();
402
+ } catch {
403
+ return null;
404
+ }
405
+
406
+ if (!mergeLog) return null;
407
+
408
+ const mergeLines = mergeLog.split('\n').filter(Boolean);
409
+ const prNotes = [];
410
+
411
+ for (const line of mergeLines) {
412
+ // Format: "abc1234 Merge pull request #XX from org/branch"
413
+ const prMatch = line.match(/^([a-f0-9]+)\s+Merge pull request #(\d+)\s+from\s+(.+)$/);
414
+ if (!prMatch) continue;
415
+
416
+ const [, shortHash, prNum, branchRef] = prMatch;
417
+
418
+ // Get the full hash for this merge commit
419
+ let fullHash;
420
+ try {
421
+ fullHash = execFileSync('git', ['rev-parse', shortHash],
422
+ { cwd: repoPath, encoding: 'utf8' }).trim();
423
+ } catch {
424
+ continue;
425
+ }
426
+
427
+ // List files changed in this merge commit.
428
+ // For merge commits, diff against first parent to see what the PR brought in.
429
+ // Plain diff-tree on a merge commit shows nothing without -m or -c.
430
+ let changedFiles;
431
+ try {
432
+ changedFiles = execFileSync('git', [
433
+ 'diff', '--name-only', `${fullHash}^1`, fullHash
434
+ ], { cwd: repoPath, encoding: 'utf8' }).trim();
435
+ } catch {
436
+ continue;
437
+ }
438
+
439
+ // Look for RELEASE-NOTES*.md files
440
+ const noteFiles = changedFiles.split('\n')
441
+ .filter(f => /^RELEASE-NOTES.*\.md$/i.test(f.trim()));
442
+
443
+ if (noteFiles.length === 0) continue;
444
+
445
+ // Read the content of each release notes file from that commit
446
+ for (const noteFile of noteFiles) {
447
+ try {
448
+ const content = execFileSync('git', [
449
+ 'show', `${fullHash}:${noteFile.trim()}`
450
+ ], { cwd: repoPath, encoding: 'utf8' }).trim();
451
+
452
+ if (content) {
453
+ prNotes.push({
454
+ prNum,
455
+ branch: branchRef,
456
+ content,
457
+ hash: shortHash,
458
+ });
459
+ }
460
+ } catch {
461
+ // File might have been deleted in the merge. Skip.
462
+ }
463
+ }
464
+ }
465
+
466
+ if (prNotes.length === 0) return null;
467
+
468
+ // If only one PR had notes, return it directly (no wrapping)
469
+ if (prNotes.length === 1) {
470
+ return {
471
+ notes: prNotes[0].content,
472
+ notesSource: 'file',
473
+ prCount: 1,
474
+ };
475
+ }
476
+
477
+ // Multiple PRs: combine into one document (newest first, already in log order)
478
+ const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
479
+ const name = pkg.name?.replace(/^@[^/]+\//, '') || basename(repoPath);
480
+
481
+ const sections = [];
482
+ sections.push(`# Release Notes: ${name} v${newVersion}`);
483
+ sections.push('');
484
+ sections.push(`This release combines ${prNotes.length} merged pull requests.`);
485
+ sections.push('');
486
+
487
+ // Collect all issue refs for a combined summary
488
+ const allIssueRefs = new Set();
489
+
490
+ for (const pr of prNotes) {
491
+ sections.push(`---`);
492
+ sections.push('');
493
+ sections.push(`### PR #${pr.prNum}`);
494
+ sections.push('');
495
+
496
+ // Strip the top-level heading from individual notes to avoid duplicate titles
497
+ let body = pr.content;
498
+ body = body.replace(/^#\s+.*\n+/, '');
499
+ sections.push(body);
500
+ sections.push('');
501
+
502
+ // Collect issue references
503
+ const refs = body.match(/#\d+/g) || [];
504
+ for (const ref of refs) allIssueRefs.add(ref);
505
+ }
506
+
507
+ // Add combined issue references at the end
508
+ if (allIssueRefs.size > 0) {
509
+ sections.push('---');
510
+ sections.push('');
511
+ sections.push('## All issues referenced');
512
+ sections.push('');
513
+ for (const ref of allIssueRefs) {
514
+ sections.push(`- ${ref}`);
515
+ }
516
+ sections.push('');
517
+ }
518
+
519
+ return {
520
+ notes: sections.join('\n'),
521
+ notesSource: 'file',
522
+ prCount: prNotes.length,
523
+ };
524
+ }
525
+
371
526
  /**
372
527
  * Check if a file was modified in commits since the last git tag.
373
528
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.9.65",
3
+ "version": "1.9.67",
4
4
  "type": "module",
5
5
  "description": "One-command release pipeline. Bumps version, updates changelog + SKILL.md, publishes to npm + GitHub.",
6
6
  "main": "core.mjs",