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
+});