@samitouri / QOS-React-2 / commits / 17b3765244

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