@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/README.md +66 -1601
- package/bin/cli.js +2 -0
- package/dist/scripts/annotate-changelog.js +582 -0
- package/dist/scripts/check-pr-status.js +32 -1
- package/dist/scripts/doctor.js +963 -28
- package/dist/scripts/init-project.js +6 -14
- package/dist/scripts/lib/semver-utils.js +127 -0
- package/dist/scripts/lib/workflow-template.js +34 -0
- package/dist/types/annotate-changelog.d.ts +89 -0
- package/dist/types/check-pr-status.d.ts +3 -0
- package/dist/types/doctor.d.ts +7 -1
- package/dist/types/lib/semver-utils.d.ts +15 -0
- package/dist/types/lib/workflow-template.d.ts +14 -0
- package/package.json +10 -3
- package/scripts/templates/workflows/release.yml.template +4 -2
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);
|