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