@oorabona/release-it-preset 1.2.0 → 1.3.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,546 @@
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
+ export function extractStructuredChangelogNotes(body, options = {}) {
361
+ if (!body) {
362
+ return [];
363
+ }
364
+ const notes = [];
365
+ const normalizedBody = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
366
+ const openMarker = /<!--\s*changelog\s*:\s*([a-z]+)\s*-->/gi;
367
+ const closeMarker = /<!--\s*\/changelog\s*-->/i;
368
+ const prNumber = options.prNumber ?? 0;
369
+ for (const match of normalizedBody.matchAll(openMarker)) {
370
+ const type = match[1].toLowerCase();
371
+ const contentStart = match.index + match[0].length;
372
+ const rest = normalizedBody.slice(contentStart);
373
+ const closeMatch = closeMarker.exec(rest);
374
+ if (!closeMatch) {
375
+ warnForPr(prNumber, options, 'unclosed marker');
376
+ continue;
377
+ }
378
+ const section = CHANGELOG_TYPE_TO_SECTION[type];
379
+ if (!section) {
380
+ warnForPr(prNumber, options, `unknown changelog type "${type}"`);
381
+ continue;
382
+ }
383
+ const rawContent = rest.slice(0, closeMatch.index);
384
+ // A second open marker before the close means overlapping blocks: the
385
+ // outer region is malformed and must never leak a raw marker (or
386
+ // duplicated text) into the changelog. The inner block, if well-formed,
387
+ // is still picked up by its own matchAll iteration.
388
+ if (/<!--\s*changelog\s*:/i.test(rawContent)) {
389
+ warnForPr(prNumber, options, `nested changelog marker inside ${type} block`);
390
+ continue;
391
+ }
392
+ const text = normalizeBlockText(rawContent);
393
+ if (!text) {
394
+ warnForPr(prNumber, options, `empty ${type} block`);
395
+ continue;
396
+ }
397
+ notes.push({ section, text });
398
+ }
399
+ return notes;
400
+ }
401
+ // Only the CURRENT PR's own reference is stripped before re-appending it:
402
+ // a foreign trailing reference like "fixes issue (#123)" is the author's
403
+ // text and must survive verbatim.
404
+ function stripAnnotationReferences(text, prNumber) {
405
+ const ownReference = new RegExp(`\\s+\\(#${prNumber}\\)(?:\\s+\\(\\[[0-9a-f]{7,40}\\]\\([^)]+/commit/[0-9a-f]{7,40}\\)\\))?$`, 'i');
406
+ return text
407
+ .replace(ownReference, '')
408
+ .replace(/\s+\(\[[0-9a-f]{7,40}\]\([^)]+\/commit\/[0-9a-f]{7,40}\)\)$/i, '')
409
+ .trim();
410
+ }
411
+ function formatCommitReference(primarySha, repoUrl) {
412
+ if (!primarySha) {
413
+ return '';
414
+ }
415
+ const shortSha = primarySha.substring(0, 7);
416
+ return ` ([${shortSha}](${repoUrl}/commit/${shortSha}))`;
417
+ }
418
+ function formatNote(note, primarySha, repoUrl, prNumber) {
419
+ const text = stripAnnotationReferences(note.text, prNumber);
420
+ const reference = ` (#${prNumber})${formatCommitReference(primarySha, repoUrl)}`;
421
+ return {
422
+ section: note.section,
423
+ text: `${text}${reference}`,
424
+ rawLine: `- ${text}${reference}`,
425
+ shaList: primarySha ? [primarySha] : [],
426
+ prNumber,
427
+ order: Number.MAX_SAFE_INTEGER,
428
+ };
429
+ }
430
+ function notesForResolvedGroup(group, deps) {
431
+ return extractStructuredChangelogNotes(group.pr.body, { prNumber: group.pr.number, warn: deps.warn });
432
+ }
433
+ export function renderAnnotatedBody(parsed, resolvedGroups, repoUrl, deps) {
434
+ const removedEntries = new Set();
435
+ const additionsBySection = new Map();
436
+ let annotatedGroups = 0;
437
+ for (const group of resolvedGroups) {
438
+ const notes = notesForResolvedGroup(group, deps);
439
+ if (notes.length === 0) {
440
+ continue;
441
+ }
442
+ annotatedGroups += 1;
443
+ // The PR body is mutable post-merge: name every source PR so the
444
+ // maintainer knows exactly what to review in the resulting diff.
445
+ deps.log(`- PR #${group.pr.number}: ${notes.length} changelog block(s) applied`);
446
+ for (const entry of group.entries) {
447
+ removedEntries.add(entry);
448
+ }
449
+ const primarySha = choosePrimarySha([group.primarySha, ...group.entries.flatMap(entry => entry.shaList)]);
450
+ for (const note of notes) {
451
+ const formatted = formatNote(note, primarySha, repoUrl, group.pr.number);
452
+ const list = additionsBySection.get(formatted.section) ?? [];
453
+ list.push(formatted);
454
+ additionsBySection.set(formatted.section, list);
455
+ }
456
+ }
457
+ // Nothing was annotated: re-rendering would only restructure the existing
458
+ // body without adding any information — the caller must leave the file
459
+ // untouched.
460
+ if (annotatedGroups === 0) {
461
+ return null;
462
+ }
463
+ // Everything that is not replaced is preserved in place: sections keep
464
+ // their original order and internal layout (notes, wrapped bullets);
465
+ // replacement bullets append at the end of their target section; sections
466
+ // that only exist in the additions are appended in canonical order.
467
+ const lines = [];
468
+ const emittedHeadings = new Set();
469
+ const emitSection = (heading, items, additions) => {
470
+ const keptItems = items.filter(item => item.kind === 'note' || !removedEntries.has(item.entry));
471
+ if (keptItems.length === 0 && additions.length === 0) {
472
+ return;
473
+ }
474
+ if (heading !== null) {
475
+ lines.push(heading);
476
+ }
477
+ for (const item of keptItems) {
478
+ lines.push(item.kind === 'note' ? item.line : item.entry.rawLine);
479
+ }
480
+ for (const added of additions) {
481
+ lines.push(added.rawLine);
482
+ }
483
+ lines.push('');
484
+ };
485
+ for (const section of parsed.sections) {
486
+ const additions = section.heading !== null ? (additionsBySection.get(section.heading) ?? []) : [];
487
+ if (section.heading !== null) {
488
+ emittedHeadings.add(section.heading);
489
+ }
490
+ emitSection(section.heading, section.items, additions);
491
+ }
492
+ const pendingHeadings = [...additionsBySection.keys()].filter(heading => !emittedHeadings.has(heading));
493
+ const orderedPending = [
494
+ ...STANDARD_SECTION_ORDER.filter(heading => pendingHeadings.includes(heading)),
495
+ ...pendingHeadings.filter(heading => !STANDARD_SECTION_ORDER.includes(heading)),
496
+ ];
497
+ for (const heading of orderedPending) {
498
+ emitSection(heading, [], additionsBySection.get(heading) ?? []);
499
+ }
500
+ return `\n${lines.join('\n').trim()}\n\n`;
501
+ }
502
+ function readChangelog(changelogPath, deps) {
503
+ try {
504
+ return deps.readFileSync(changelogPath, 'utf8');
505
+ }
506
+ catch (error) {
507
+ throw new ChangelogError(`Could not read ${changelogPath}. Run release-it-preset update first.\n${errorText(error)}`);
508
+ }
509
+ }
510
+ export function annotateChangelog(deps) {
511
+ const changelogPath = deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md';
512
+ deps.log('Annotating [Unreleased] section...');
513
+ const changelog = readChangelog(changelogPath, deps);
514
+ const block = extractUnreleasedBlock(changelog, changelogPath);
515
+ const parsed = parseUnreleasedEntries(block.body);
516
+ if (parsed.entries.length === 0) {
517
+ deps.log('No changelog entries found in [Unreleased]');
518
+ return;
519
+ }
520
+ const { groups } = groupEntriesForLookup(parsed.entries);
521
+ if (groups.length === 0) {
522
+ deps.log('No PR or commit references found in [Unreleased]');
523
+ return;
524
+ }
525
+ const { repoUrl, ownerRepo } = getRequiredGitHubContext(deps);
526
+ const resolved = resolvePullRequestGroups(groups, ownerRepo, deps);
527
+ const nextBody = renderAnnotatedBody(parsed, resolved.resolved, repoUrl, deps);
528
+ if (nextBody === null) {
529
+ deps.log('No changelog blocks found in the resolved pull requests — nothing to annotate');
530
+ return;
531
+ }
532
+ deps.writeFileSync(changelogPath, `${block.prefix}${nextBody}${block.suffix}`);
533
+ deps.log(`Annotated ${resolved.resolved.length} pull request(s)`);
534
+ }
535
+ /* c8 ignore start */
536
+ if (import.meta.url === `file://${process.argv[1]}`) {
537
+ void runScript({ error: console.error, exit: process.exit }, () => annotateChangelog({
538
+ execSync,
539
+ readFileSync,
540
+ writeFileSync,
541
+ getEnv: (key) => process.env[key],
542
+ log: console.log,
543
+ warn: console.warn,
544
+ }));
545
+ }
546
+ /* c8 ignore end */
@@ -11,10 +11,11 @@
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
15
  import { STRICT_CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
16
16
  import { runScript } from './lib/run-script.js';
17
17
  const SKIP_CHANGELOG_REGEX = /\[skip-changelog]/i;
18
+ const CHANGELOG_BLOCK_REGEX = /<!--\s*changelog\s*:\s*(added|changed|deprecated|removed|fixed|security)\s*-->[\s\S]*?<!--\s*\/changelog\s*-->/i;
18
19
  export function safeExec(command, deps) {
19
20
  try {
20
21
  return deps.execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
@@ -77,6 +78,30 @@ 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
+ return CHANGELOG_BLOCK_REGEX.test(body) ? 'present' : 'absent';
100
+ }
101
+ catch {
102
+ return 'unknown';
103
+ }
104
+ }
80
105
  export function getDiffRange(baseRef, headRef) {
81
106
  if (!baseRef) {
82
107
  return headRef;
@@ -95,6 +120,7 @@ export function runPrCheck(args, deps) {
95
120
  const commits = splitList(commitsOutput);
96
121
  const changelogPath = deps.getEnv('CHANGELOG_FILE') ?? 'CHANGELOG.md';
97
122
  const changelogEvaluation = evaluateChangelogStatus(changedFiles, changelogPath, commits);
123
+ const changelogBlock = evaluateChangelogBlockStatus(deps);
98
124
  const conventional = hasConventionalCommits(commits);
99
125
  return {
100
126
  baseRef,
@@ -102,6 +128,7 @@ export function runPrCheck(args, deps) {
102
128
  changedFiles,
103
129
  commits,
104
130
  changelogStatus: changelogEvaluation.status,
131
+ changelogBlock,
105
132
  skipChangelogMarker: changelogEvaluation.skipMarker,
106
133
  hasConventionalCommits: conventional,
107
134
  };
@@ -125,6 +152,7 @@ export function writeOutputs(result, deps) {
125
152
  const commitsEncoded = Buffer.from(JSON.stringify(result.commits), 'utf8').toString('base64');
126
153
  const filesEncoded = Buffer.from(JSON.stringify(result.changedFiles), 'utf8').toString('base64');
127
154
  deps.writeOutput('changelog_status', result.changelogStatus);
155
+ deps.writeOutput('changelog_block', result.changelogBlock);
128
156
  deps.writeOutput('skip_changelog', result.skipChangelogMarker ? 'true' : 'false');
129
157
  deps.writeOutput('conventional_commits', result.hasConventionalCommits ? 'true' : 'false');
130
158
  deps.writeOutput('commit_messages', commitsEncoded);