@samitouri / QOS-React-1 / commits / d3d0ce329e

[script] Add yarn generate-changelog (#34962)

(disclaimer: I used codex to write this script) Adds a new `yarn generate-changelog` script to simplify the process of writing changelogs. You can use it as follows: ``` $ yarn generate-changelog --help Usage: yarn generate-changelog [--codex|--claude] [--debug] [<pkg@version> ...] Options: --codex Use Codex for commit summarization. [boolean] --claude Use Claude for commit summarization. [boolean] --debug Enable verbose debug logging. [boolean] [default: false] -h, --help Show help [boolean] Examples: generate-changelog --codex Generate changelog for a single eslint-plugin-react-hooks@7.0.1 package using Codex. generate-changelog --claude react@19.3 Generate changelog entries for react-dom@19.3 multiple packages using Claude. generate-changelog --codex Generate changelog for all stable packages using recorded versions. ``` For example, if no args are passed, the script will print find all the relevant commits affecting packages (defaults to `stablePackages` in `ReactVersions.js`) and format them as a simple markdown list. ``` $ yarn generate-changelog ## eslint-plugin-react-hooks@7.0.0 * [compiler] improve zod v3 backwards compat (#34877) ([#34877](https://github.com/facebook/react/pull/34877) by [@henryqdineen](https://github.com/henryqdineen)) * [ESLint] Disallow passing effect event down when inlined as a prop (#34820) ([#34820](https://github.com/facebook/react/pull/34820) by [@jf-eirinha](https://github.com/jf-eirinha)) * Switch to `export =` to fix eslint-plugin-react-hooks types (#34949) ([#34949](https://github.com/facebook/react/pull/34949) by [@karlhorky](https://github.com/karlhorky)) * [eprh] Type `configs.flat` more strictly (#34950) ([#34950](https://github.com/facebook/react/pull/34950) by [@poteto](https://github.com/poteto)) * Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34951) ([#34951](https://github.com/facebook/react/pull/34951) by [@karlhorky](https://github.com/karlhorky)) * Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34953) ([#34953](https://github.com/facebook/react/pull/34953) by [@karlhorky](https://github.com/karlhorky)) // etc etc... ``` If `--codex` or `--claude` is passed, the script will attempt to use them to summarize the commit(s) in the same style as our existing CHANGELOG.md. And finally, for debugging the script you can add `--debug` to see what's going on.

lauren committed Oct 27, 2025 at 15:48 UTC d3d0ce329e81c7081feda5d37130d01e0607b8a4
2 files changed +817
package.json
+1
@@ -152,6 +152,7 @@
152 "download-build-in-codesandbox-ci": "yarn build --type=node react/index react.react-server react-dom/index react-dom/client react-dom/src/server react-dom/test-utils react-dom.react-server scheduler/index react/jsx-runtime react/jsx-dev-runtime react-server-dom-webpack",
153 "check-release-dependencies": "node ./scripts/release/check-release-dependencies",
154 "generate-inline-fizz-runtime": "node ./scripts/rollup/generate-inline-fizz-runtime.js",
155 + "generate-changelog": "node ./scripts/tasks/generate-changelog.js",
156 "flags": "node ./scripts/flags/flags.js"
157 },
158 "resolutions": {
scripts/tasks/generate-changelog.js new
+816
@@ -0,0 +1,816 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +'use strict';
9 +
10 +const fs = require('fs');
11 +const path = require('path');
12 +const https = require('https');
13 +const {execFile} = require('child_process');
14 +const {promisify} = require('util');
15 +const semver = require('semver');
16 +const yargs = require('yargs/yargs');
17 +
18 +const {stablePackages} = require('../../ReactVersions');
19 +
20 +const execFileAsync = promisify(execFile);
21 +const repoRoot = path.resolve(__dirname, '..', '..');
22 +
23 +function parseArgs(argv) {
24 + const parser = yargs(argv)
25 + .usage(
26 + 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [<pkg@version> ...]'
27 + )
28 + .example(
29 + '$0 --codex eslint-plugin-react-hooks@7.0.1',
30 + 'Generate changelog for a single package using Codex.'
31 + )
32 + .example(
33 + '$0 --claude react@19.3 react-dom@19.3',
34 + 'Generate changelog entries for multiple packages using Claude.'
35 + )
36 + .example(
37 + '$0 --codex',
38 + 'Generate changelog for all stable packages using recorded versions.'
39 + )
40 + .option('codex', {
41 + type: 'boolean',
42 + describe: 'Use Codex for commit summarization.',
43 + })
44 + .option('claude', {
45 + type: 'boolean',
46 + describe: 'Use Claude for commit summarization.',
47 + })
48 + .option('debug', {
49 + type: 'boolean',
50 + describe: 'Enable verbose debug logging.',
51 + default: false,
52 + })
53 + .help('help')
54 + .alias('h', 'help')
55 + .version(false)
56 + .parserConfiguration({
57 + 'parse-numbers': false,
58 + 'parse-positional-numbers': false,
59 + });
60 +
61 + const args = parser.scriptName('generate-changelog').parse();
62 + const packageSpecs = [];
63 + const debug = !!args.debug;
64 + let summarizer = null;
65 + if (args.codex && args.claude) {
66 + throw new Error('Choose either --codex or --claude, not both.');
67 + }
68 + if (args.codex) {
69 + summarizer = 'codex';
70 + } else if (args.claude) {
71 + summarizer = 'claude';
72 + }
73 +
74 + const positionalArgs = Array.isArray(args._) ? args._ : [];
75 + for (let i = 0; i < positionalArgs.length; i++) {
76 + const token = String(positionalArgs[i]).trim();
77 + if (!token) {
78 + continue;
79 + }
80 +
81 + const atIndex = token.lastIndexOf('@');
82 + if (atIndex <= 0 || atIndex === token.length - 1) {
83 + throw new Error(`Invalid package specification: ${token}`);
84 + }
85 +
86 + const packageName = token.slice(0, atIndex);
87 + const versionText = token.slice(atIndex + 1);
88 + const validVersion =
89 + semver.valid(versionText) || semver.valid(semver.coerce(versionText));
90 + if (!validVersion) {
91 + throw new Error(`Invalid version for ${packageName}: ${versionText}`);
92 + }
93 +
94 + packageSpecs.push({
95 + name: packageName,
96 + version: validVersion,
97 + displayVersion: versionText,
98 + });
99 + }
100 +
101 + if (packageSpecs.length === 0) {
102 + Object.keys(stablePackages).forEach(pkgName => {
103 + const versionText = stablePackages[pkgName];
104 + const validVersion = semver.valid(versionText);
105 + if (!validVersion) {
106 + throw new Error(
107 + `Invalid stable version configured for ${pkgName}: ${versionText}`
108 + );
109 + }
110 + packageSpecs.push({
111 + name: pkgName,
112 + version: validVersion,
113 + displayVersion: versionText,
114 + });
115 + });
116 + }
117 +
118 + if (summarizer && !isCommandAvailable(summarizer)) {
119 + throw new Error(
120 + `Requested summarizer "${summarizer}" is not available on the PATH.`
121 + );
122 + }
123 +
124 + return {
125 + debug,
126 + summarizer,
127 + packageSpecs,
128 + };
129 +}
130 +
131 +async function fetchNpmInfo(packageName, {log}) {
132 + const npmArgs = ['view', `${packageName}@latest`, '--json'];
133 + const options = {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024};
134 + log(`Fetching npm info for ${packageName}...`);
135 + const {stdout} = await execFileAsync('npm', npmArgs, options);
136 +
137 + let data = stdout.trim();
138 + if (!data) {
139 + throw new Error(`npm view returned empty result for ${packageName}`);
140 + }
141 +
142 + let info = JSON.parse(data);
143 + if (Array.isArray(info)) {
144 + info = info[info.length - 1];
145 + }
146 +
147 + const version = info.version || info['dist-tags']?.latest;
148 + let gitHead = info.gitHead || null;
149 +
150 + if (!gitHead) {
151 + const gitHeadResult = await execFileAsync(
152 + 'npm',
153 + ['view', `${packageName}@${version}`, 'gitHead'],
154 + {cwd: repoRoot, maxBuffer: 1024 * 1024}
155 + );
156 + const possibleGitHead = gitHeadResult.stdout.trim();
157 + if (
158 + possibleGitHead &&
159 + possibleGitHead !== 'undefined' &&
160 + possibleGitHead !== 'null'
161 + ) {
162 + log(`Found gitHead for ${packageName}@${version}: ${possibleGitHead}`);
163 + gitHead = possibleGitHead;
164 + }
165 + }
166 +
167 + if (!version) {
168 + throw new Error(
169 + `Unable to determine latest published version for ${packageName}`
170 + );
171 + }
172 + if (!gitHead) {
173 + throw new Error(
174 + `Unable to determine git commit for ${packageName}@${version}`
175 + );
176 + }
177 +
178 + return {
179 + publishedVersion: version,
180 + gitHead,
181 + };
182 +}
183 +
184 +async function collectCommitsSince(packageName, sinceGitSha, {log}) {
185 + log(`Collecting commits for ${packageName} since ${sinceGitSha}...`);
186 + await execFileAsync('git', ['cat-file', '-e', `${sinceGitSha}^{commit}`], {
187 + cwd: repoRoot,
188 + });
189 + const {stdout} = await execFileAsync(
190 + 'git',
191 + [
192 + 'rev-list',
193 + '--reverse',
194 + `${sinceGitSha}..HEAD`,
195 + '--',
196 + path.posix.join('packages', packageName),
197 + ],
198 + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024}
199 + );
200 +
201 + return stdout
202 + .trim()
203 + .split('\n')
204 + .map(line => line.trim())
205 + .filter(Boolean);
206 +}
207 +
208 +async function loadCommitDetails(sha, {log}) {
209 + log(`Loading commit details for ${sha}...`);
210 + const format = ['%H', '%s', '%an', '%ae', '%ct', '%B'].join('%n');
211 + const {stdout} = await execFileAsync(
212 + 'git',
213 + ['show', '--quiet', `--format=${format}`, sha],
214 + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024}
215 + );
216 +
217 + const [commitSha, subject, authorName, authorEmail, timestamp, ...rest] =
218 + stdout.split('\n');
219 + const body = rest.join('\n').trim();
220 +
221 + return {
222 + sha: commitSha.trim(),
223 + subject: subject.trim(),
224 + authorName: authorName.trim(),
225 + authorEmail: authorEmail.trim(),
226 + timestamp: +timestamp.trim() || 0,
227 + body,
228 + };
229 +}
230 +
231 +function extractPrNumber(subject, body) {
232 + const patterns = [
233 + /\(#(\d+)\)/,
234 + /https:\/\/github\.com\/facebook\/react\/pull\/(\d+)/,
235 + ];
236 +
237 + for (let i = 0; i < patterns.length; i++) {
238 + const pattern = patterns[i];
239 + const subjectMatch = subject && subject.match(pattern);
240 + if (subjectMatch) {
241 + return subjectMatch[1];
242 + }
243 + const bodyMatch = body && body.match(pattern);
244 + if (bodyMatch) {
245 + return bodyMatch[1];
246 + }
247 + }
248 +
249 + return null;
250 +}
251 +
252 +function isCommandAvailable(command) {
253 + const paths = (process.env.PATH || '').split(path.delimiter);
254 + const extensions =
255 + process.platform === 'win32' && process.env.PATHEXT
256 + ? process.env.PATHEXT.split(';')
257 + : [''];
258 +
259 + for (let i = 0; i < paths.length; i++) {
260 + const dir = paths[i];
261 + if (!dir) {
262 + continue;
263 + }
264 + for (let j = 0; j < extensions.length; j++) {
265 + const ext = extensions[j];
266 + const fullPath = path.join(dir, `${command}${ext}`);
267 + try {
268 + fs.accessSync(fullPath, fs.constants.X_OK);
269 + return true;
270 + } catch {
271 + // Keep searching.
272 + }
273 + }
274 + }
275 + return false;
276 +}
277 +
278 +function readChangelogSnippet(preferredPackage) {
279 + const cacheKey =
280 + preferredPackage === 'eslint-plugin-react-hooks'
281 + ? preferredPackage
282 + : 'root';
283 + if (!readChangelogSnippet.cache) {
284 + readChangelogSnippet.cache = new Map();
285 + }
286 + const cache = readChangelogSnippet.cache;
287 + if (cache.has(cacheKey)) {
288 + return cache.get(cacheKey);
289 + }
290 +
291 + const targetPath =
292 + preferredPackage === 'eslint-plugin-react-hooks'
293 + ? path.join(
294 + repoRoot,
295 + 'packages',
296 + 'eslint-plugin-react-hooks',
297 + 'CHANGELOG.md'
298 + )
299 + : path.join(repoRoot, 'CHANGELOG.md');
300 +
301 + let content = '';
302 + try {
303 + content = fs.readFileSync(targetPath, 'utf8');
304 + } catch {
305 + content = '';
306 + }
307 +
308 + const snippet = content.slice(0, 4000);
309 + cache.set(cacheKey, snippet);
310 + return snippet;
311 +}
312 +
313 +function sanitizeSummary(text) {
314 + if (!text) {
315 + return '';
316 + }
317 +
318 + const trimmed = text.trim();
319 + const withoutBullet = trimmed.replace(/^([-*]\s+|\d+\s*[\.)]\s+)/, '');
320 +
321 + return withoutBullet.replace(/\s+/g, ' ').trim();
322 +}
323 +
324 +async function summarizePackages({
325 + summarizer,
326 + packageSpecs,
327 + packageTargets,
328 + commitsByPackage,
329 + log,
330 +}) {
331 + const summariesByPackage = new Map();
332 + if (!summarizer) {
333 + packageSpecs.forEach(spec => {
334 + const commits = commitsByPackage.get(spec.name) || [];
335 + const summaryMap = new Map();
336 + for (let i = 0; i < commits.length; i++) {
337 + const commit = commits[i];
338 + summaryMap.set(commit.sha, commit.subject);
339 + }
340 + summariesByPackage.set(spec.name, summaryMap);
341 + });
342 + return summariesByPackage;
343 + }
344 +
345 + const tasks = packageSpecs.map(spec => {
346 + const commits = commitsByPackage.get(spec.name) || [];
347 + return summarizePackageCommits({
348 + summarizer,
349 + spec,
350 + commits,
351 + packageTargets,
352 + allPackageSpecs: packageSpecs,
353 + log,
354 + });
355 + });
356 +
357 + const results = await Promise.all(tasks);
358 + results.forEach(entry => {
359 + summariesByPackage.set(entry.packageName, entry.summaries);
360 + });
361 + return summariesByPackage;
362 +}
363 +
364 +async function summarizePackageCommits({
365 + summarizer,
366 + spec,
367 + commits,
368 + packageTargets,
369 + allPackageSpecs,
370 + log,
371 +}) {
372 + const summaries = new Map();
373 + if (commits.length === 0) {
374 + return {packageName: spec.name, summaries};
375 + }
376 +
377 + const rootStyle = readChangelogSnippet('root');
378 + const hooksStyle = readChangelogSnippet('eslint-plugin-react-hooks');
379 + const targetList = allPackageSpecs.map(
380 + targetSpec =>
381 + `${targetSpec.name}@${targetSpec.displayVersion || targetSpec.version}`
382 + );
383 + const payload = commits.map(commit => {
384 + const packages = Array.from(commit.packages || []).sort();
385 + const usesHooksStyle = (commit.packages || new Set()).has(
386 + 'eslint-plugin-react-hooks'
387 + );
388 + const packagesWithVersions = packages.map(pkgName => {
389 + const targetSpec = packageTargets.get(pkgName);
390 + if (!targetSpec) {
391 + return pkgName;
392 + }
393 + return `${pkgName}@${targetSpec.displayVersion || targetSpec.version}`;
394 + });
395 + return {
396 + sha: commit.sha,
397 + packages,
398 + packagesWithVersions,
399 + style: usesHooksStyle ? 'eslint-plugin-react-hooks' : 'root',
400 + subject: commit.subject,
401 + body: commit.body || '',
402 + };
403 + });
404 +
405 + const promptParts = [
406 + `You are preparing changelog summaries for ${spec.name} ${
407 + spec.displayVersion || spec.version
408 + }.`,
409 + 'The broader release includes:',
410 + ...targetList.map(line => `- ${line}`),
411 + '',
412 + 'For each commit payload, write a single concise sentence without a leading bullet.',
413 + 'Match the tone and formatting of the provided style samples. Do not mention commit hashes.',
414 + 'Return a JSON array where each element has the shape `{ "sha": "<sha>", "summary": "<text>" }`.',
415 + 'The JSON must contain one entry per commit in the same order they are provided.',
416 + 'Use `"root"` style unless the payload specifies `"eslint-plugin-react-hooks"`, in which case use that style sample.',
417 + '',
418 + '--- STYLE: root ---',
419 + rootStyle,
420 + '--- END STYLE ---',
421 + '',
422 + '--- STYLE: eslint-plugin-react-hooks ---',
423 + hooksStyle,
424 + '--- END STYLE ---',
425 + '',
426 + `Commits affecting ${spec.name}:`,
427 + ];
428 +
429 + payload.forEach((item, index) => {
430 + promptParts.push(
431 + `Commit ${index + 1}:`,
432 + `sha: ${item.sha}`,
433 + `style: ${item.style}`,
434 + `packages: ${item.packagesWithVersions.join(', ') || 'none'}`,
435 + `subject: ${item.subject}`,
436 + 'body:',
437 + item.body || '(empty)',
438 + ''
439 + );
440 + });
441 + promptParts.push('Return ONLY the JSON array.', '');
442 +
443 + const prompt = promptParts.join('\n');
444 + log(
445 + `Invoking ${summarizer} for ${payload.length} commit summaries targeting ${spec.name}.`
446 + );
447 + log(`Summarizer prompt length: ${prompt.length} characters.`);
448 +
449 + try {
450 + const raw = await runSummarizer(summarizer, prompt);
451 + log(`Summarizer output length: ${raw.length}`);
452 + const parsed = parseSummariesResponse(raw);
453 + if (!parsed) {
454 + throw new Error('Unable to parse summarizer output.');
455 + }
456 + parsed.forEach(entry => {
457 + const summary = sanitizeSummary(entry.summary || '');
458 + if (summary) {
459 + summaries.set(entry.sha, summary);
460 + }
461 + });
462 + } catch (error) {
463 + if (log !== noopLogger) {
464 + log(
465 + `Warning: failed to summarize commits for ${spec.name} with ${summarizer}. Falling back to subjects. ${error.message}`
466 + );
467 + if (error && error.stack) {
468 + log(error.stack);
469 + }
470 + }
471 + }
472 +
473 + for (let i = 0; i < commits.length; i++) {
474 + const commit = commits[i];
475 + if (!summaries.has(commit.sha)) {
476 + summaries.set(commit.sha, commit.subject);
477 + }
478 + }
479 +
480 + log(`Summaries available for ${summaries.size} commit(s) for ${spec.name}.`);
481 +
482 + return {packageName: spec.name, summaries};
483 +}
484 +
485 +function noopLogger() {}
486 +
487 +async function runSummarizer(command, prompt) {
488 + const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024};
489 +
490 + if (command === 'codex') {
491 + const {stdout} = await execFileAsync(
492 + 'codex',
493 + ['exec', '--json', prompt],
494 + options
495 + );
496 + return parseCodexSummary(stdout);
497 + }
498 +
499 + if (command === 'claude') {
500 + const {stdout} = await execFileAsync('claude', ['-p', prompt], options);
501 + return stripClaudeBanner(stdout);
502 + }
503 +
504 + throw new Error(`Unsupported summarizer command: ${command}`);
505 +}
506 +
507 +function parseCodexSummary(output) {
508 + let last = '';
509 + const lines = output.split('\n');
510 + for (let i = 0; i < lines.length; i++) {
511 + const trimmed = lines[i].trim();
512 + if (!trimmed) {
513 + continue;
514 + }
515 + try {
516 + const event = JSON.parse(trimmed);
517 + if (
518 + event.type === 'item.completed' &&
519 + event.item?.type === 'agent_message'
520 + ) {
521 + last = event.item.text || '';
522 + }
523 + } catch {
524 + last = trimmed;
525 + }
526 + }
527 + return last || output;
528 +}
529 +
530 +function stripClaudeBanner(text) {
531 + return text
532 + .split('\n')
533 + .filter(
534 + line =>
535 + line.trim() !==
536 + 'Claude Code at Meta (https://fburl.com/claude.code.users)'
537 + )
538 + .join('\n');
539 +}
540 +
541 +function parseSummariesResponse(raw) {
542 + const candidates = [];
543 + const trimmed = raw.trim();
544 + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
545 + if (fencedMatch) {
546 + candidates.push(fencedMatch[1].trim());
547 + }
548 +
549 + const firstBracket = trimmed.indexOf('[');
550 + if (firstBracket !== -1) {
551 + candidates.push(trimmed.slice(firstBracket).trim());
552 + }
553 +
554 + for (let i = 0; i < candidates.length; i++) {
555 + const candidate = candidates[i];
556 + if (!candidate) {
557 + continue;
558 + }
559 + try {
560 + const parsed = JSON.parse(candidate);
561 + if (Array.isArray(parsed)) {
562 + return parsed;
563 + }
564 + } catch {
565 + // Try the next candidate.
566 + }
567 + }
568 +
569 + try {
570 + const parsed = JSON.parse(trimmed);
571 + if (Array.isArray(parsed)) {
572 + return parsed;
573 + }
574 + } catch {
575 + // Fall through.
576 + }
577 +
578 + return null;
579 +}
580 +
581 +async function fetchPullRequestMetadata(prNumber, {log}) {
582 + log(`Fetching PR metadata for #${prNumber}...`);
583 + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
584 + const requestOptions = {
585 + hostname: 'api.github.com',
586 + path: `/repos/facebook/react/pulls/${prNumber}`,
587 + method: 'GET',
588 + headers: {
589 + 'User-Agent': 'generate-changelog-script',
590 + Accept: 'application/vnd.github+json',
591 + },
592 + };
593 + if (token) {
594 + requestOptions.headers.Authorization = `Bearer ${token}`;
595 + }
596 +
597 + return new Promise(resolve => {
598 + const req = https.request(requestOptions, res => {
599 + let raw = '';
600 + res.on('data', chunk => {
601 + raw += chunk;
602 + });
603 + res.on('end', () => {
604 + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
605 + try {
606 + const json = JSON.parse(raw);
607 + resolve({
608 + authorLogin: json.user?.login || null,
609 + });
610 + } catch (error) {
611 + process.stderr.write(
612 + `Warning: unable to parse GitHub response for PR #${prNumber}: ${error.message}\n`
613 + );
614 + resolve(null);
615 + }
616 + } else {
617 + process.stderr.write(
618 + `Warning: GitHub API request failed for PR #${prNumber} with status ${res.statusCode}\n`
619 + );
620 + resolve(null);
621 + }
622 + });
623 + });
624 +
625 + req.on('error', error => {
626 + process.stderr.write(
627 + `Warning: GitHub API request errored for PR #${prNumber}: ${error.message}\n`
628 + );
629 + resolve(null);
630 + });
631 +
632 + req.end();
633 + });
634 +}
635 +
636 +async function main() {
637 + const {packageSpecs, summarizer, debug} = parseArgs(process.argv.slice(2));
638 + const log = debug
639 + ? (...args) => console.log('[generate-changelog]', ...args)
640 + : noopLogger;
641 + const allStablePackages = Object.keys(stablePackages);
642 +
643 + const packageTargets = new Map();
644 + for (let i = 0; i < packageSpecs.length; i++) {
645 + const spec = packageSpecs[i];
646 + if (!allStablePackages.includes(spec.name)) {
647 + throw new Error(
648 + `Package "${spec.name}" is not listed in stablePackages.`
649 + );
650 + }
651 + if (packageTargets.has(spec.name)) {
652 + throw new Error(`Package "${spec.name}" was specified more than once.`);
653 + }
654 + packageTargets.set(spec.name, spec);
655 + }
656 +
657 + const targetPackages = packageSpecs.map(spec => spec.name);
658 + log(
659 + `Starting changelog generation for: ${packageSpecs
660 + .map(spec => `${spec.name}@${spec.displayVersion || spec.version}`)
661 + .join(', ')}`
662 + );
663 +
664 + const packageInfoMap = new Map();
665 + const packageInfoResults = await Promise.all(
666 + targetPackages.map(async pkg => {
667 + const info = await fetchNpmInfo(pkg, {log});
668 + return {pkg, info};
669 + })
670 + );
671 + for (let i = 0; i < packageInfoResults.length; i++) {
672 + const entry = packageInfoResults[i];
673 + packageInfoMap.set(entry.pkg, entry.info);
674 + }
675 +
676 + const commitPackagesMap = new Map();
677 + const commitCollections = await Promise.all(
678 + targetPackages.map(async pkg => {
679 + const {gitHead} = packageInfoMap.get(pkg);
680 + const commits = await collectCommitsSince(pkg, gitHead, {log});
681 + log(`Package ${pkg} has ${commits.length} commit(s) since ${gitHead}.`);
682 + return {pkg, commits};
683 + })
684 + );
685 + for (let i = 0; i < commitCollections.length; i++) {
686 + const entry = commitCollections[i];
687 + const pkg = entry.pkg;
688 + const commits = entry.commits;
689 + for (let j = 0; j < commits.length; j++) {
690 + const sha = commits[j];
691 + if (!commitPackagesMap.has(sha)) {
692 + commitPackagesMap.set(sha, new Set());
693 + }
694 + commitPackagesMap.get(sha).add(pkg);
695 + }
696 + }
697 + log(`Found ${commitPackagesMap.size} commits touching target packages.`);
698 +
699 + if (commitPackagesMap.size === 0) {
700 + console.log('No commits found for the selected packages.');
701 + return;
702 + }
703 +
704 + const commitDetails = await Promise.all(
705 + Array.from(commitPackagesMap.entries()).map(
706 + async ([sha, packagesTouched]) => {
707 + const detail = await loadCommitDetails(sha, {log});
708 + detail.packages = packagesTouched;
709 + detail.prNumber = extractPrNumber(detail.subject, detail.body);
710 + return detail;
711 + }
712 + )
713 + );
714 +
715 + commitDetails.sort((a, b) => a.timestamp - b.timestamp);
716 + log(`Ordered ${commitDetails.length} commit(s) chronologically.`);
717 +
718 + const commitsByPackage = new Map();
719 + commitDetails.forEach(commit => {
720 + commit.packages.forEach(pkgName => {
721 + if (!commitsByPackage.has(pkgName)) {
722 + commitsByPackage.set(pkgName, []);
723 + }
724 + commitsByPackage.get(pkgName).push(commit);
725 + });
726 + });
727 +
728 + const uniquePrNumbers = Array.from(
729 + new Set(commitDetails.map(commit => commit.prNumber).filter(Boolean))
730 + );
731 + log(`Identified ${uniquePrNumbers.length} unique PR number(s).`);
732 +
733 + const prMetadata = new Map();
734 + log(`Summarizer selected: ${summarizer || 'none (using commit titles)'}`);
735 + const prMetadataResults = await Promise.all(
736 + uniquePrNumbers.map(async prNumber => {
737 + const meta = await fetchPullRequestMetadata(prNumber, {log});
738 + return {prNumber, meta};
739 + })
740 + );
741 + for (let i = 0; i < prMetadataResults.length; i++) {
742 + const entry = prMetadataResults[i];
743 + if (entry.meta) {
744 + prMetadata.set(entry.prNumber, entry.meta);
745 + }
746 + }
747 + log(`Fetched metadata for ${prMetadata.size} PR(s).`);
748 +
749 + const summariesByPackage = await summarizePackages({
750 + summarizer,
751 + packageSpecs,
752 + packageTargets,
753 + commitsByPackage,
754 + log,
755 + });
756 +
757 + const outputLines = [];
758 + for (let i = 0; i < packageSpecs.length; i++) {
759 + const spec = packageSpecs[i];
760 + outputLines.push(`## ${spec.name}@${spec.displayVersion || spec.version}`);
761 + const commitsForPackage = commitsByPackage.get(spec.name) || [];
762 +
763 + if (commitsForPackage.length === 0) {
764 + outputLines.push('* No changes since the last release.');
765 + outputLines.push('');
766 + continue;
767 + }
768 +
769 + commitsForPackage.forEach(commit => {
770 + if (commit.prNumber && prMetadata.has(commit.prNumber)) {
771 + commit.authorLogin = prMetadata.get(commit.prNumber).authorLogin;
772 + }
773 +
774 + const prFragment = commit.prNumber
775 + ? `[#${commit.prNumber}](https://github.com/facebook/react/pull/${commit.prNumber})`
776 + : `commit ${commit.sha.slice(0, 7)}`;
777 +
778 + let authorFragment = commit.authorLogin
779 + ? `[@${commit.authorLogin}](https://github.com/${commit.authorLogin})`
780 + : commit.authorName || 'unknown author';
781 +
782 + if (
783 + !commit.authorLogin &&
784 + commit.authorName &&
785 + commit.authorName.startsWith('@')
786 + ) {
787 + const username = commit.authorName.slice(1);
788 + authorFragment = `[@${username}](https://github.com/${username})`;
789 + }
790 +
791 + const summaryMap = summariesByPackage.get(spec.name) || new Map();
792 + let summary = summaryMap.get(commit.sha) || commit.subject;
793 +
794 + if (commit.prNumber) {
795 + const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`);
796 + summary = summary.replace(prPattern, '').trim();
797 + }
798 +
799 + outputLines.push(`* ${summary} (${prFragment} by ${authorFragment})`);
800 + });
801 +
802 + outputLines.push('');
803 + }
804 +
805 + while (outputLines.length && outputLines[outputLines.length - 1] === '') {
806 + outputLines.pop();
807 + }
808 +
809 + log('Generated changelog sections.');
810 + console.log(outputLines.join('\n'));
811 +}
812 +
813 +main().catch(error => {
814 + process.stderr.write(`${error.message}\n`);
815 + process.exit(1);
816 +});