@oorabona/release-it-preset 1.1.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/README.md +58 -1596
- package/bin/cli.js +14 -17
- package/bin/validators.js +34 -0
- package/dist/scripts/annotate-changelog.js +546 -0
- package/dist/scripts/check-pr-status.js +29 -1
- package/dist/scripts/doctor.js +963 -28
- package/dist/scripts/init-project.js +150 -7
- package/dist/scripts/lib/semver-utils.js +127 -0
- package/dist/scripts/lib/workflow-template.js +34 -0
- package/dist/scripts/lib/workspace-detect.js +173 -0
- package/dist/types/annotate-changelog.d.ts +86 -0
- package/dist/types/check-pr-status.d.ts +3 -0
- package/dist/types/doctor.d.ts +7 -1
- package/dist/types/init-project.d.ts +25 -1
- package/dist/types/lib/semver-utils.d.ts +15 -0
- package/dist/types/lib/workflow-template.d.ts +14 -0
- package/dist/types/lib/workspace-detect.d.ts +59 -0
- package/package.json +23 -10
- package/scripts/templates/workflows/release.yml.template +65 -0
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
|
|
@@ -160,21 +162,19 @@ function handleReleaseCommand(configName, args) {
|
|
|
160
162
|
const extendsPreset = extendsMatch?.[1];
|
|
161
163
|
|
|
162
164
|
if (extendsPreset && extendsPreset !== configName) {
|
|
163
|
-
console.
|
|
164
|
-
console.
|
|
165
|
-
console.
|
|
166
|
-
console.
|
|
167
|
-
console.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
console.
|
|
172
|
-
console.
|
|
173
|
-
|
|
165
|
+
console.warn(`⚠️ Note: your .release-it.json extends "${extendsPreset}"`);
|
|
166
|
+
console.warn(` but you invoked the "${configName}" preset.`);
|
|
167
|
+
console.warn(` Using "${configName}" config directly; .release-it.json customizations are ignored for this run.`);
|
|
168
|
+
console.warn(` To use your customizations, run: release-it-preset ${extendsPreset}`);
|
|
169
|
+
console.warn(``);
|
|
170
|
+
// Force --config flag to use OUR preset, ignore user's .release-it.json
|
|
171
|
+
fullArgs = ['--config', configPath, ...args];
|
|
172
|
+
} else {
|
|
173
|
+
console.log(`✅ Config validated: preset "${configName}"`);
|
|
174
|
+
console.log(`📝 Using: ${userConfigPath}\n`);
|
|
175
|
+
// Let release-it discover .release-it.json and merge via extends
|
|
176
|
+
fullArgs = [...args];
|
|
174
177
|
}
|
|
175
|
-
|
|
176
|
-
console.log(`✅ Config validated: preset "${configName}"`);
|
|
177
|
-
console.log(`📝 Using: ${userConfigPath}\n`);
|
|
178
178
|
} catch (error) {
|
|
179
179
|
if (error instanceof SyntaxError) {
|
|
180
180
|
console.error(`❌ Failed to parse .release-it.json: ${error.message}`);
|
|
@@ -183,9 +183,6 @@ function handleReleaseCommand(configName, args) {
|
|
|
183
183
|
}
|
|
184
184
|
process.exit(1);
|
|
185
185
|
}
|
|
186
|
-
|
|
187
|
-
// Let release-it discover .release-it.json and merge via extends
|
|
188
|
-
fullArgs = [...args];
|
|
189
186
|
} else {
|
|
190
187
|
// No user config - use preset directly
|
|
191
188
|
console.log(`📝 Using preset config directly: ${configPath}`);
|
package/bin/validators.js
CHANGED
|
@@ -117,6 +117,40 @@ export function sanitizeArgs(args) {
|
|
|
117
117
|
* @throws {Error} If validation fails (invalid extension, too deep, missing file, etc.)
|
|
118
118
|
* @returns {string} Absolute path to validated config file
|
|
119
119
|
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validates a workflow filename for use with `init --workflow-name`.
|
|
123
|
+
*
|
|
124
|
+
* Allowed: simple filenames matching ^[A-Za-z0-9._-]+\.ya?ml$
|
|
125
|
+
* Rejected: path components (subdir/), traversal (../), wrong extension.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} name - The workflow filename to validate
|
|
128
|
+
* @throws {Error} If the name contains path separators, traversal, or wrong extension
|
|
129
|
+
* @returns {string} The validated filename
|
|
130
|
+
*/
|
|
131
|
+
export function validateWorkflowName(name) {
|
|
132
|
+
if (!name || name.length === 0) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Workflow name cannot be empty.\n` +
|
|
135
|
+
`Expected a simple filename like "release.yml" or "publish.yml".`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Reject any path separators or traversal — name must be a single filename component
|
|
140
|
+
const WORKFLOW_NAME_RE = /^[A-Za-z0-9._-]+\.ya?ml$/;
|
|
141
|
+
if (!WORKFLOW_NAME_RE.test(name)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Invalid workflow name: "${name}"\n` +
|
|
144
|
+
`Workflow name must be a simple filename matching ^[A-Za-z0-9._-]+\\.ya?ml$\n` +
|
|
145
|
+
`Examples: release.yml, publish.yml, release_it.yml\n` +
|
|
146
|
+
`Path components (subdir/foo.yml) and traversal (../etc.yml) are not allowed.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return name;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
120
154
|
export function validateConfigPath(configPath) {
|
|
121
155
|
// 1. Whitelist config file extensions (defense in depth)
|
|
122
156
|
const allowedExtensions = ['.json', '.js', '.cjs', '.mjs', '.yaml', '.yml', '.toml'];
|
|
@@ -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 */
|