@oorabona/release-it-preset 1.2.0 → 1.4.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/bin/cli.js CHANGED
@@ -46,6 +46,7 @@ const RELEASE_CONFIGS = {
46
46
  const UTILITY_COMMANDS = {
47
47
  init: 'init-project',
48
48
  update: 'populate-unreleased-changelog',
49
+ annotate: 'annotate-changelog',
49
50
  validate: 'validate-release',
50
51
  check: 'check-config',
51
52
  doctor: 'doctor',
@@ -76,6 +77,7 @@ Release Commands:
76
77
  Utility Commands:
77
78
  init [--yes] Initialize project (create CHANGELOG.md, .release-it.json, etc.)
78
79
  update Update [Unreleased] section from commits
80
+ annotate Enrich [Unreleased] entries from merged PR changelog blocks
79
81
  validate [--allow-dirty] Validate project is ready for release
80
82
  check Display configuration and project status
81
83
  doctor Run diagnostic checklist and show readiness score
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Annotate [Unreleased] entries from typed pull request changelog blocks.
4
+ */
5
+ import { execSync } from 'node:child_process';
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { ChangelogError } from './lib/errors.js';
8
+ import { getGitHubRepoUrl } from './lib/git-utils.js';
9
+ import { runScript } from './lib/run-script.js';
10
+ const STANDARD_SECTION_ORDER = [
11
+ '### Added',
12
+ '### Changed',
13
+ '### Deprecated',
14
+ '### Removed',
15
+ '### Fixed',
16
+ '### Security',
17
+ '### ⚠️ BREAKING CHANGES',
18
+ ];
19
+ const DEFAULT_SECTION = '### Changed';
20
+ const BREAKING_SECTION = '### ⚠️ BREAKING CHANGES';
21
+ const CHANGELOG_TYPE_TO_SECTION = {
22
+ added: '### Added',
23
+ changed: '### Changed',
24
+ deprecated: '### Deprecated',
25
+ removed: '### Removed',
26
+ fixed: '### Fixed',
27
+ security: '### Security',
28
+ };
29
+ const GH_JSON_OPTIONS = { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
30
+ export function extractUnreleasedBlock(changelog, changelogPath = 'CHANGELOG.md') {
31
+ const headerRegex = /^## \[Unreleased\][^\n]*(?:\r?\n|$)/m;
32
+ const headerMatch = headerRegex.exec(changelog);
33
+ if (!headerMatch) {
34
+ throw new ChangelogError(`No [Unreleased] section found in ${changelogPath}. Run release-it-preset update first.`);
35
+ }
36
+ const bodyStart = headerMatch.index + headerMatch[0].length;
37
+ const rest = changelog.slice(bodyStart);
38
+ const nextSectionMatch = /^## \[/m.exec(rest);
39
+ const bodyEnd = nextSectionMatch ? bodyStart + nextSectionMatch.index : changelog.length;
40
+ return {
41
+ prefix: changelog.slice(0, bodyStart),
42
+ body: changelog.slice(bodyStart, bodyEnd),
43
+ suffix: changelog.slice(bodyEnd),
44
+ };
45
+ }
46
+ export function normalizeSectionHeading(rawHeading) {
47
+ const stripped = rawHeading.replace(/^#+\s*/, '').trim();
48
+ if (/breaking[-\s]+changes?/i.test(stripped) || /breaking/i.test(stripped)) {
49
+ return BREAKING_SECTION;
50
+ }
51
+ const standard = STANDARD_SECTION_ORDER.find(heading => heading.replace(/^###\s*/, '').toLowerCase() === stripped.toLowerCase());
52
+ if (standard) {
53
+ return standard;
54
+ }
55
+ return stripped ? `### ${stripped}` : DEFAULT_SECTION;
56
+ }
57
+ export function extractCommitShas(value) {
58
+ // Only the generated TRAILING reference identifies the bullet's commit.
59
+ // Annotated bullets import author text verbatim, so a /commit/ URL or a
60
+ // hex word inside the prose must never re-key the bullet to another
61
+ // commit (and through it, another PR) on the next run.
62
+ const trailingLink = value.match(/\(\[([0-9a-f]{7,40})\]\([^)]*\/commit\/[0-9a-f]{7,40}\)\)\s*$/i);
63
+ if (trailingLink) {
64
+ return [trailingLink[1].toLowerCase()];
65
+ }
66
+ const bareReference = value.match(/\(([0-9a-f]{7,40})\)\s*$/i);
67
+ if (bareReference) {
68
+ return [bareReference[1].toLowerCase()];
69
+ }
70
+ return [];
71
+ }
72
+ export function extractPrNumber(value) {
73
+ // The generated trailing reference always wins: annotated bullets carry
74
+ // author text verbatim, and an embedded "PR #72" or /pull/72 in that prose
75
+ // must not out-rank the (#N) suffix appended by annotate itself.
76
+ const squashSuffix = value.match(/\(#(\d{1,10})\)(?:\s+\(\[[0-9a-f]{7,40}\]\([^)]*\)\))?\s*$/i);
77
+ if (squashSuffix) {
78
+ return Number.parseInt(squashSuffix[1], 10);
79
+ }
80
+ const explicitPr = value.match(/\bPR\s+#(\d{1,10})\b/i);
81
+ if (explicitPr) {
82
+ return Number.parseInt(explicitPr[1], 10);
83
+ }
84
+ const pullUrl = value.match(/\/pull\/(\d{1,10})\b/i);
85
+ if (pullUrl) {
86
+ return Number.parseInt(pullUrl[1], 10);
87
+ }
88
+ return null;
89
+ }
90
+ export function choosePrimarySha(values) {
91
+ for (const value of values) {
92
+ if (typeof value === 'string' && /^[0-9a-f]{7,40}$/i.test(value)) {
93
+ return value.toLowerCase();
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ export function parseUnreleasedEntries(body) {
99
+ const entries = [];
100
+ const sections = [{ heading: null, items: [] }];
101
+ let order = 0;
102
+ const currentSection = () => sections[sections.length - 1];
103
+ const sectionHeadingOf = (section) => section.heading ?? DEFAULT_SECTION;
104
+ for (const line of body.split(/\r?\n/)) {
105
+ const headingMatch = line.match(/^###\s+(.+?)\s*$/);
106
+ if (headingMatch) {
107
+ sections.push({ heading: normalizeSectionHeading(line), items: [] });
108
+ continue;
109
+ }
110
+ const bulletMatch = line.match(/^-\s+(.+?)\s*$/);
111
+ if (bulletMatch) {
112
+ const text = bulletMatch[1].trim();
113
+ const entry = {
114
+ section: sectionHeadingOf(currentSection()),
115
+ text,
116
+ rawLine: line.replace(/\s+$/, ''),
117
+ shaList: extractCommitShas(text),
118
+ prNumber: extractPrNumber(text),
119
+ order,
120
+ };
121
+ entries.push(entry);
122
+ currentSection().items.push({ kind: 'entry', entry });
123
+ order += 1;
124
+ continue;
125
+ }
126
+ // Indented continuation of a wrapped bullet: it belongs to that bullet
127
+ // and must travel with it (or stay with it) — never hoisted as a note.
128
+ const items = currentSection().items;
129
+ const lastItem = items[items.length - 1];
130
+ if (/^\s+\S/.test(line) && lastItem?.kind === 'entry') {
131
+ lastItem.entry.rawLine += `\n${line.replace(/\s+$/, '')}`;
132
+ lastItem.entry.text += ` ${line.trim()}`;
133
+ lastItem.entry.shaList = extractCommitShas(lastItem.entry.text);
134
+ lastItem.entry.prNumber = extractPrNumber(lastItem.entry.text);
135
+ continue;
136
+ }
137
+ if (line.trim() && line.trim() !== 'No changes yet.') {
138
+ currentSection().items.push({ kind: 'note', line: line.replace(/\s+$/, '') });
139
+ }
140
+ }
141
+ return { entries, sections };
142
+ }
143
+ export function groupEntriesForLookup(entries) {
144
+ const byKey = new Map();
145
+ const passthrough = [];
146
+ for (const entry of entries) {
147
+ if (entry.section === BREAKING_SECTION) {
148
+ passthrough.push(entry);
149
+ continue;
150
+ }
151
+ // The commit sha is the authoritative key: the commits/<sha>/pulls
152
+ // endpoint returns the true merged PR, while a textual (#N) may be an
153
+ // issue reference. Only sha-less entries fall back to the PR number.
154
+ if (entry.prNumber !== null && entry.shaList.length === 0) {
155
+ const key = `pr:${entry.prNumber}`;
156
+ const existing = byKey.get(key);
157
+ if (existing) {
158
+ existing.entries.push(entry);
159
+ existing.primarySha = choosePrimarySha([existing.primarySha, ...entry.shaList]);
160
+ }
161
+ else {
162
+ byKey.set(key, {
163
+ key,
164
+ kind: 'pr',
165
+ ref: String(entry.prNumber),
166
+ entries: [entry],
167
+ primarySha: choosePrimarySha(entry.shaList),
168
+ });
169
+ }
170
+ continue;
171
+ }
172
+ const primarySha = choosePrimarySha(entry.shaList);
173
+ if (!primarySha) {
174
+ passthrough.push(entry);
175
+ continue;
176
+ }
177
+ const key = `sha:${primarySha}`;
178
+ const existing = byKey.get(key);
179
+ if (existing) {
180
+ existing.entries.push(entry);
181
+ existing.primarySha = choosePrimarySha([existing.primarySha, ...entry.shaList]);
182
+ }
183
+ else {
184
+ byKey.set(key, {
185
+ key,
186
+ kind: 'sha',
187
+ ref: primarySha,
188
+ entries: [entry],
189
+ primarySha,
190
+ });
191
+ }
192
+ }
193
+ return { groups: [...byKey.values()], passthrough };
194
+ }
195
+ function errorText(error) {
196
+ if (error instanceof Error) {
197
+ const stderr = error.stderr;
198
+ const stdout = error.stdout;
199
+ const pieces = [
200
+ error.message,
201
+ Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr,
202
+ Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout,
203
+ ].filter((piece) => typeof piece === 'string' && piece.trim().length > 0);
204
+ return pieces.join('\n');
205
+ }
206
+ return String(error);
207
+ }
208
+ function parseGhJson(command, output) {
209
+ try {
210
+ return JSON.parse(output);
211
+ }
212
+ catch (error) {
213
+ throw new ChangelogError(`GitHub CLI command returned invalid JSON: ${command}\n${errorText(error)}`);
214
+ }
215
+ }
216
+ function execGhJson(command, deps) {
217
+ try {
218
+ const output = deps.execSync(command, GH_JSON_OPTIONS);
219
+ return parseGhJson(command, output);
220
+ }
221
+ catch (error) {
222
+ if (error instanceof ChangelogError) {
223
+ throw error;
224
+ }
225
+ throw new ChangelogError(`GitHub CLI command failed: ${command}\n${errorText(error)}`);
226
+ }
227
+ }
228
+ function validatePrInfo(value, command) {
229
+ if (!value || typeof value !== 'object') {
230
+ throw new ChangelogError(`GitHub CLI command returned an invalid pull request response: ${command}`);
231
+ }
232
+ const maybe = value;
233
+ if (typeof maybe.number !== 'number' || !Number.isInteger(maybe.number)) {
234
+ throw new ChangelogError(`GitHub CLI command returned a pull request without a number: ${command}`);
235
+ }
236
+ return {
237
+ number: maybe.number,
238
+ body: typeof maybe.body === 'string' ? maybe.body : null,
239
+ merged_at: typeof maybe.merged_at === 'string' ? maybe.merged_at : null,
240
+ };
241
+ }
242
+ export function fetchPullRequestByNumber(prNumber, ownerRepo, deps) {
243
+ // --repo pins the lookup to the remote-derived repository: without it gh
244
+ // infers the repo from cwd/GH_REPO and forks or CI checkouts can answer
245
+ // for the wrong repository.
246
+ const command = `gh pr view ${prNumber} --repo ${ownerRepo} --json number,body,mergedAt`;
247
+ try {
248
+ const output = deps.execSync(command, GH_JSON_OPTIONS);
249
+ const parsed = parseGhJson(command, output);
250
+ // An open or closed-unmerged PR is not part of release history — its
251
+ // body must never regenerate changelog entries.
252
+ if (typeof parsed?.mergedAt !== 'string') {
253
+ return null;
254
+ }
255
+ return validatePrInfo(parsed, command);
256
+ }
257
+ catch (error) {
258
+ if (error instanceof ChangelogError) {
259
+ throw error;
260
+ }
261
+ // A (#NNN) reference in bullet text may point at an issue, not a PR —
262
+ // that is the author's text, not an annotation candidate. Only this
263
+ // not-a-PR shape is benign; auth/network/API failures stay fatal.
264
+ if (/could not resolve to a PullRequest|no pull requests? found|not found/i.test(errorText(error))) {
265
+ return null;
266
+ }
267
+ throw new ChangelogError(`GitHub CLI command failed: ${command}\n${errorText(error)}`);
268
+ }
269
+ }
270
+ export function fetchPullRequestBySha(sha, ownerRepo, deps) {
271
+ const command = `gh api repos/${ownerRepo}/commits/${sha}/pulls --jq '[.[] | {number: .number, body: .body, merged_at: .merged_at}]'`;
272
+ let parsed;
273
+ try {
274
+ parsed = execGhJson(command, deps);
275
+ }
276
+ catch (error) {
277
+ // A sha that GitHub does not know (rebased away, or a hex-looking word
278
+ // that slipped through extraction) is the author's text, not annotate's
279
+ // business — benign passthrough. Auth/network failures stay fatal.
280
+ if (/not found|HTTP 404/i.test(errorText(error))) {
281
+ return null;
282
+ }
283
+ throw error;
284
+ }
285
+ if (!Array.isArray(parsed)) {
286
+ throw new ChangelogError(`GitHub CLI command returned an invalid pulls response: ${command}`);
287
+ }
288
+ const merged = parsed.find(item => item && typeof item === 'object' && typeof item.merged_at === 'string');
289
+ return merged ? validatePrInfo(merged, command) : null;
290
+ }
291
+ function ownerRepoFromUrl(repoUrl) {
292
+ const match = repoUrl.trim().replace(/\/$/, '').match(/^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)$/);
293
+ if (!match) {
294
+ return null;
295
+ }
296
+ const ownerRepo = match[1].replace(/\.git$/, '');
297
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(ownerRepo) ? ownerRepo : null;
298
+ }
299
+ function getRequiredGitHubContext(deps) {
300
+ const repoUrl = getGitHubRepoUrl({
301
+ execSync: deps.execSync,
302
+ getEnv: deps.getEnv,
303
+ warn: deps.warn,
304
+ }).replace(/\/$/, '');
305
+ const ownerRepo = ownerRepoFromUrl(repoUrl);
306
+ if (!repoUrl || !ownerRepo) {
307
+ throw new ChangelogError('Could not determine GitHub repository. Set GITHUB_REPOSITORY or configure a GitHub origin remote before running annotate.');
308
+ }
309
+ return { repoUrl, ownerRepo };
310
+ }
311
+ export function resolvePullRequestGroups(groups, ownerRepo, deps) {
312
+ const byPrNumber = new Map();
313
+ const unresolved = [];
314
+ for (const group of groups) {
315
+ const pr = group.kind === 'pr'
316
+ ? fetchPullRequestByNumber(Number.parseInt(group.ref, 10), ownerRepo, deps)
317
+ : fetchPullRequestBySha(group.ref, ownerRepo, deps);
318
+ if (!pr) {
319
+ unresolved.push(...group.entries);
320
+ continue;
321
+ }
322
+ const existing = byPrNumber.get(pr.number);
323
+ if (existing) {
324
+ existing.entries.push(...group.entries);
325
+ existing.primarySha = choosePrimarySha([
326
+ existing.primarySha,
327
+ group.primarySha,
328
+ ...group.entries.flatMap(entry => entry.shaList),
329
+ ]);
330
+ }
331
+ else {
332
+ byPrNumber.set(pr.number, {
333
+ pr,
334
+ entries: [...group.entries],
335
+ primarySha: choosePrimarySha([group.primarySha, ...group.entries.flatMap(entry => entry.shaList)]),
336
+ });
337
+ }
338
+ }
339
+ return {
340
+ resolved: [...byPrNumber.values()].sort((a, b) => {
341
+ const aOrder = Math.min(...a.entries.map(entry => entry.order));
342
+ const bOrder = Math.min(...b.entries.map(entry => entry.order));
343
+ return aOrder - bOrder;
344
+ }),
345
+ unresolved,
346
+ };
347
+ }
348
+ function warnForPr(prNumber, deps, message) {
349
+ deps.warn?.(`Ignoring changelog block in PR #${prNumber}: ${message}`);
350
+ }
351
+ function normalizeBlockText(value) {
352
+ return value
353
+ .replace(/\r\n/g, '\n')
354
+ .replace(/\r/g, '\n')
355
+ .trim()
356
+ .replace(/\s*\n\s*/g, ' ')
357
+ .replace(/[ \t]+/g, ' ')
358
+ .trim();
359
+ }
360
+ // Replaces fenced code regions (``` / ~~~) with spaces of identical length so
361
+ // marker scanning never sees them while every index still maps back to the
362
+ // original text. Grammar examples in PR bodies stay inert this way.
363
+ function maskFencedRegions(value) {
364
+ let openFence = null;
365
+ return value
366
+ .split('\n')
367
+ .map(line => {
368
+ // Any indentation is accepted on purpose: fences nested under list
369
+ // items are indented beyond CommonMark's top-level 3-space cap, and a
370
+ // safety mask must over-mask rather than import a fenced example.
371
+ const delimiter = line.match(/^\s*(`{3,}|~{3,})/);
372
+ if (openFence === null) {
373
+ if (delimiter) {
374
+ openFence = { char: delimiter[1][0], length: delimiter[1].length };
375
+ return ' '.repeat(line.length);
376
+ }
377
+ return line;
378
+ }
379
+ // CommonMark: the closing fence must use the same character and be at
380
+ // least as long as the opener — a ```` fence is NOT closed by an inner
381
+ // ``` example, which is precisely how docs show fenced code.
382
+ if (delimiter &&
383
+ delimiter[1][0] === openFence.char &&
384
+ delimiter[1].length >= openFence.length &&
385
+ /^\s*(`{3,}|~{3,})\s*$/.test(line)) {
386
+ openFence = null;
387
+ }
388
+ return ' '.repeat(line.length);
389
+ })
390
+ .join('\n');
391
+ }
392
+ export function extractStructuredChangelogNotes(body, options = {}) {
393
+ if (!body) {
394
+ return [];
395
+ }
396
+ const notes = [];
397
+ const normalizedBody = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
398
+ // Markers are matched against the masked text (fenced regions blanked, all
399
+ // indices preserved); the block TEXT is sliced from the real body.
400
+ const scanBody = maskFencedRegions(normalizedBody);
401
+ const openMarker = /<!--\s*changelog\s*:\s*([a-z]+)\s*-->/gi;
402
+ const closeMarker = /<!--\s*\/changelog\s*-->/i;
403
+ const prNumber = options.prNumber ?? 0;
404
+ for (const match of scanBody.matchAll(openMarker)) {
405
+ const type = match[1].toLowerCase();
406
+ const contentStart = match.index + match[0].length;
407
+ const scanRest = scanBody.slice(contentStart);
408
+ const closeMatch = closeMarker.exec(scanRest);
409
+ if (!closeMatch) {
410
+ warnForPr(prNumber, options, 'unclosed marker');
411
+ continue;
412
+ }
413
+ const section = CHANGELOG_TYPE_TO_SECTION[type];
414
+ if (!section) {
415
+ warnForPr(prNumber, options, `unknown changelog type "${type}"`);
416
+ continue;
417
+ }
418
+ const rawContent = normalizedBody.slice(contentStart, contentStart + closeMatch.index);
419
+ // A second open marker before the close means overlapping blocks: the
420
+ // outer region is malformed and must never leak a raw marker (or
421
+ // duplicated text) into the changelog. The inner block, if well-formed,
422
+ // is still picked up by its own matchAll iteration. (Checked on the
423
+ // masked region too, so fenced examples inside a block stay inert.)
424
+ if (/<!--\s*changelog\s*:/i.test(scanRest.slice(0, closeMatch.index))) {
425
+ warnForPr(prNumber, options, `nested changelog marker inside ${type} block`);
426
+ continue;
427
+ }
428
+ const text = normalizeBlockText(rawContent);
429
+ if (!text) {
430
+ warnForPr(prNumber, options, `empty ${type} block`);
431
+ continue;
432
+ }
433
+ notes.push({ section, text });
434
+ }
435
+ return notes;
436
+ }
437
+ // Only the CURRENT PR's own reference is stripped before re-appending it:
438
+ // a foreign trailing reference like "fixes issue (#123)" is the author's
439
+ // text and must survive verbatim.
440
+ function stripAnnotationReferences(text, prNumber) {
441
+ const ownReference = new RegExp(`\\s+\\(#${prNumber}\\)(?:\\s+\\(\\[[0-9a-f]{7,40}\\]\\([^)]+/commit/[0-9a-f]{7,40}\\)\\))?$`, 'i');
442
+ return text
443
+ .replace(ownReference, '')
444
+ .replace(/\s+\(\[[0-9a-f]{7,40}\]\([^)]+\/commit\/[0-9a-f]{7,40}\)\)$/i, '')
445
+ .trim();
446
+ }
447
+ function formatCommitReference(primarySha, repoUrl) {
448
+ if (!primarySha) {
449
+ return '';
450
+ }
451
+ const shortSha = primarySha.substring(0, 7);
452
+ return ` ([${shortSha}](${repoUrl}/commit/${shortSha}))`;
453
+ }
454
+ function formatNote(note, primarySha, repoUrl, prNumber) {
455
+ const text = stripAnnotationReferences(note.text, prNumber);
456
+ const reference = ` (#${prNumber})${formatCommitReference(primarySha, repoUrl)}`;
457
+ return {
458
+ section: note.section,
459
+ text: `${text}${reference}`,
460
+ rawLine: `- ${text}${reference}`,
461
+ shaList: primarySha ? [primarySha] : [],
462
+ prNumber,
463
+ order: Number.MAX_SAFE_INTEGER,
464
+ };
465
+ }
466
+ function notesForResolvedGroup(group, deps) {
467
+ return extractStructuredChangelogNotes(group.pr.body, { prNumber: group.pr.number, warn: deps.warn });
468
+ }
469
+ export function renderAnnotatedBody(parsed, resolvedGroups, repoUrl, deps) {
470
+ const removedEntries = new Set();
471
+ const additionsBySection = new Map();
472
+ let annotatedGroups = 0;
473
+ for (const group of resolvedGroups) {
474
+ const notes = notesForResolvedGroup(group, deps);
475
+ if (notes.length === 0) {
476
+ continue;
477
+ }
478
+ annotatedGroups += 1;
479
+ // The PR body is mutable post-merge: name every source PR so the
480
+ // maintainer knows exactly what to review in the resulting diff.
481
+ deps.log(`- PR #${group.pr.number}: ${notes.length} changelog block(s) applied`);
482
+ for (const entry of group.entries) {
483
+ removedEntries.add(entry);
484
+ }
485
+ const primarySha = choosePrimarySha([group.primarySha, ...group.entries.flatMap(entry => entry.shaList)]);
486
+ for (const note of notes) {
487
+ const formatted = formatNote(note, primarySha, repoUrl, group.pr.number);
488
+ const list = additionsBySection.get(formatted.section) ?? [];
489
+ list.push(formatted);
490
+ additionsBySection.set(formatted.section, list);
491
+ }
492
+ }
493
+ // Nothing was annotated: re-rendering would only restructure the existing
494
+ // body without adding any information — the caller must leave the file
495
+ // untouched.
496
+ if (annotatedGroups === 0) {
497
+ return null;
498
+ }
499
+ // Everything that is not replaced is preserved in place: sections keep
500
+ // their original order and internal layout (notes, wrapped bullets);
501
+ // replacement bullets append at the end of their target section; sections
502
+ // that only exist in the additions are appended in canonical order.
503
+ const lines = [];
504
+ const emittedHeadings = new Set();
505
+ const emitSection = (heading, items, additions) => {
506
+ const keptItems = items.filter(item => item.kind === 'note' || !removedEntries.has(item.entry));
507
+ if (keptItems.length === 0 && additions.length === 0) {
508
+ return;
509
+ }
510
+ if (heading !== null) {
511
+ lines.push(heading);
512
+ }
513
+ for (const item of keptItems) {
514
+ lines.push(item.kind === 'note' ? item.line : item.entry.rawLine);
515
+ }
516
+ for (const added of additions) {
517
+ lines.push(added.rawLine);
518
+ }
519
+ lines.push('');
520
+ };
521
+ for (const section of parsed.sections) {
522
+ const additions = section.heading !== null ? (additionsBySection.get(section.heading) ?? []) : [];
523
+ if (section.heading !== null) {
524
+ emittedHeadings.add(section.heading);
525
+ }
526
+ emitSection(section.heading, section.items, additions);
527
+ }
528
+ const pendingHeadings = [...additionsBySection.keys()].filter(heading => !emittedHeadings.has(heading));
529
+ const orderedPending = [
530
+ ...STANDARD_SECTION_ORDER.filter(heading => pendingHeadings.includes(heading)),
531
+ ...pendingHeadings.filter(heading => !STANDARD_SECTION_ORDER.includes(heading)),
532
+ ];
533
+ for (const heading of orderedPending) {
534
+ emitSection(heading, [], additionsBySection.get(heading) ?? []);
535
+ }
536
+ return { body: `\n${lines.join('\n').trim()}\n\n`, appliedPrCount: annotatedGroups };
537
+ }
538
+ function readChangelog(changelogPath, deps) {
539
+ try {
540
+ return deps.readFileSync(changelogPath, 'utf8');
541
+ }
542
+ catch (error) {
543
+ throw new ChangelogError(`Could not read ${changelogPath}. Run release-it-preset update first.\n${errorText(error)}`);
544
+ }
545
+ }
546
+ export function annotateChangelog(deps) {
547
+ const changelogPath = deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md';
548
+ deps.log('Annotating [Unreleased] section...');
549
+ const changelog = readChangelog(changelogPath, deps);
550
+ const block = extractUnreleasedBlock(changelog, changelogPath);
551
+ const parsed = parseUnreleasedEntries(block.body);
552
+ if (parsed.entries.length === 0) {
553
+ deps.log('No changelog entries found in [Unreleased]');
554
+ return;
555
+ }
556
+ const { groups } = groupEntriesForLookup(parsed.entries);
557
+ if (groups.length === 0) {
558
+ deps.log('No PR or commit references found in [Unreleased]');
559
+ return;
560
+ }
561
+ const { repoUrl, ownerRepo } = getRequiredGitHubContext(deps);
562
+ const resolved = resolvePullRequestGroups(groups, ownerRepo, deps);
563
+ const rendered = renderAnnotatedBody(parsed, resolved.resolved, repoUrl, deps);
564
+ if (rendered === null) {
565
+ deps.log('No changelog blocks found in the resolved pull requests — nothing to annotate');
566
+ return;
567
+ }
568
+ deps.writeFileSync(changelogPath, `${block.prefix}${rendered.body}${block.suffix}`);
569
+ deps.log(`Annotated ${rendered.appliedPrCount} pull request(s)`);
570
+ }
571
+ /* c8 ignore start */
572
+ if (import.meta.url === `file://${process.argv[1]}`) {
573
+ void runScript({ error: console.error, exit: process.exit }, () => annotateChangelog({
574
+ execSync,
575
+ readFileSync,
576
+ writeFileSync,
577
+ getEnv: (key) => process.env[key],
578
+ log: console.log,
579
+ warn: console.warn,
580
+ }));
581
+ }
582
+ /* c8 ignore end */
@@ -11,7 +11,8 @@
11
11
  * can consume them without relying on continue-on-error semantics.
12
12
  */
13
13
  import { execSync } from 'node:child_process';
14
- import { appendFileSync } from 'node:fs';
14
+ import { appendFileSync, readFileSync } from 'node:fs';
15
+ import { extractStructuredChangelogNotes } from './annotate-changelog.js';
15
16
  import { STRICT_CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
16
17
  import { runScript } from './lib/run-script.js';
17
18
  const SKIP_CHANGELOG_REGEX = /\[skip-changelog]/i;
@@ -77,6 +78,33 @@ export function evaluateChangelogStatus(changedFiles, changelogPath, commits) {
77
78
  }
78
79
  return { status: 'missing', skipMarker };
79
80
  }
81
+ export function evaluateChangelogBlockStatus(deps) {
82
+ const eventPath = deps.getEnv('GITHUB_EVENT_PATH');
83
+ if (!eventPath) {
84
+ return 'unknown';
85
+ }
86
+ try {
87
+ const event = JSON.parse(readFileSync(eventPath, 'utf8'));
88
+ if (!event || typeof event !== 'object') {
89
+ return 'unknown';
90
+ }
91
+ const pullRequest = event.pull_request;
92
+ if (!pullRequest || typeof pullRequest !== 'object') {
93
+ return 'unknown';
94
+ }
95
+ const body = pullRequest.body;
96
+ if (typeof body !== 'string') {
97
+ return 'unknown';
98
+ }
99
+ // Same parser as annotate (typed markers, fence masking): a fenced
100
+ // documentation example must not count as present here while annotate
101
+ // would ignore it.
102
+ return extractStructuredChangelogNotes(body).length > 0 ? 'present' : 'absent';
103
+ }
104
+ catch {
105
+ return 'unknown';
106
+ }
107
+ }
80
108
  export function getDiffRange(baseRef, headRef) {
81
109
  if (!baseRef) {
82
110
  return headRef;
@@ -95,6 +123,7 @@ export function runPrCheck(args, deps) {
95
123
  const commits = splitList(commitsOutput);
96
124
  const changelogPath = deps.getEnv('CHANGELOG_FILE') ?? 'CHANGELOG.md';
97
125
  const changelogEvaluation = evaluateChangelogStatus(changedFiles, changelogPath, commits);
126
+ const changelogBlock = evaluateChangelogBlockStatus(deps);
98
127
  const conventional = hasConventionalCommits(commits);
99
128
  return {
100
129
  baseRef,
@@ -102,6 +131,7 @@ export function runPrCheck(args, deps) {
102
131
  changedFiles,
103
132
  commits,
104
133
  changelogStatus: changelogEvaluation.status,
134
+ changelogBlock,
105
135
  skipChangelogMarker: changelogEvaluation.skipMarker,
106
136
  hasConventionalCommits: conventional,
107
137
  };
@@ -125,6 +155,7 @@ export function writeOutputs(result, deps) {
125
155
  const commitsEncoded = Buffer.from(JSON.stringify(result.commits), 'utf8').toString('base64');
126
156
  const filesEncoded = Buffer.from(JSON.stringify(result.changedFiles), 'utf8').toString('base64');
127
157
  deps.writeOutput('changelog_status', result.changelogStatus);
158
+ deps.writeOutput('changelog_block', result.changelogBlock);
128
159
  deps.writeOutput('skip_changelog', result.skipChangelogMarker ? 'true' : 'false');
129
160
  deps.writeOutput('conventional_commits', result.hasConventionalCommits ? 'true' : 'false');
130
161
  deps.writeOutput('commit_messages', commitsEncoded);