From d3d0ce329e81c7081feda5d37130d01e0607b8a4 Mon Sep 17 00:00:00 2001 From: lauren Date: Mon, 27 Oct 2025 15:48:36 -0400 Subject: [PATCH 1/7] [script] Add yarn generate-changelog (#34962) (disclaimer: I used codex to write this script) Adds a new `yarn generate-changelog` script to simplify the process of writing changelogs. You can use it as follows: ``` $ yarn generate-changelog --help Usage: yarn generate-changelog [--codex|--claude] [--debug] [ ...] Options: --codex Use Codex for commit summarization. [boolean] --claude Use Claude for commit summarization. [boolean] --debug Enable verbose debug logging. [boolean] [default: false] -h, --help Show help [boolean] Examples: generate-changelog --codex Generate changelog for a single eslint-plugin-react-hooks@7.0.1 package using Codex. generate-changelog --claude react@19.3 Generate changelog entries for react-dom@19.3 multiple packages using Claude. generate-changelog --codex Generate changelog for all stable packages using recorded versions. ``` For example, if no args are passed, the script will print find all the relevant commits affecting packages (defaults to `stablePackages` in `ReactVersions.js`) and format them as a simple markdown list. ``` $ yarn generate-changelog ## eslint-plugin-react-hooks@7.0.0 * [compiler] improve zod v3 backwards compat (#34877) ([#34877](https://github.com/facebook/react/pull/34877) by [@henryqdineen](https://github.com/henryqdineen)) * [ESLint] Disallow passing effect event down when inlined as a prop (#34820) ([#34820](https://github.com/facebook/react/pull/34820) by [@jf-eirinha](https://github.com/jf-eirinha)) * Switch to `export =` to fix eslint-plugin-react-hooks types (#34949) ([#34949](https://github.com/facebook/react/pull/34949) by [@karlhorky](https://github.com/karlhorky)) * [eprh] Type `configs.flat` more strictly (#34950) ([#34950](https://github.com/facebook/react/pull/34950) by [@poteto](https://github.com/poteto)) * Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34951) ([#34951](https://github.com/facebook/react/pull/34951) by [@karlhorky](https://github.com/karlhorky)) * Add hint for Node.js cjs-module-lexer for eslint-plugin-react-hook types (#34953) ([#34953](https://github.com/facebook/react/pull/34953) by [@karlhorky](https://github.com/karlhorky)) // etc etc... ``` If `--codex` or `--claude` is passed, the script will attempt to use them to summarize the commit(s) in the same style as our existing CHANGELOG.md. And finally, for debugging the script you can add `--debug` to see what's going on. --- package.json | 1 + scripts/tasks/generate-changelog.js | 816 ++++++++++++++++++++++++++++ 2 files changed, 817 insertions(+) create mode 100644 scripts/tasks/generate-changelog.js diff --git a/package.json b/package.json index 7bb0c2c5c9350..9fb3082f0c69c 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "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", "check-release-dependencies": "node ./scripts/release/check-release-dependencies", "generate-inline-fizz-runtime": "node ./scripts/rollup/generate-inline-fizz-runtime.js", + "generate-changelog": "node ./scripts/tasks/generate-changelog.js", "flags": "node ./scripts/flags/flags.js" }, "resolutions": { diff --git a/scripts/tasks/generate-changelog.js b/scripts/tasks/generate-changelog.js new file mode 100644 index 0000000000000..158346d2d9ab3 --- /dev/null +++ b/scripts/tasks/generate-changelog.js @@ -0,0 +1,816 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const {execFile} = require('child_process'); +const {promisify} = require('util'); +const semver = require('semver'); +const yargs = require('yargs/yargs'); + +const {stablePackages} = require('../../ReactVersions'); + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(__dirname, '..', '..'); + +function parseArgs(argv) { + const parser = yargs(argv) + .usage( + 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [ ...]' + ) + .example( + '$0 --codex eslint-plugin-react-hooks@7.0.1', + 'Generate changelog for a single package using Codex.' + ) + .example( + '$0 --claude react@19.3 react-dom@19.3', + 'Generate changelog entries for multiple packages using Claude.' + ) + .example( + '$0 --codex', + 'Generate changelog for all stable packages using recorded versions.' + ) + .option('codex', { + type: 'boolean', + describe: 'Use Codex for commit summarization.', + }) + .option('claude', { + type: 'boolean', + describe: 'Use Claude for commit summarization.', + }) + .option('debug', { + type: 'boolean', + describe: 'Enable verbose debug logging.', + default: false, + }) + .help('help') + .alias('h', 'help') + .version(false) + .parserConfiguration({ + 'parse-numbers': false, + 'parse-positional-numbers': false, + }); + + const args = parser.scriptName('generate-changelog').parse(); + const packageSpecs = []; + const debug = !!args.debug; + let summarizer = null; + if (args.codex && args.claude) { + throw new Error('Choose either --codex or --claude, not both.'); + } + if (args.codex) { + summarizer = 'codex'; + } else if (args.claude) { + summarizer = 'claude'; + } + + const positionalArgs = Array.isArray(args._) ? args._ : []; + for (let i = 0; i < positionalArgs.length; i++) { + const token = String(positionalArgs[i]).trim(); + if (!token) { + continue; + } + + const atIndex = token.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === token.length - 1) { + throw new Error(`Invalid package specification: ${token}`); + } + + const packageName = token.slice(0, atIndex); + const versionText = token.slice(atIndex + 1); + const validVersion = + semver.valid(versionText) || semver.valid(semver.coerce(versionText)); + if (!validVersion) { + throw new Error(`Invalid version for ${packageName}: ${versionText}`); + } + + packageSpecs.push({ + name: packageName, + version: validVersion, + displayVersion: versionText, + }); + } + + if (packageSpecs.length === 0) { + Object.keys(stablePackages).forEach(pkgName => { + const versionText = stablePackages[pkgName]; + const validVersion = semver.valid(versionText); + if (!validVersion) { + throw new Error( + `Invalid stable version configured for ${pkgName}: ${versionText}` + ); + } + packageSpecs.push({ + name: pkgName, + version: validVersion, + displayVersion: versionText, + }); + }); + } + + if (summarizer && !isCommandAvailable(summarizer)) { + throw new Error( + `Requested summarizer "${summarizer}" is not available on the PATH.` + ); + } + + return { + debug, + summarizer, + packageSpecs, + }; +} + +async function fetchNpmInfo(packageName, {log}) { + const npmArgs = ['view', `${packageName}@latest`, '--json']; + const options = {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024}; + log(`Fetching npm info for ${packageName}...`); + const {stdout} = await execFileAsync('npm', npmArgs, options); + + let data = stdout.trim(); + if (!data) { + throw new Error(`npm view returned empty result for ${packageName}`); + } + + let info = JSON.parse(data); + if (Array.isArray(info)) { + info = info[info.length - 1]; + } + + const version = info.version || info['dist-tags']?.latest; + let gitHead = info.gitHead || null; + + if (!gitHead) { + const gitHeadResult = await execFileAsync( + 'npm', + ['view', `${packageName}@${version}`, 'gitHead'], + {cwd: repoRoot, maxBuffer: 1024 * 1024} + ); + const possibleGitHead = gitHeadResult.stdout.trim(); + if ( + possibleGitHead && + possibleGitHead !== 'undefined' && + possibleGitHead !== 'null' + ) { + log(`Found gitHead for ${packageName}@${version}: ${possibleGitHead}`); + gitHead = possibleGitHead; + } + } + + if (!version) { + throw new Error( + `Unable to determine latest published version for ${packageName}` + ); + } + if (!gitHead) { + throw new Error( + `Unable to determine git commit for ${packageName}@${version}` + ); + } + + return { + publishedVersion: version, + gitHead, + }; +} + +async function collectCommitsSince(packageName, sinceGitSha, {log}) { + log(`Collecting commits for ${packageName} since ${sinceGitSha}...`); + await execFileAsync('git', ['cat-file', '-e', `${sinceGitSha}^{commit}`], { + cwd: repoRoot, + }); + const {stdout} = await execFileAsync( + 'git', + [ + 'rev-list', + '--reverse', + `${sinceGitSha}..HEAD`, + '--', + path.posix.join('packages', packageName), + ], + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} + ); + + return stdout + .trim() + .split('\n') + .map(line => line.trim()) + .filter(Boolean); +} + +async function loadCommitDetails(sha, {log}) { + log(`Loading commit details for ${sha}...`); + const format = ['%H', '%s', '%an', '%ae', '%ct', '%B'].join('%n'); + const {stdout} = await execFileAsync( + 'git', + ['show', '--quiet', `--format=${format}`, sha], + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} + ); + + const [commitSha, subject, authorName, authorEmail, timestamp, ...rest] = + stdout.split('\n'); + const body = rest.join('\n').trim(); + + return { + sha: commitSha.trim(), + subject: subject.trim(), + authorName: authorName.trim(), + authorEmail: authorEmail.trim(), + timestamp: +timestamp.trim() || 0, + body, + }; +} + +function extractPrNumber(subject, body) { + const patterns = [ + /\(#(\d+)\)/, + /https:\/\/github\.com\/facebook\/react\/pull\/(\d+)/, + ]; + + for (let i = 0; i < patterns.length; i++) { + const pattern = patterns[i]; + const subjectMatch = subject && subject.match(pattern); + if (subjectMatch) { + return subjectMatch[1]; + } + const bodyMatch = body && body.match(pattern); + if (bodyMatch) { + return bodyMatch[1]; + } + } + + return null; +} + +function isCommandAvailable(command) { + const paths = (process.env.PATH || '').split(path.delimiter); + const extensions = + process.platform === 'win32' && process.env.PATHEXT + ? process.env.PATHEXT.split(';') + : ['']; + + for (let i = 0; i < paths.length; i++) { + const dir = paths[i]; + if (!dir) { + continue; + } + for (let j = 0; j < extensions.length; j++) { + const ext = extensions[j]; + const fullPath = path.join(dir, `${command}${ext}`); + try { + fs.accessSync(fullPath, fs.constants.X_OK); + return true; + } catch { + // Keep searching. + } + } + } + return false; +} + +function readChangelogSnippet(preferredPackage) { + const cacheKey = + preferredPackage === 'eslint-plugin-react-hooks' + ? preferredPackage + : 'root'; + if (!readChangelogSnippet.cache) { + readChangelogSnippet.cache = new Map(); + } + const cache = readChangelogSnippet.cache; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const targetPath = + preferredPackage === 'eslint-plugin-react-hooks' + ? path.join( + repoRoot, + 'packages', + 'eslint-plugin-react-hooks', + 'CHANGELOG.md' + ) + : path.join(repoRoot, 'CHANGELOG.md'); + + let content = ''; + try { + content = fs.readFileSync(targetPath, 'utf8'); + } catch { + content = ''; + } + + const snippet = content.slice(0, 4000); + cache.set(cacheKey, snippet); + return snippet; +} + +function sanitizeSummary(text) { + if (!text) { + return ''; + } + + const trimmed = text.trim(); + const withoutBullet = trimmed.replace(/^([-*]\s+|\d+\s*[\.)]\s+)/, ''); + + return withoutBullet.replace(/\s+/g, ' ').trim(); +} + +async function summarizePackages({ + summarizer, + packageSpecs, + packageTargets, + commitsByPackage, + log, +}) { + const summariesByPackage = new Map(); + if (!summarizer) { + packageSpecs.forEach(spec => { + const commits = commitsByPackage.get(spec.name) || []; + const summaryMap = new Map(); + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + summaryMap.set(commit.sha, commit.subject); + } + summariesByPackage.set(spec.name, summaryMap); + }); + return summariesByPackage; + } + + const tasks = packageSpecs.map(spec => { + const commits = commitsByPackage.get(spec.name) || []; + return summarizePackageCommits({ + summarizer, + spec, + commits, + packageTargets, + allPackageSpecs: packageSpecs, + log, + }); + }); + + const results = await Promise.all(tasks); + results.forEach(entry => { + summariesByPackage.set(entry.packageName, entry.summaries); + }); + return summariesByPackage; +} + +async function summarizePackageCommits({ + summarizer, + spec, + commits, + packageTargets, + allPackageSpecs, + log, +}) { + const summaries = new Map(); + if (commits.length === 0) { + return {packageName: spec.name, summaries}; + } + + const rootStyle = readChangelogSnippet('root'); + const hooksStyle = readChangelogSnippet('eslint-plugin-react-hooks'); + const targetList = allPackageSpecs.map( + targetSpec => + `${targetSpec.name}@${targetSpec.displayVersion || targetSpec.version}` + ); + const payload = commits.map(commit => { + const packages = Array.from(commit.packages || []).sort(); + const usesHooksStyle = (commit.packages || new Set()).has( + 'eslint-plugin-react-hooks' + ); + const packagesWithVersions = packages.map(pkgName => { + const targetSpec = packageTargets.get(pkgName); + if (!targetSpec) { + return pkgName; + } + return `${pkgName}@${targetSpec.displayVersion || targetSpec.version}`; + }); + return { + sha: commit.sha, + packages, + packagesWithVersions, + style: usesHooksStyle ? 'eslint-plugin-react-hooks' : 'root', + subject: commit.subject, + body: commit.body || '', + }; + }); + + const promptParts = [ + `You are preparing changelog summaries for ${spec.name} ${ + spec.displayVersion || spec.version + }.`, + 'The broader release includes:', + ...targetList.map(line => `- ${line}`), + '', + 'For each commit payload, write a single concise sentence without a leading bullet.', + 'Match the tone and formatting of the provided style samples. Do not mention commit hashes.', + 'Return a JSON array where each element has the shape `{ "sha": "", "summary": "" }`.', + 'The JSON must contain one entry per commit in the same order they are provided.', + 'Use `"root"` style unless the payload specifies `"eslint-plugin-react-hooks"`, in which case use that style sample.', + '', + '--- STYLE: root ---', + rootStyle, + '--- END STYLE ---', + '', + '--- STYLE: eslint-plugin-react-hooks ---', + hooksStyle, + '--- END STYLE ---', + '', + `Commits affecting ${spec.name}:`, + ]; + + payload.forEach((item, index) => { + promptParts.push( + `Commit ${index + 1}:`, + `sha: ${item.sha}`, + `style: ${item.style}`, + `packages: ${item.packagesWithVersions.join(', ') || 'none'}`, + `subject: ${item.subject}`, + 'body:', + item.body || '(empty)', + '' + ); + }); + promptParts.push('Return ONLY the JSON array.', ''); + + const prompt = promptParts.join('\n'); + log( + `Invoking ${summarizer} for ${payload.length} commit summaries targeting ${spec.name}.` + ); + log(`Summarizer prompt length: ${prompt.length} characters.`); + + try { + const raw = await runSummarizer(summarizer, prompt); + log(`Summarizer output length: ${raw.length}`); + const parsed = parseSummariesResponse(raw); + if (!parsed) { + throw new Error('Unable to parse summarizer output.'); + } + parsed.forEach(entry => { + const summary = sanitizeSummary(entry.summary || ''); + if (summary) { + summaries.set(entry.sha, summary); + } + }); + } catch (error) { + if (log !== noopLogger) { + log( + `Warning: failed to summarize commits for ${spec.name} with ${summarizer}. Falling back to subjects. ${error.message}` + ); + if (error && error.stack) { + log(error.stack); + } + } + } + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + if (!summaries.has(commit.sha)) { + summaries.set(commit.sha, commit.subject); + } + } + + log(`Summaries available for ${summaries.size} commit(s) for ${spec.name}.`); + + return {packageName: spec.name, summaries}; +} + +function noopLogger() {} + +async function runSummarizer(command, prompt) { + const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024}; + + if (command === 'codex') { + const {stdout} = await execFileAsync( + 'codex', + ['exec', '--json', prompt], + options + ); + return parseCodexSummary(stdout); + } + + if (command === 'claude') { + const {stdout} = await execFileAsync('claude', ['-p', prompt], options); + return stripClaudeBanner(stdout); + } + + throw new Error(`Unsupported summarizer command: ${command}`); +} + +function parseCodexSummary(output) { + let last = ''; + const lines = output.split('\n'); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed) { + continue; + } + try { + const event = JSON.parse(trimmed); + if ( + event.type === 'item.completed' && + event.item?.type === 'agent_message' + ) { + last = event.item.text || ''; + } + } catch { + last = trimmed; + } + } + return last || output; +} + +function stripClaudeBanner(text) { + return text + .split('\n') + .filter( + line => + line.trim() !== + 'Claude Code at Meta (https://fburl.com/claude.code.users)' + ) + .join('\n'); +} + +function parseSummariesResponse(raw) { + const candidates = []; + const trimmed = raw.trim(); + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fencedMatch) { + candidates.push(fencedMatch[1].trim()); + } + + const firstBracket = trimmed.indexOf('['); + if (firstBracket !== -1) { + candidates.push(trimmed.slice(firstBracket).trim()); + } + + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + if (!candidate) { + continue; + } + try { + const parsed = JSON.parse(candidate); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Try the next candidate. + } + } + + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Fall through. + } + + return null; +} + +async function fetchPullRequestMetadata(prNumber, {log}) { + log(`Fetching PR metadata for #${prNumber}...`); + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; + const requestOptions = { + hostname: 'api.github.com', + path: `/repos/facebook/react/pulls/${prNumber}`, + method: 'GET', + headers: { + 'User-Agent': 'generate-changelog-script', + Accept: 'application/vnd.github+json', + }, + }; + if (token) { + requestOptions.headers.Authorization = `Bearer ${token}`; + } + + return new Promise(resolve => { + const req = https.request(requestOptions, res => { + let raw = ''; + res.on('data', chunk => { + raw += chunk; + }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + const json = JSON.parse(raw); + resolve({ + authorLogin: json.user?.login || null, + }); + } catch (error) { + process.stderr.write( + `Warning: unable to parse GitHub response for PR #${prNumber}: ${error.message}\n` + ); + resolve(null); + } + } else { + process.stderr.write( + `Warning: GitHub API request failed for PR #${prNumber} with status ${res.statusCode}\n` + ); + resolve(null); + } + }); + }); + + req.on('error', error => { + process.stderr.write( + `Warning: GitHub API request errored for PR #${prNumber}: ${error.message}\n` + ); + resolve(null); + }); + + req.end(); + }); +} + +async function main() { + const {packageSpecs, summarizer, debug} = parseArgs(process.argv.slice(2)); + const log = debug + ? (...args) => console.log('[generate-changelog]', ...args) + : noopLogger; + const allStablePackages = Object.keys(stablePackages); + + const packageTargets = new Map(); + for (let i = 0; i < packageSpecs.length; i++) { + const spec = packageSpecs[i]; + if (!allStablePackages.includes(spec.name)) { + throw new Error( + `Package "${spec.name}" is not listed in stablePackages.` + ); + } + if (packageTargets.has(spec.name)) { + throw new Error(`Package "${spec.name}" was specified more than once.`); + } + packageTargets.set(spec.name, spec); + } + + const targetPackages = packageSpecs.map(spec => spec.name); + log( + `Starting changelog generation for: ${packageSpecs + .map(spec => `${spec.name}@${spec.displayVersion || spec.version}`) + .join(', ')}` + ); + + const packageInfoMap = new Map(); + const packageInfoResults = await Promise.all( + targetPackages.map(async pkg => { + const info = await fetchNpmInfo(pkg, {log}); + return {pkg, info}; + }) + ); + for (let i = 0; i < packageInfoResults.length; i++) { + const entry = packageInfoResults[i]; + packageInfoMap.set(entry.pkg, entry.info); + } + + const commitPackagesMap = new Map(); + const commitCollections = await Promise.all( + targetPackages.map(async pkg => { + const {gitHead} = packageInfoMap.get(pkg); + const commits = await collectCommitsSince(pkg, gitHead, {log}); + log(`Package ${pkg} has ${commits.length} commit(s) since ${gitHead}.`); + return {pkg, commits}; + }) + ); + for (let i = 0; i < commitCollections.length; i++) { + const entry = commitCollections[i]; + const pkg = entry.pkg; + const commits = entry.commits; + for (let j = 0; j < commits.length; j++) { + const sha = commits[j]; + if (!commitPackagesMap.has(sha)) { + commitPackagesMap.set(sha, new Set()); + } + commitPackagesMap.get(sha).add(pkg); + } + } + log(`Found ${commitPackagesMap.size} commits touching target packages.`); + + if (commitPackagesMap.size === 0) { + console.log('No commits found for the selected packages.'); + return; + } + + const commitDetails = await Promise.all( + Array.from(commitPackagesMap.entries()).map( + async ([sha, packagesTouched]) => { + const detail = await loadCommitDetails(sha, {log}); + detail.packages = packagesTouched; + detail.prNumber = extractPrNumber(detail.subject, detail.body); + return detail; + } + ) + ); + + commitDetails.sort((a, b) => a.timestamp - b.timestamp); + log(`Ordered ${commitDetails.length} commit(s) chronologically.`); + + const commitsByPackage = new Map(); + commitDetails.forEach(commit => { + commit.packages.forEach(pkgName => { + if (!commitsByPackage.has(pkgName)) { + commitsByPackage.set(pkgName, []); + } + commitsByPackage.get(pkgName).push(commit); + }); + }); + + const uniquePrNumbers = Array.from( + new Set(commitDetails.map(commit => commit.prNumber).filter(Boolean)) + ); + log(`Identified ${uniquePrNumbers.length} unique PR number(s).`); + + const prMetadata = new Map(); + log(`Summarizer selected: ${summarizer || 'none (using commit titles)'}`); + const prMetadataResults = await Promise.all( + uniquePrNumbers.map(async prNumber => { + const meta = await fetchPullRequestMetadata(prNumber, {log}); + return {prNumber, meta}; + }) + ); + for (let i = 0; i < prMetadataResults.length; i++) { + const entry = prMetadataResults[i]; + if (entry.meta) { + prMetadata.set(entry.prNumber, entry.meta); + } + } + log(`Fetched metadata for ${prMetadata.size} PR(s).`); + + const summariesByPackage = await summarizePackages({ + summarizer, + packageSpecs, + packageTargets, + commitsByPackage, + log, + }); + + const outputLines = []; + for (let i = 0; i < packageSpecs.length; i++) { + const spec = packageSpecs[i]; + outputLines.push(`## ${spec.name}@${spec.displayVersion || spec.version}`); + const commitsForPackage = commitsByPackage.get(spec.name) || []; + + if (commitsForPackage.length === 0) { + outputLines.push('* No changes since the last release.'); + outputLines.push(''); + continue; + } + + commitsForPackage.forEach(commit => { + if (commit.prNumber && prMetadata.has(commit.prNumber)) { + commit.authorLogin = prMetadata.get(commit.prNumber).authorLogin; + } + + const prFragment = commit.prNumber + ? `[#${commit.prNumber}](https://github.com/facebook/react/pull/${commit.prNumber})` + : `commit ${commit.sha.slice(0, 7)}`; + + let authorFragment = commit.authorLogin + ? `[@${commit.authorLogin}](https://github.com/${commit.authorLogin})` + : commit.authorName || 'unknown author'; + + if ( + !commit.authorLogin && + commit.authorName && + commit.authorName.startsWith('@') + ) { + const username = commit.authorName.slice(1); + authorFragment = `[@${username}](https://github.com/${username})`; + } + + const summaryMap = summariesByPackage.get(spec.name) || new Map(); + let summary = summaryMap.get(commit.sha) || commit.subject; + + if (commit.prNumber) { + const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`); + summary = summary.replace(prPattern, '').trim(); + } + + outputLines.push(`* ${summary} (${prFragment} by ${authorFragment})`); + }); + + outputLines.push(''); + } + + while (outputLines.length && outputLines[outputLines.length - 1] === '') { + outputLines.pop(); + } + + log('Generated changelog sections.'); + console.log(outputLines.join('\n')); +} + +main().catch(error => { + process.stderr.write(`${error.message}\n`); + process.exit(1); +}); From 0d721b60c2b0447f85f20f457e908a494acb28b0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 27 Oct 2025 22:06:28 +0100 Subject: [PATCH 2/7] [Flight] Don't hang after resolving cyclic references (#34988) --- .../react-client/src/ReactFlightClient.js | 16 ++++++++++++ .../src/__tests__/ReactFlightDOMEdge-test.js | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index c7506a13e5941..503f79e4cc582 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -624,6 +624,22 @@ function wakeChunkIfInitialized( rejectListeners.splice(rejectionIdx, 1); } } + // The status might have changed after fulfilling the reference. + switch ((chunk: SomeChunk).status) { + case INITIALIZED: + const initializedChunk: InitializedChunk = (chunk: any); + wakeChunk( + resolveListeners, + initializedChunk.value, + initializedChunk, + ); + return; + case ERRORED: + if (rejectListeners !== null) { + rejectChunk(rejectListeners, chunk.reason); + } + return; + } } } } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index a47c9b086a18c..9e024107fc5dd 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -2196,4 +2196,29 @@ describe('ReactFlightDOMEdge', () => { 'Switched to client rendering because the server rendering errored:\n\nssr-throw', ); }); + + it('should properly resolve with deduped objects', async () => { + const obj = {foo: 'hi'}; + + function Test(props) { + return props.obj.foo; + } + + const root = { + obj: obj, + node: , + }; + + const stream = ReactServerDOMServer.renderToReadableStream(root); + + const response = ReactServerDOMClient.createFromReadableStream(stream, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + + const result = await response; + expect(result).toEqual({obj: obj, node: 'hi'}); + }); }); From 90817f8810da9d993bf93a75b673399b112af67e Mon Sep 17 00:00:00 2001 From: Ricky Date: Mon, 27 Oct 2025 17:38:56 -0400 Subject: [PATCH 3/7] [rn] delete the legacy renderers from the sync (#34946) Now that RN is only on the New Architecture, we can stop stop syncing the legacy React Native renderers. In this diff, I just stop syncing them. In a follow up I'll delete the code for them so only Fabric is left. This will also allow us to remove the `enableLegacyMode` feature flag. --- .../workflows/runtime_commit_artifacts.yml | 7 ++-- packages/react-native-renderer/index.js | 1 + packages/react-reconciler/README.md | 2 +- scripts/rollup/bundles.js | 36 ------------------- .../rollup/shims/react-native/ReactNative.js | 1 + 5 files changed, 8 insertions(+), 39 deletions(-) diff --git a/.github/workflows/runtime_commit_artifacts.yml b/.github/workflows/runtime_commit_artifacts.yml index b982d561ed71c..2809df18159f0 100644 --- a/.github/workflows/runtime_commit_artifacts.yml +++ b/.github/workflows/runtime_commit_artifacts.yml @@ -162,10 +162,13 @@ jobs: mv build/facebook-react-native/react-is/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-is/ mv build/facebook-react-native/react-test-renderer/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-test-renderer/ - # Delete OSS renderer. OSS renderer is synced through internal script. + # Delete the OSS renderers, these are sync'd to RN separately. RENDERER_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/implementations/ + SHIM_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/shims/ rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js - rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js + + # Delete the legacy renderer shim, this is not sync'd and will get deleted in the future. + rm $SHIM_FOLDER/ReactNative.js # Copy eslint-plugin-react-hooks # NOTE: This is different from www, here we include the full package diff --git a/packages/react-native-renderer/index.js b/packages/react-native-renderer/index.js index ecc5e58c3d8d6..131c066ff44b6 100644 --- a/packages/react-native-renderer/index.js +++ b/packages/react-native-renderer/index.js @@ -12,4 +12,5 @@ import * as ReactNative from './src/ReactNativeRenderer'; // Assert that the exports line up with the type we're going to expose. (ReactNative: ReactNativeType); +// TODO: Delete the legacy renderer, only Fabric is used now. export * from './src/ReactNativeRenderer'; diff --git a/packages/react-reconciler/README.md b/packages/react-reconciler/README.md index b30387c591f16..82080513a459c 100644 --- a/packages/react-reconciler/README.md +++ b/packages/react-reconciler/README.md @@ -59,7 +59,7 @@ The examples in the React repository are declared a bit differently than a third * [React ART](https://github.com/facebook/react/blob/main/packages/react-art/src/ReactART.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-art/src/ReactFiberConfigART.js) * [React DOM](https://github.com/facebook/react/blob/main/packages/react-dom/src/client/ReactDOM.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js) -* [React Native](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactNativeRenderer.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactFiberConfigNative.js) +* [React Native](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactFabric.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactFiberConfigFabric.js) If these links break please file an issue and we’ll fix them. They intentionally link to the latest versions since the API is still evolving. If you have more questions please file an issue and we’ll try to help! diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0a7b17ec2cc7f..69d0b534fce61 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -819,42 +819,6 @@ const bundles = [ }), }, - /******* React Native *******/ - { - bundleTypes: __EXPERIMENTAL__ - ? [] - : [RN_FB_DEV, RN_FB_PROD, RN_FB_PROFILING], - moduleType: RENDERER, - entry: 'react-native-renderer', - global: 'ReactNativeRenderer', - externals: ['react-native', 'ReactNativeInternalFeatureFlags'], - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: true, - babel: opts => - Object.assign({}, opts, { - plugins: opts.plugins.concat([ - [require.resolve('@babel/plugin-transform-classes'), {loose: true}], - ]), - }), - }, - { - bundleTypes: [RN_OSS_DEV, RN_OSS_PROD, RN_OSS_PROFILING], - moduleType: RENDERER, - entry: 'react-native-renderer', - global: 'ReactNativeRenderer', - // ReactNativeInternalFeatureFlags temporary until we land enableRemoveConsolePatches. - // Needs to be done before the next RN OSS release. - externals: ['react-native', 'ReactNativeInternalFeatureFlags'], - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: true, - babel: opts => - Object.assign({}, opts, { - plugins: opts.plugins.concat([ - [require.resolve('@babel/plugin-transform-classes'), {loose: true}], - ]), - }), - }, - /******* React Native Fabric *******/ { bundleTypes: __EXPERIMENTAL__ diff --git a/scripts/rollup/shims/react-native/ReactNative.js b/scripts/rollup/shims/react-native/ReactNative.js index 82c062bb85123..4e7ab19ae6e62 100644 --- a/scripts/rollup/shims/react-native/ReactNative.js +++ b/scripts/rollup/shims/react-native/ReactNative.js @@ -14,6 +14,7 @@ import type {ReactNativeType} from './ReactNativeTypes'; let ReactNative: ReactNativeType; +// TODO: Delete the legacy renderer. Only ReactFabric is used now. if (__DEV__) { ReactNative = require('../implementations/ReactNativeRenderer-dev'); } else { From dd53a946ecf4805ea0ecb885e538f69e8e3cbd61 Mon Sep 17 00:00:00 2001 From: Ricky Date: Mon, 27 Oct 2025 17:48:33 -0400 Subject: [PATCH 4/7] [rn] enabled disableLegacyMode everywhere (#34947) Stacked on https://github.com/facebook/react/pull/34946 This should be a noop, now that the legacy renderers are not being sync'd. --- .github/workflows/runtime_commit_artifacts.yml | 2 +- .../src/__tests__/ReactFabricAndNative-test.internal.js | 8 ++++++++ packages/shared/forks/ReactFeatureFlags.native-fb.js | 2 +- packages/shared/forks/ReactFeatureFlags.native-oss.js | 2 +- .../forks/ReactFeatureFlags.test-renderer.native-fb.js | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/runtime_commit_artifacts.yml b/.github/workflows/runtime_commit_artifacts.yml index 2809df18159f0..1b98673cd4dd6 100644 --- a/.github/workflows/runtime_commit_artifacts.yml +++ b/.github/workflows/runtime_commit_artifacts.yml @@ -164,10 +164,10 @@ jobs: # Delete the OSS renderers, these are sync'd to RN separately. RENDERER_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/implementations/ - SHIM_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/shims/ rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js # Delete the legacy renderer shim, this is not sync'd and will get deleted in the future. + SHIM_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/shims/ rm $SHIM_FOLDER/ReactNative.js # Copy eslint-plugin-react-hooks diff --git a/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js index 4e0fcad9c80c3..0b2f46b4d5dca 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js @@ -40,6 +40,7 @@ describe('created with ReactFabric called with ReactNative', () => { require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface').getNativeTagFromPublicInstance; }); + // @gate !disableLegacyMode it('find Fabric instances with the RN renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -60,6 +61,7 @@ describe('created with ReactFabric called with ReactNative', () => { expect(getNativeTagFromPublicInstance(instance)).toBe(2); }); + // @gate !disableLegacyMode it('find Fabric nodes with the RN renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -80,6 +82,7 @@ describe('created with ReactFabric called with ReactNative', () => { expect(handle).toBe(2); }); + // @gate !disableLegacyMode it('dispatches commands on Fabric nodes with the RN renderer', () => { nativeFabricUIManager.dispatchCommand.mockClear(); const View = createReactNativeComponentClass('RCTView', () => ({ @@ -101,6 +104,7 @@ describe('created with ReactFabric called with ReactNative', () => { expect(UIManager.dispatchViewManagerCommand).not.toBeCalled(); }); + // @gate !disableLegacyMode it('dispatches sendAccessibilityEvent on Fabric nodes with the RN renderer', () => { nativeFabricUIManager.sendAccessibilityEvent.mockClear(); const View = createReactNativeComponentClass('RCTView', () => ({ @@ -143,6 +147,7 @@ describe('created with ReactNative called with ReactFabric', () => { .ReactNativeViewConfigRegistry.register; }); + // @gate !disableLegacyMode it('find Paper instances with the Fabric renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -163,6 +168,7 @@ describe('created with ReactNative called with ReactFabric', () => { expect(instance._nativeTag).toBe(3); }); + // @gate !disableLegacyMode it('find Paper nodes with the Fabric renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -183,6 +189,7 @@ describe('created with ReactNative called with ReactFabric', () => { expect(handle).toBe(3); }); + // @gate !disableLegacyMode it('dispatches commands on Paper nodes with the Fabric renderer', () => { UIManager.dispatchViewManagerCommand.mockReset(); const View = createReactNativeComponentClass('RCTView', () => ({ @@ -205,6 +212,7 @@ describe('created with ReactNative called with ReactFabric', () => { expect(nativeFabricUIManager.dispatchCommand).not.toBeCalled(); }); + // @gate !disableLegacyMode it('dispatches sendAccessibilityEvent on Paper nodes with the Fabric renderer', () => { ReactNativePrivateInterface.legacySendAccessibilityEvent.mockReset(); const View = createReactNativeComponentClass('RCTView', () => ({ diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index afecaa685faa5..dd0bd8624f326 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -35,7 +35,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const disableLegacyContext: boolean = false; export const disableLegacyContextForFunctionComponents: boolean = false; -export const disableLegacyMode: boolean = false; +export const disableLegacyMode: boolean = true; export const disableSchedulerTimeoutInWorkLoop: boolean = false; export const disableTextareaChildren: boolean = false; export const enableAsyncDebugInfo: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 7cabeb526a2bc..555307cef00a7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -21,7 +21,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const disableLegacyContext: boolean = true; export const disableLegacyContextForFunctionComponents: boolean = true; -export const disableLegacyMode: boolean = false; +export const disableLegacyMode: boolean = true; export const disableSchedulerTimeoutInWorkLoop: boolean = false; export const disableTextareaChildren: boolean = false; export const enableAsyncDebugInfo: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index eb56da603d309..0ff044250cb2e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -16,7 +16,7 @@ export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; export const disableLegacyContext = false; export const disableLegacyContextForFunctionComponents = false; -export const disableLegacyMode = false; +export const disableLegacyMode = true; export const disableSchedulerTimeoutInWorkLoop = false; export const disableTextareaChildren = false; export const enableAsyncDebugInfo = false; From 69f3e9d0346a04af55de05a08d7aa2df17035849 Mon Sep 17 00:00:00 2001 From: lauren Date: Mon, 27 Oct 2025 17:54:09 -0400 Subject: [PATCH 5/7] [generate-changelog] Add `--format` option (#34992) Adds a new `--format` option which can be `text` (default), `csv`, or `json`. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34992). * #34993 * __->__ #34992 --- scripts/tasks/generate-changelog.js | 246 ++++++++++++++++++++++++---- 1 file changed, 213 insertions(+), 33 deletions(-) diff --git a/scripts/tasks/generate-changelog.js b/scripts/tasks/generate-changelog.js index 158346d2d9ab3..17818573a961d 100644 --- a/scripts/tasks/generate-changelog.js +++ b/scripts/tasks/generate-changelog.js @@ -23,7 +23,7 @@ const repoRoot = path.resolve(__dirname, '..', '..'); function parseArgs(argv) { const parser = yargs(argv) .usage( - 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [ ...]' + 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [--format ] [ ...]' ) .example( '$0 --codex eslint-plugin-react-hooks@7.0.1', @@ -50,6 +50,12 @@ function parseArgs(argv) { describe: 'Enable verbose debug logging.', default: false, }) + .option('format', { + type: 'string', + describe: 'Output format for the generated changelog.', + choices: ['text', 'csv', 'json'], + default: 'text', + }) .help('help') .alias('h', 'help') .version(false) @@ -61,6 +67,7 @@ function parseArgs(argv) { const args = parser.scriptName('generate-changelog').parse(); const packageSpecs = []; const debug = !!args.debug; + const format = args.format || 'text'; let summarizer = null; if (args.codex && args.claude) { throw new Error('Choose either --codex or --claude, not both.'); @@ -123,6 +130,7 @@ function parseArgs(argv) { return { debug, + format, summarizer, packageSpecs, }; @@ -484,6 +492,22 @@ async function summarizePackageCommits({ function noopLogger() {} +function escapeCsvValue(value) { + if (value == null) { + return ''; + } + + const stringValue = String(value).replace(/\r?\n|\r/g, ' '); + if (stringValue.includes('"') || stringValue.includes(',')) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; +} + +function toCsvRow(values) { + return values.map(escapeCsvValue).join(','); +} + async function runSummarizer(command, prompt) { const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024}; @@ -634,7 +658,9 @@ async function fetchPullRequestMetadata(prNumber, {log}) { } async function main() { - const {packageSpecs, summarizer, debug} = parseArgs(process.argv.slice(2)); + const {packageSpecs, summarizer, debug, format} = parseArgs( + process.argv.slice(2) + ); const log = debug ? (...args) => console.log('[generate-changelog]', ...args) : noopLogger; @@ -754,60 +780,214 @@ async function main() { log, }); - const outputLines = []; + const noChangesMessage = 'No changes since the last release.'; + const changelogEntries = []; for (let i = 0; i < packageSpecs.length; i++) { const spec = packageSpecs[i]; - outputLines.push(`## ${spec.name}@${spec.displayVersion || spec.version}`); + const versionText = spec.displayVersion || spec.version; const commitsForPackage = commitsByPackage.get(spec.name) || []; + const entry = { + package: spec.name, + version: versionText, + hasChanges: commitsForPackage.length > 0, + commits: [], + note: null, + }; - if (commitsForPackage.length === 0) { - outputLines.push('* No changes since the last release.'); - outputLines.push(''); + if (!entry.hasChanges) { + entry.note = noChangesMessage; + changelogEntries.push(entry); continue; } - commitsForPackage.forEach(commit => { + const summaryMap = summariesByPackage.get(spec.name) || new Map(); + entry.commits = commitsForPackage.map(commit => { if (commit.prNumber && prMetadata.has(commit.prNumber)) { - commit.authorLogin = prMetadata.get(commit.prNumber).authorLogin; + const metadata = prMetadata.get(commit.prNumber); + if (metadata && metadata.authorLogin) { + commit.authorLogin = metadata.authorLogin; + } } - const prFragment = commit.prNumber - ? `[#${commit.prNumber}](https://github.com/facebook/react/pull/${commit.prNumber})` - : `commit ${commit.sha.slice(0, 7)}`; + let summary = summaryMap.get(commit.sha) || commit.subject; + if (commit.prNumber) { + const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`); + summary = summary.replace(prPattern, '').trim(); + } - let authorFragment = commit.authorLogin - ? `[@${commit.authorLogin}](https://github.com/${commit.authorLogin})` - : commit.authorName || 'unknown author'; + const prNumber = commit.prNumber || null; + const prUrl = prNumber + ? `https://github.com/facebook/react/pull/${prNumber}` + : null; + const commitSha = commit.sha; + const commitUrl = `https://github.com/facebook/react/commit/${commitSha}`; + + const authorLogin = commit.authorLogin || null; + const authorName = commit.authorName || null; + const authorEmail = commit.authorEmail || null; + + let authorUrl = null; + let authorDisplay = authorName || 'unknown author'; + + if (authorLogin) { + authorUrl = `https://github.com/${authorLogin}`; + authorDisplay = `[@${authorLogin}](${authorUrl})`; + } else if (authorName && authorName.startsWith('@')) { + const username = authorName.slice(1); + authorUrl = `https://github.com/${username}`; + authorDisplay = `[@${username}](${authorUrl})`; + } - if ( - !commit.authorLogin && - commit.authorName && - commit.authorName.startsWith('@') - ) { - const username = commit.authorName.slice(1); - authorFragment = `[@${username}](https://github.com/${username})`; + const referenceDisplay = prNumber + ? `[#${prNumber}](${prUrl})` + : `commit ${commitSha.slice(0, 7)}`; + const referenceType = prNumber ? 'pr' : 'commit'; + const referenceId = prNumber ? `#${prNumber}` : commitSha.slice(0, 7); + const referenceUrl = prNumber ? prUrl : commitUrl; + + return { + summary, + prNumber, + prUrl, + commitSha, + commitUrl, + authorLogin, + authorName, + authorEmail, + authorUrl, + authorDisplay, + referenceDisplay, + referenceType, + referenceId, + referenceUrl, + }; + }); + + changelogEntries.push(entry); + } + + log('Generated changelog sections.'); + if (format === 'text') { + const outputLines = []; + for (let i = 0; i < changelogEntries.length; i++) { + const entry = changelogEntries[i]; + outputLines.push(`## ${entry.package}@${entry.version}`); + if (!entry.hasChanges) { + outputLines.push(`* ${entry.note}`); + outputLines.push(''); + continue; } - const summaryMap = summariesByPackage.get(spec.name) || new Map(); - let summary = summaryMap.get(commit.sha) || commit.subject; + entry.commits.forEach(commit => { + outputLines.push( + `* ${commit.summary} (${commit.referenceDisplay} by ${commit.authorDisplay})` + ); + }); + outputLines.push(''); + } - if (commit.prNumber) { - const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`); - summary = summary.replace(prPattern, '').trim(); + while (outputLines.length && outputLines[outputLines.length - 1] === '') { + outputLines.pop(); + } + + console.log(outputLines.join('\n')); + return; + } + + if (format === 'csv') { + const header = [ + 'package', + 'version', + 'summary', + 'reference_type', + 'reference_id', + 'reference_url', + 'author_name', + 'author_login', + 'author_url', + 'author_email', + 'commit_sha', + 'commit_url', + ]; + const rows = [header]; + changelogEntries.forEach(entry => { + if (!entry.hasChanges) { + rows.push([ + entry.package, + entry.version, + entry.note, + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]); + return; } - outputLines.push(`* ${summary} (${prFragment} by ${authorFragment})`); + entry.commits.forEach(commit => { + const authorName = + commit.authorName || + (commit.authorLogin ? `@${commit.authorLogin}` : 'unknown author'); + rows.push([ + entry.package, + entry.version, + commit.summary, + commit.referenceType, + commit.referenceId, + commit.referenceUrl, + authorName, + commit.authorLogin || '', + commit.authorUrl || '', + commit.authorEmail || '', + commit.commitSha, + commit.commitUrl, + ]); + }); }); - outputLines.push(''); + const csvLines = rows.map(toCsvRow); + console.log(csvLines.join('\n')); + return; } - while (outputLines.length && outputLines[outputLines.length - 1] === '') { - outputLines.pop(); + if (format === 'json') { + const payload = changelogEntries.map(entry => ({ + package: entry.package, + version: entry.version, + hasChanges: entry.hasChanges, + note: entry.hasChanges ? undefined : entry.note, + commits: entry.commits.map(commit => ({ + summary: commit.summary, + prNumber: commit.prNumber, + prUrl: commit.prUrl, + commitSha: commit.commitSha, + commitUrl: commit.commitUrl, + author: { + login: commit.authorLogin, + name: commit.authorName, + email: commit.authorEmail, + url: commit.authorUrl, + display: commit.authorDisplay, + }, + reference: { + type: commit.referenceType, + id: commit.referenceId, + url: commit.referenceUrl, + label: commit.referenceDisplay, + }, + })), + })); + + console.log(JSON.stringify(payload, null, 2)); + return; } - log('Generated changelog sections.'); - console.log(outputLines.join('\n')); + throw new Error(`Unsupported format: ${format}`); } main().catch(error => { From 17b3765244b83e4c6f7a8c9b78ea4c8fa3a35622 Mon Sep 17 00:00:00 2001 From: lauren Date: Mon, 27 Oct 2025 18:04:48 -0400 Subject: [PATCH 6/7] [generate-changelog] Refactor (#34993) Just a light reorganization. --- package.json | 2 +- scripts/tasks/generate-changelog.js | 996 ------------------ scripts/tasks/generate-changelog/args.js | 128 +++ scripts/tasks/generate-changelog/data.js | 190 ++++ .../tasks/generate-changelog/formatters.js | 228 ++++ scripts/tasks/generate-changelog/index.js | 158 +++ scripts/tasks/generate-changelog/summaries.js | 306 ++++++ scripts/tasks/generate-changelog/utils.js | 62 ++ 8 files changed, 1073 insertions(+), 997 deletions(-) delete mode 100644 scripts/tasks/generate-changelog.js create mode 100644 scripts/tasks/generate-changelog/args.js create mode 100644 scripts/tasks/generate-changelog/data.js create mode 100644 scripts/tasks/generate-changelog/formatters.js create mode 100644 scripts/tasks/generate-changelog/index.js create mode 100644 scripts/tasks/generate-changelog/summaries.js create mode 100644 scripts/tasks/generate-changelog/utils.js diff --git a/package.json b/package.json index 9fb3082f0c69c..1b88aa171a811 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "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", "check-release-dependencies": "node ./scripts/release/check-release-dependencies", "generate-inline-fizz-runtime": "node ./scripts/rollup/generate-inline-fizz-runtime.js", - "generate-changelog": "node ./scripts/tasks/generate-changelog.js", + "generate-changelog": "node ./scripts/tasks/generate-changelog/index.js", "flags": "node ./scripts/flags/flags.js" }, "resolutions": { diff --git a/scripts/tasks/generate-changelog.js b/scripts/tasks/generate-changelog.js deleted file mode 100644 index 17818573a961d..0000000000000 --- a/scripts/tasks/generate-changelog.js +++ /dev/null @@ -1,996 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const {execFile} = require('child_process'); -const {promisify} = require('util'); -const semver = require('semver'); -const yargs = require('yargs/yargs'); - -const {stablePackages} = require('../../ReactVersions'); - -const execFileAsync = promisify(execFile); -const repoRoot = path.resolve(__dirname, '..', '..'); - -function parseArgs(argv) { - const parser = yargs(argv) - .usage( - 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [--format ] [ ...]' - ) - .example( - '$0 --codex eslint-plugin-react-hooks@7.0.1', - 'Generate changelog for a single package using Codex.' - ) - .example( - '$0 --claude react@19.3 react-dom@19.3', - 'Generate changelog entries for multiple packages using Claude.' - ) - .example( - '$0 --codex', - 'Generate changelog for all stable packages using recorded versions.' - ) - .option('codex', { - type: 'boolean', - describe: 'Use Codex for commit summarization.', - }) - .option('claude', { - type: 'boolean', - describe: 'Use Claude for commit summarization.', - }) - .option('debug', { - type: 'boolean', - describe: 'Enable verbose debug logging.', - default: false, - }) - .option('format', { - type: 'string', - describe: 'Output format for the generated changelog.', - choices: ['text', 'csv', 'json'], - default: 'text', - }) - .help('help') - .alias('h', 'help') - .version(false) - .parserConfiguration({ - 'parse-numbers': false, - 'parse-positional-numbers': false, - }); - - const args = parser.scriptName('generate-changelog').parse(); - const packageSpecs = []; - const debug = !!args.debug; - const format = args.format || 'text'; - let summarizer = null; - if (args.codex && args.claude) { - throw new Error('Choose either --codex or --claude, not both.'); - } - if (args.codex) { - summarizer = 'codex'; - } else if (args.claude) { - summarizer = 'claude'; - } - - const positionalArgs = Array.isArray(args._) ? args._ : []; - for (let i = 0; i < positionalArgs.length; i++) { - const token = String(positionalArgs[i]).trim(); - if (!token) { - continue; - } - - const atIndex = token.lastIndexOf('@'); - if (atIndex <= 0 || atIndex === token.length - 1) { - throw new Error(`Invalid package specification: ${token}`); - } - - const packageName = token.slice(0, atIndex); - const versionText = token.slice(atIndex + 1); - const validVersion = - semver.valid(versionText) || semver.valid(semver.coerce(versionText)); - if (!validVersion) { - throw new Error(`Invalid version for ${packageName}: ${versionText}`); - } - - packageSpecs.push({ - name: packageName, - version: validVersion, - displayVersion: versionText, - }); - } - - if (packageSpecs.length === 0) { - Object.keys(stablePackages).forEach(pkgName => { - const versionText = stablePackages[pkgName]; - const validVersion = semver.valid(versionText); - if (!validVersion) { - throw new Error( - `Invalid stable version configured for ${pkgName}: ${versionText}` - ); - } - packageSpecs.push({ - name: pkgName, - version: validVersion, - displayVersion: versionText, - }); - }); - } - - if (summarizer && !isCommandAvailable(summarizer)) { - throw new Error( - `Requested summarizer "${summarizer}" is not available on the PATH.` - ); - } - - return { - debug, - format, - summarizer, - packageSpecs, - }; -} - -async function fetchNpmInfo(packageName, {log}) { - const npmArgs = ['view', `${packageName}@latest`, '--json']; - const options = {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024}; - log(`Fetching npm info for ${packageName}...`); - const {stdout} = await execFileAsync('npm', npmArgs, options); - - let data = stdout.trim(); - if (!data) { - throw new Error(`npm view returned empty result for ${packageName}`); - } - - let info = JSON.parse(data); - if (Array.isArray(info)) { - info = info[info.length - 1]; - } - - const version = info.version || info['dist-tags']?.latest; - let gitHead = info.gitHead || null; - - if (!gitHead) { - const gitHeadResult = await execFileAsync( - 'npm', - ['view', `${packageName}@${version}`, 'gitHead'], - {cwd: repoRoot, maxBuffer: 1024 * 1024} - ); - const possibleGitHead = gitHeadResult.stdout.trim(); - if ( - possibleGitHead && - possibleGitHead !== 'undefined' && - possibleGitHead !== 'null' - ) { - log(`Found gitHead for ${packageName}@${version}: ${possibleGitHead}`); - gitHead = possibleGitHead; - } - } - - if (!version) { - throw new Error( - `Unable to determine latest published version for ${packageName}` - ); - } - if (!gitHead) { - throw new Error( - `Unable to determine git commit for ${packageName}@${version}` - ); - } - - return { - publishedVersion: version, - gitHead, - }; -} - -async function collectCommitsSince(packageName, sinceGitSha, {log}) { - log(`Collecting commits for ${packageName} since ${sinceGitSha}...`); - await execFileAsync('git', ['cat-file', '-e', `${sinceGitSha}^{commit}`], { - cwd: repoRoot, - }); - const {stdout} = await execFileAsync( - 'git', - [ - 'rev-list', - '--reverse', - `${sinceGitSha}..HEAD`, - '--', - path.posix.join('packages', packageName), - ], - {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} - ); - - return stdout - .trim() - .split('\n') - .map(line => line.trim()) - .filter(Boolean); -} - -async function loadCommitDetails(sha, {log}) { - log(`Loading commit details for ${sha}...`); - const format = ['%H', '%s', '%an', '%ae', '%ct', '%B'].join('%n'); - const {stdout} = await execFileAsync( - 'git', - ['show', '--quiet', `--format=${format}`, sha], - {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} - ); - - const [commitSha, subject, authorName, authorEmail, timestamp, ...rest] = - stdout.split('\n'); - const body = rest.join('\n').trim(); - - return { - sha: commitSha.trim(), - subject: subject.trim(), - authorName: authorName.trim(), - authorEmail: authorEmail.trim(), - timestamp: +timestamp.trim() || 0, - body, - }; -} - -function extractPrNumber(subject, body) { - const patterns = [ - /\(#(\d+)\)/, - /https:\/\/github\.com\/facebook\/react\/pull\/(\d+)/, - ]; - - for (let i = 0; i < patterns.length; i++) { - const pattern = patterns[i]; - const subjectMatch = subject && subject.match(pattern); - if (subjectMatch) { - return subjectMatch[1]; - } - const bodyMatch = body && body.match(pattern); - if (bodyMatch) { - return bodyMatch[1]; - } - } - - return null; -} - -function isCommandAvailable(command) { - const paths = (process.env.PATH || '').split(path.delimiter); - const extensions = - process.platform === 'win32' && process.env.PATHEXT - ? process.env.PATHEXT.split(';') - : ['']; - - for (let i = 0; i < paths.length; i++) { - const dir = paths[i]; - if (!dir) { - continue; - } - for (let j = 0; j < extensions.length; j++) { - const ext = extensions[j]; - const fullPath = path.join(dir, `${command}${ext}`); - try { - fs.accessSync(fullPath, fs.constants.X_OK); - return true; - } catch { - // Keep searching. - } - } - } - return false; -} - -function readChangelogSnippet(preferredPackage) { - const cacheKey = - preferredPackage === 'eslint-plugin-react-hooks' - ? preferredPackage - : 'root'; - if (!readChangelogSnippet.cache) { - readChangelogSnippet.cache = new Map(); - } - const cache = readChangelogSnippet.cache; - if (cache.has(cacheKey)) { - return cache.get(cacheKey); - } - - const targetPath = - preferredPackage === 'eslint-plugin-react-hooks' - ? path.join( - repoRoot, - 'packages', - 'eslint-plugin-react-hooks', - 'CHANGELOG.md' - ) - : path.join(repoRoot, 'CHANGELOG.md'); - - let content = ''; - try { - content = fs.readFileSync(targetPath, 'utf8'); - } catch { - content = ''; - } - - const snippet = content.slice(0, 4000); - cache.set(cacheKey, snippet); - return snippet; -} - -function sanitizeSummary(text) { - if (!text) { - return ''; - } - - const trimmed = text.trim(); - const withoutBullet = trimmed.replace(/^([-*]\s+|\d+\s*[\.)]\s+)/, ''); - - return withoutBullet.replace(/\s+/g, ' ').trim(); -} - -async function summarizePackages({ - summarizer, - packageSpecs, - packageTargets, - commitsByPackage, - log, -}) { - const summariesByPackage = new Map(); - if (!summarizer) { - packageSpecs.forEach(spec => { - const commits = commitsByPackage.get(spec.name) || []; - const summaryMap = new Map(); - for (let i = 0; i < commits.length; i++) { - const commit = commits[i]; - summaryMap.set(commit.sha, commit.subject); - } - summariesByPackage.set(spec.name, summaryMap); - }); - return summariesByPackage; - } - - const tasks = packageSpecs.map(spec => { - const commits = commitsByPackage.get(spec.name) || []; - return summarizePackageCommits({ - summarizer, - spec, - commits, - packageTargets, - allPackageSpecs: packageSpecs, - log, - }); - }); - - const results = await Promise.all(tasks); - results.forEach(entry => { - summariesByPackage.set(entry.packageName, entry.summaries); - }); - return summariesByPackage; -} - -async function summarizePackageCommits({ - summarizer, - spec, - commits, - packageTargets, - allPackageSpecs, - log, -}) { - const summaries = new Map(); - if (commits.length === 0) { - return {packageName: spec.name, summaries}; - } - - const rootStyle = readChangelogSnippet('root'); - const hooksStyle = readChangelogSnippet('eslint-plugin-react-hooks'); - const targetList = allPackageSpecs.map( - targetSpec => - `${targetSpec.name}@${targetSpec.displayVersion || targetSpec.version}` - ); - const payload = commits.map(commit => { - const packages = Array.from(commit.packages || []).sort(); - const usesHooksStyle = (commit.packages || new Set()).has( - 'eslint-plugin-react-hooks' - ); - const packagesWithVersions = packages.map(pkgName => { - const targetSpec = packageTargets.get(pkgName); - if (!targetSpec) { - return pkgName; - } - return `${pkgName}@${targetSpec.displayVersion || targetSpec.version}`; - }); - return { - sha: commit.sha, - packages, - packagesWithVersions, - style: usesHooksStyle ? 'eslint-plugin-react-hooks' : 'root', - subject: commit.subject, - body: commit.body || '', - }; - }); - - const promptParts = [ - `You are preparing changelog summaries for ${spec.name} ${ - spec.displayVersion || spec.version - }.`, - 'The broader release includes:', - ...targetList.map(line => `- ${line}`), - '', - 'For each commit payload, write a single concise sentence without a leading bullet.', - 'Match the tone and formatting of the provided style samples. Do not mention commit hashes.', - 'Return a JSON array where each element has the shape `{ "sha": "", "summary": "" }`.', - 'The JSON must contain one entry per commit in the same order they are provided.', - 'Use `"root"` style unless the payload specifies `"eslint-plugin-react-hooks"`, in which case use that style sample.', - '', - '--- STYLE: root ---', - rootStyle, - '--- END STYLE ---', - '', - '--- STYLE: eslint-plugin-react-hooks ---', - hooksStyle, - '--- END STYLE ---', - '', - `Commits affecting ${spec.name}:`, - ]; - - payload.forEach((item, index) => { - promptParts.push( - `Commit ${index + 1}:`, - `sha: ${item.sha}`, - `style: ${item.style}`, - `packages: ${item.packagesWithVersions.join(', ') || 'none'}`, - `subject: ${item.subject}`, - 'body:', - item.body || '(empty)', - '' - ); - }); - promptParts.push('Return ONLY the JSON array.', ''); - - const prompt = promptParts.join('\n'); - log( - `Invoking ${summarizer} for ${payload.length} commit summaries targeting ${spec.name}.` - ); - log(`Summarizer prompt length: ${prompt.length} characters.`); - - try { - const raw = await runSummarizer(summarizer, prompt); - log(`Summarizer output length: ${raw.length}`); - const parsed = parseSummariesResponse(raw); - if (!parsed) { - throw new Error('Unable to parse summarizer output.'); - } - parsed.forEach(entry => { - const summary = sanitizeSummary(entry.summary || ''); - if (summary) { - summaries.set(entry.sha, summary); - } - }); - } catch (error) { - if (log !== noopLogger) { - log( - `Warning: failed to summarize commits for ${spec.name} with ${summarizer}. Falling back to subjects. ${error.message}` - ); - if (error && error.stack) { - log(error.stack); - } - } - } - - for (let i = 0; i < commits.length; i++) { - const commit = commits[i]; - if (!summaries.has(commit.sha)) { - summaries.set(commit.sha, commit.subject); - } - } - - log(`Summaries available for ${summaries.size} commit(s) for ${spec.name}.`); - - return {packageName: spec.name, summaries}; -} - -function noopLogger() {} - -function escapeCsvValue(value) { - if (value == null) { - return ''; - } - - const stringValue = String(value).replace(/\r?\n|\r/g, ' '); - if (stringValue.includes('"') || stringValue.includes(',')) { - return `"${stringValue.replace(/"/g, '""')}"`; - } - return stringValue; -} - -function toCsvRow(values) { - return values.map(escapeCsvValue).join(','); -} - -async function runSummarizer(command, prompt) { - const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024}; - - if (command === 'codex') { - const {stdout} = await execFileAsync( - 'codex', - ['exec', '--json', prompt], - options - ); - return parseCodexSummary(stdout); - } - - if (command === 'claude') { - const {stdout} = await execFileAsync('claude', ['-p', prompt], options); - return stripClaudeBanner(stdout); - } - - throw new Error(`Unsupported summarizer command: ${command}`); -} - -function parseCodexSummary(output) { - let last = ''; - const lines = output.split('\n'); - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim(); - if (!trimmed) { - continue; - } - try { - const event = JSON.parse(trimmed); - if ( - event.type === 'item.completed' && - event.item?.type === 'agent_message' - ) { - last = event.item.text || ''; - } - } catch { - last = trimmed; - } - } - return last || output; -} - -function stripClaudeBanner(text) { - return text - .split('\n') - .filter( - line => - line.trim() !== - 'Claude Code at Meta (https://fburl.com/claude.code.users)' - ) - .join('\n'); -} - -function parseSummariesResponse(raw) { - const candidates = []; - const trimmed = raw.trim(); - const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); - if (fencedMatch) { - candidates.push(fencedMatch[1].trim()); - } - - const firstBracket = trimmed.indexOf('['); - if (firstBracket !== -1) { - candidates.push(trimmed.slice(firstBracket).trim()); - } - - for (let i = 0; i < candidates.length; i++) { - const candidate = candidates[i]; - if (!candidate) { - continue; - } - try { - const parsed = JSON.parse(candidate); - if (Array.isArray(parsed)) { - return parsed; - } - } catch { - // Try the next candidate. - } - } - - try { - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return parsed; - } - } catch { - // Fall through. - } - - return null; -} - -async function fetchPullRequestMetadata(prNumber, {log}) { - log(`Fetching PR metadata for #${prNumber}...`); - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; - const requestOptions = { - hostname: 'api.github.com', - path: `/repos/facebook/react/pulls/${prNumber}`, - method: 'GET', - headers: { - 'User-Agent': 'generate-changelog-script', - Accept: 'application/vnd.github+json', - }, - }; - if (token) { - requestOptions.headers.Authorization = `Bearer ${token}`; - } - - return new Promise(resolve => { - const req = https.request(requestOptions, res => { - let raw = ''; - res.on('data', chunk => { - raw += chunk; - }); - res.on('end', () => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { - try { - const json = JSON.parse(raw); - resolve({ - authorLogin: json.user?.login || null, - }); - } catch (error) { - process.stderr.write( - `Warning: unable to parse GitHub response for PR #${prNumber}: ${error.message}\n` - ); - resolve(null); - } - } else { - process.stderr.write( - `Warning: GitHub API request failed for PR #${prNumber} with status ${res.statusCode}\n` - ); - resolve(null); - } - }); - }); - - req.on('error', error => { - process.stderr.write( - `Warning: GitHub API request errored for PR #${prNumber}: ${error.message}\n` - ); - resolve(null); - }); - - req.end(); - }); -} - -async function main() { - const {packageSpecs, summarizer, debug, format} = parseArgs( - process.argv.slice(2) - ); - const log = debug - ? (...args) => console.log('[generate-changelog]', ...args) - : noopLogger; - const allStablePackages = Object.keys(stablePackages); - - const packageTargets = new Map(); - for (let i = 0; i < packageSpecs.length; i++) { - const spec = packageSpecs[i]; - if (!allStablePackages.includes(spec.name)) { - throw new Error( - `Package "${spec.name}" is not listed in stablePackages.` - ); - } - if (packageTargets.has(spec.name)) { - throw new Error(`Package "${spec.name}" was specified more than once.`); - } - packageTargets.set(spec.name, spec); - } - - const targetPackages = packageSpecs.map(spec => spec.name); - log( - `Starting changelog generation for: ${packageSpecs - .map(spec => `${spec.name}@${spec.displayVersion || spec.version}`) - .join(', ')}` - ); - - const packageInfoMap = new Map(); - const packageInfoResults = await Promise.all( - targetPackages.map(async pkg => { - const info = await fetchNpmInfo(pkg, {log}); - return {pkg, info}; - }) - ); - for (let i = 0; i < packageInfoResults.length; i++) { - const entry = packageInfoResults[i]; - packageInfoMap.set(entry.pkg, entry.info); - } - - const commitPackagesMap = new Map(); - const commitCollections = await Promise.all( - targetPackages.map(async pkg => { - const {gitHead} = packageInfoMap.get(pkg); - const commits = await collectCommitsSince(pkg, gitHead, {log}); - log(`Package ${pkg} has ${commits.length} commit(s) since ${gitHead}.`); - return {pkg, commits}; - }) - ); - for (let i = 0; i < commitCollections.length; i++) { - const entry = commitCollections[i]; - const pkg = entry.pkg; - const commits = entry.commits; - for (let j = 0; j < commits.length; j++) { - const sha = commits[j]; - if (!commitPackagesMap.has(sha)) { - commitPackagesMap.set(sha, new Set()); - } - commitPackagesMap.get(sha).add(pkg); - } - } - log(`Found ${commitPackagesMap.size} commits touching target packages.`); - - if (commitPackagesMap.size === 0) { - console.log('No commits found for the selected packages.'); - return; - } - - const commitDetails = await Promise.all( - Array.from(commitPackagesMap.entries()).map( - async ([sha, packagesTouched]) => { - const detail = await loadCommitDetails(sha, {log}); - detail.packages = packagesTouched; - detail.prNumber = extractPrNumber(detail.subject, detail.body); - return detail; - } - ) - ); - - commitDetails.sort((a, b) => a.timestamp - b.timestamp); - log(`Ordered ${commitDetails.length} commit(s) chronologically.`); - - const commitsByPackage = new Map(); - commitDetails.forEach(commit => { - commit.packages.forEach(pkgName => { - if (!commitsByPackage.has(pkgName)) { - commitsByPackage.set(pkgName, []); - } - commitsByPackage.get(pkgName).push(commit); - }); - }); - - const uniquePrNumbers = Array.from( - new Set(commitDetails.map(commit => commit.prNumber).filter(Boolean)) - ); - log(`Identified ${uniquePrNumbers.length} unique PR number(s).`); - - const prMetadata = new Map(); - log(`Summarizer selected: ${summarizer || 'none (using commit titles)'}`); - const prMetadataResults = await Promise.all( - uniquePrNumbers.map(async prNumber => { - const meta = await fetchPullRequestMetadata(prNumber, {log}); - return {prNumber, meta}; - }) - ); - for (let i = 0; i < prMetadataResults.length; i++) { - const entry = prMetadataResults[i]; - if (entry.meta) { - prMetadata.set(entry.prNumber, entry.meta); - } - } - log(`Fetched metadata for ${prMetadata.size} PR(s).`); - - const summariesByPackage = await summarizePackages({ - summarizer, - packageSpecs, - packageTargets, - commitsByPackage, - log, - }); - - const noChangesMessage = 'No changes since the last release.'; - const changelogEntries = []; - for (let i = 0; i < packageSpecs.length; i++) { - const spec = packageSpecs[i]; - const versionText = spec.displayVersion || spec.version; - const commitsForPackage = commitsByPackage.get(spec.name) || []; - const entry = { - package: spec.name, - version: versionText, - hasChanges: commitsForPackage.length > 0, - commits: [], - note: null, - }; - - if (!entry.hasChanges) { - entry.note = noChangesMessage; - changelogEntries.push(entry); - continue; - } - - const summaryMap = summariesByPackage.get(spec.name) || new Map(); - entry.commits = commitsForPackage.map(commit => { - if (commit.prNumber && prMetadata.has(commit.prNumber)) { - const metadata = prMetadata.get(commit.prNumber); - if (metadata && metadata.authorLogin) { - commit.authorLogin = metadata.authorLogin; - } - } - - let summary = summaryMap.get(commit.sha) || commit.subject; - if (commit.prNumber) { - const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`); - summary = summary.replace(prPattern, '').trim(); - } - - const prNumber = commit.prNumber || null; - const prUrl = prNumber - ? `https://github.com/facebook/react/pull/${prNumber}` - : null; - const commitSha = commit.sha; - const commitUrl = `https://github.com/facebook/react/commit/${commitSha}`; - - const authorLogin = commit.authorLogin || null; - const authorName = commit.authorName || null; - const authorEmail = commit.authorEmail || null; - - let authorUrl = null; - let authorDisplay = authorName || 'unknown author'; - - if (authorLogin) { - authorUrl = `https://github.com/${authorLogin}`; - authorDisplay = `[@${authorLogin}](${authorUrl})`; - } else if (authorName && authorName.startsWith('@')) { - const username = authorName.slice(1); - authorUrl = `https://github.com/${username}`; - authorDisplay = `[@${username}](${authorUrl})`; - } - - const referenceDisplay = prNumber - ? `[#${prNumber}](${prUrl})` - : `commit ${commitSha.slice(0, 7)}`; - const referenceType = prNumber ? 'pr' : 'commit'; - const referenceId = prNumber ? `#${prNumber}` : commitSha.slice(0, 7); - const referenceUrl = prNumber ? prUrl : commitUrl; - - return { - summary, - prNumber, - prUrl, - commitSha, - commitUrl, - authorLogin, - authorName, - authorEmail, - authorUrl, - authorDisplay, - referenceDisplay, - referenceType, - referenceId, - referenceUrl, - }; - }); - - changelogEntries.push(entry); - } - - log('Generated changelog sections.'); - if (format === 'text') { - const outputLines = []; - for (let i = 0; i < changelogEntries.length; i++) { - const entry = changelogEntries[i]; - outputLines.push(`## ${entry.package}@${entry.version}`); - if (!entry.hasChanges) { - outputLines.push(`* ${entry.note}`); - outputLines.push(''); - continue; - } - - entry.commits.forEach(commit => { - outputLines.push( - `* ${commit.summary} (${commit.referenceDisplay} by ${commit.authorDisplay})` - ); - }); - outputLines.push(''); - } - - while (outputLines.length && outputLines[outputLines.length - 1] === '') { - outputLines.pop(); - } - - console.log(outputLines.join('\n')); - return; - } - - if (format === 'csv') { - const header = [ - 'package', - 'version', - 'summary', - 'reference_type', - 'reference_id', - 'reference_url', - 'author_name', - 'author_login', - 'author_url', - 'author_email', - 'commit_sha', - 'commit_url', - ]; - const rows = [header]; - changelogEntries.forEach(entry => { - if (!entry.hasChanges) { - rows.push([ - entry.package, - entry.version, - entry.note, - '', - '', - '', - '', - '', - '', - '', - '', - '', - ]); - return; - } - - entry.commits.forEach(commit => { - const authorName = - commit.authorName || - (commit.authorLogin ? `@${commit.authorLogin}` : 'unknown author'); - rows.push([ - entry.package, - entry.version, - commit.summary, - commit.referenceType, - commit.referenceId, - commit.referenceUrl, - authorName, - commit.authorLogin || '', - commit.authorUrl || '', - commit.authorEmail || '', - commit.commitSha, - commit.commitUrl, - ]); - }); - }); - - const csvLines = rows.map(toCsvRow); - console.log(csvLines.join('\n')); - return; - } - - if (format === 'json') { - const payload = changelogEntries.map(entry => ({ - package: entry.package, - version: entry.version, - hasChanges: entry.hasChanges, - note: entry.hasChanges ? undefined : entry.note, - commits: entry.commits.map(commit => ({ - summary: commit.summary, - prNumber: commit.prNumber, - prUrl: commit.prUrl, - commitSha: commit.commitSha, - commitUrl: commit.commitUrl, - author: { - login: commit.authorLogin, - name: commit.authorName, - email: commit.authorEmail, - url: commit.authorUrl, - display: commit.authorDisplay, - }, - reference: { - type: commit.referenceType, - id: commit.referenceId, - url: commit.referenceUrl, - label: commit.referenceDisplay, - }, - })), - })); - - console.log(JSON.stringify(payload, null, 2)); - return; - } - - throw new Error(`Unsupported format: ${format}`); -} - -main().catch(error => { - process.stderr.write(`${error.message}\n`); - process.exit(1); -}); diff --git a/scripts/tasks/generate-changelog/args.js b/scripts/tasks/generate-changelog/args.js new file mode 100644 index 0000000000000..cee42f24ecd0d --- /dev/null +++ b/scripts/tasks/generate-changelog/args.js @@ -0,0 +1,128 @@ +'use strict'; + +const semver = require('semver'); +const yargs = require('yargs/yargs'); + +const {stablePackages} = require('../../../ReactVersions'); +const {isCommandAvailable} = require('./utils'); + +function parseArgs(argv) { + const parser = yargs(argv) + .usage( + 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [--format ] [ ...]' + ) + .example( + '$0 --codex eslint-plugin-react-hooks@7.0.1', + 'Generate changelog for a single package using Codex.' + ) + .example( + '$0 --claude react@19.3 react-dom@19.3', + 'Generate changelog entries for multiple packages using Claude.' + ) + .example( + '$0 --codex', + 'Generate changelog for all stable packages using recorded versions.' + ) + .option('codex', { + type: 'boolean', + describe: 'Use Codex for commit summarization.', + }) + .option('claude', { + type: 'boolean', + describe: 'Use Claude for commit summarization.', + }) + .option('debug', { + type: 'boolean', + describe: 'Enable verbose debug logging.', + default: false, + }) + .option('format', { + type: 'string', + describe: 'Output format for the generated changelog.', + choices: ['text', 'csv', 'json'], + default: 'text', + }) + .help('help') + .alias('h', 'help') + .version(false) + .parserConfiguration({ + 'parse-numbers': false, + 'parse-positional-numbers': false, + }); + + const args = parser.scriptName('generate-changelog').parse(); + const packageSpecs = []; + const debug = !!args.debug; + const format = args.format || 'text'; + let summarizer = null; + + if (args.codex && args.claude) { + throw new Error('Choose either --codex or --claude, not both.'); + } + if (args.codex) { + summarizer = 'codex'; + } else if (args.claude) { + summarizer = 'claude'; + } + + const positionalArgs = Array.isArray(args._) ? args._ : []; + for (let i = 0; i < positionalArgs.length; i++) { + const token = String(positionalArgs[i]).trim(); + if (!token) { + continue; + } + + const atIndex = token.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === token.length - 1) { + throw new Error(`Invalid package specification: ${token}`); + } + + const packageName = token.slice(0, atIndex); + const versionText = token.slice(atIndex + 1); + const validVersion = + semver.valid(versionText) || semver.valid(semver.coerce(versionText)); + if (!validVersion) { + throw new Error(`Invalid version for ${packageName}: ${versionText}`); + } + + packageSpecs.push({ + name: packageName, + version: validVersion, + displayVersion: versionText, + }); + } + + if (packageSpecs.length === 0) { + Object.keys(stablePackages).forEach(pkgName => { + const versionText = stablePackages[pkgName]; + const validVersion = semver.valid(versionText); + if (!validVersion) { + throw new Error( + `Invalid stable version configured for ${pkgName}: ${versionText}` + ); + } + packageSpecs.push({ + name: pkgName, + version: validVersion, + displayVersion: versionText, + }); + }); + } + + if (summarizer && !isCommandAvailable(summarizer)) { + throw new Error( + `Requested summarizer "${summarizer}" is not available on the PATH.` + ); + } + + return { + debug, + format, + summarizer, + packageSpecs, + }; +} + +module.exports = { + parseArgs, +}; diff --git a/scripts/tasks/generate-changelog/data.js b/scripts/tasks/generate-changelog/data.js new file mode 100644 index 0000000000000..17e25e0a7639f --- /dev/null +++ b/scripts/tasks/generate-changelog/data.js @@ -0,0 +1,190 @@ +'use strict'; + +const https = require('https'); +const path = require('path'); + +const {execFileAsync, repoRoot} = require('./utils'); + +async function fetchNpmInfo(packageName, {log}) { + const npmArgs = ['view', `${packageName}@latest`, '--json']; + const options = {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024}; + log(`Fetching npm info for ${packageName}...`); + const {stdout} = await execFileAsync('npm', npmArgs, options); + + let data = stdout.trim(); + if (!data) { + throw new Error(`npm view returned empty result for ${packageName}`); + } + + let info = JSON.parse(data); + if (Array.isArray(info)) { + info = info[info.length - 1]; + } + + const version = info.version || info['dist-tags']?.latest; + let gitHead = info.gitHead || null; + + if (!gitHead) { + const gitHeadResult = await execFileAsync( + 'npm', + ['view', `${packageName}@${version}`, 'gitHead'], + {cwd: repoRoot, maxBuffer: 1024 * 1024} + ); + const possibleGitHead = gitHeadResult.stdout.trim(); + if ( + possibleGitHead && + possibleGitHead !== 'undefined' && + possibleGitHead !== 'null' + ) { + log(`Found gitHead for ${packageName}@${version}: ${possibleGitHead}`); + gitHead = possibleGitHead; + } + } + + if (!version) { + throw new Error( + `Unable to determine latest published version for ${packageName}` + ); + } + if (!gitHead) { + throw new Error( + `Unable to determine git commit for ${packageName}@${version}` + ); + } + + return { + publishedVersion: version, + gitHead, + }; +} + +async function collectCommitsSince(packageName, sinceGitSha, {log}) { + log(`Collecting commits for ${packageName} since ${sinceGitSha}...`); + await execFileAsync('git', ['cat-file', '-e', `${sinceGitSha}^{commit}`], { + cwd: repoRoot, + }); + const {stdout} = await execFileAsync( + 'git', + [ + 'rev-list', + '--reverse', + `${sinceGitSha}..HEAD`, + '--', + path.posix.join('packages', packageName), + ], + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} + ); + + return stdout + .trim() + .split('\n') + .map(line => line.trim()) + .filter(Boolean); +} + +async function loadCommitDetails(sha, {log}) { + log(`Loading commit details for ${sha}...`); + const format = ['%H', '%s', '%an', '%ae', '%ct', '%B'].join('%n'); + const {stdout} = await execFileAsync( + 'git', + ['show', '--quiet', `--format=${format}`, sha], + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} + ); + + const [commitSha, subject, authorName, authorEmail, timestamp, ...rest] = + stdout.split('\n'); + const body = rest.join('\n').trim(); + + return { + sha: commitSha.trim(), + subject: subject.trim(), + authorName: authorName.trim(), + authorEmail: authorEmail.trim(), + timestamp: +timestamp.trim() || 0, + body, + }; +} + +function extractPrNumber(subject, body) { + const patterns = [ + /\(#(\d+)\)/, + /https:\/\/github\.com\/facebook\/react\/pull\/(\d+)/, + ]; + + for (let i = 0; i < patterns.length; i++) { + const pattern = patterns[i]; + const subjectMatch = subject && subject.match(pattern); + if (subjectMatch) { + return subjectMatch[1]; + } + const bodyMatch = body && body.match(pattern); + if (bodyMatch) { + return bodyMatch[1]; + } + } + + return null; +} + +async function fetchPullRequestMetadata(prNumber, {log}) { + log(`Fetching PR metadata for #${prNumber}...`); + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; + const requestOptions = { + hostname: 'api.github.com', + path: `/repos/facebook/react/pulls/${prNumber}`, + method: 'GET', + headers: { + 'User-Agent': 'generate-changelog-script', + Accept: 'application/vnd.github+json', + }, + }; + if (token) { + requestOptions.headers.Authorization = `Bearer ${token}`; + } + + return new Promise(resolve => { + const req = https.request(requestOptions, res => { + let raw = ''; + res.on('data', chunk => { + raw += chunk; + }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + const json = JSON.parse(raw); + resolve({ + authorLogin: json.user?.login || null, + }); + } catch (error) { + process.stderr.write( + `Warning: unable to parse GitHub response for PR #${prNumber}: ${error.message}\n` + ); + resolve(null); + } + } else { + process.stderr.write( + `Warning: GitHub API request failed for PR #${prNumber} with status ${res.statusCode}\n` + ); + resolve(null); + } + }); + }); + + req.on('error', error => { + process.stderr.write( + `Warning: GitHub API request errored for PR #${prNumber}: ${error.message}\n` + ); + resolve(null); + }); + + req.end(); + }); +} + +module.exports = { + fetchNpmInfo, + collectCommitsSince, + loadCommitDetails, + extractPrNumber, + fetchPullRequestMetadata, +}; diff --git a/scripts/tasks/generate-changelog/formatters.js b/scripts/tasks/generate-changelog/formatters.js new file mode 100644 index 0000000000000..49ed2a6a1066b --- /dev/null +++ b/scripts/tasks/generate-changelog/formatters.js @@ -0,0 +1,228 @@ +'use strict'; + +const {toCsvRow} = require('./utils'); + +const NO_CHANGES_MESSAGE = 'No changes since the last release.'; + +function buildChangelogEntries({ + packageSpecs, + commitsByPackage, + summariesByPackage, + prMetadata, +}) { + const entries = []; + + for (let i = 0; i < packageSpecs.length; i++) { + const spec = packageSpecs[i]; + const version = spec.displayVersion || spec.version; + const commitsForPackage = commitsByPackage.get(spec.name) || []; + + if (commitsForPackage.length === 0) { + entries.push({ + package: spec.name, + version, + hasChanges: false, + note: NO_CHANGES_MESSAGE, + commits: [], + }); + continue; + } + + const summaryMap = summariesByPackage.get(spec.name) || new Map(); + const commitEntries = commitsForPackage.map(commit => { + let summary = summaryMap.get(commit.sha) || commit.subject; + if (commit.prNumber) { + const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`); + summary = summary.replace(prPattern, '').trim(); + } + + const commitSha = commit.sha; + const commitUrl = `https://github.com/facebook/react/commit/${commitSha}`; + const prNumber = commit.prNumber || null; + const prUrl = prNumber + ? `https://github.com/facebook/react/pull/${prNumber}` + : null; + const prEntry = prNumber ? prMetadata.get(prNumber) : null; + + const authorLogin = prEntry?.authorLogin || null; + const authorName = commit.authorName || null; + const authorEmail = commit.authorEmail || null; + + let authorUrl = null; + let authorDisplay = authorName || 'unknown author'; + + if (authorLogin) { + authorUrl = `https://github.com/${authorLogin}`; + authorDisplay = `[@${authorLogin}](${authorUrl})`; + } else if (authorName && authorName.startsWith('@')) { + const username = authorName.slice(1); + authorUrl = `https://github.com/${username}`; + authorDisplay = `[@${username}](${authorUrl})`; + } + + const referenceType = prNumber ? 'pr' : 'commit'; + const referenceId = prNumber ? `#${prNumber}` : commitSha.slice(0, 7); + const referenceUrl = prNumber ? prUrl : commitUrl; + const referenceLabel = prNumber + ? `[#${prNumber}](${prUrl})` + : `commit ${commitSha.slice(0, 7)}`; + + return { + summary, + prNumber, + prUrl, + commitSha, + commitUrl, + author: { + login: authorLogin, + name: authorName, + email: authorEmail, + url: authorUrl, + display: authorDisplay, + }, + reference: { + type: referenceType, + id: referenceId, + url: referenceUrl, + label: referenceLabel, + }, + }; + }); + + entries.push({ + package: spec.name, + version, + hasChanges: true, + note: null, + commits: commitEntries, + }); + } + + return entries; +} + +function renderChangelog(entries, format) { + if (format === 'text') { + const lines = []; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + lines.push(`## ${entry.package}@${entry.version}`); + if (!entry.hasChanges) { + lines.push(`* ${entry.note}`); + lines.push(''); + continue; + } + + entry.commits.forEach(commit => { + lines.push( + `* ${commit.summary} (${commit.reference.label} by ${commit.author.display})` + ); + }); + lines.push(''); + } + + while (lines.length && lines[lines.length - 1] === '') { + lines.pop(); + } + + return lines.join('\n'); + } + + if (format === 'csv') { + const header = [ + 'package', + 'version', + 'summary', + 'reference_type', + 'reference_id', + 'reference_url', + 'author_name', + 'author_login', + 'author_url', + 'author_email', + 'commit_sha', + 'commit_url', + ]; + const rows = [header]; + + entries.forEach(entry => { + if (!entry.hasChanges) { + rows.push([ + entry.package, + entry.version, + entry.note, + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]); + return; + } + + entry.commits.forEach(commit => { + const authorName = + commit.author.name || + (commit.author.login ? `@${commit.author.login}` : 'unknown author'); + rows.push([ + entry.package, + entry.version, + commit.summary, + commit.reference.type, + commit.reference.id, + commit.reference.url, + authorName, + commit.author.login || '', + commit.author.url || '', + commit.author.email || '', + commit.commitSha, + commit.commitUrl, + ]); + }); + }); + + return rows.map(toCsvRow).join('\n'); + } + + if (format === 'json') { + const payload = entries.map(entry => ({ + package: entry.package, + version: entry.version, + hasChanges: entry.hasChanges, + note: entry.hasChanges ? undefined : entry.note, + commits: entry.commits.map(commit => ({ + summary: commit.summary, + prNumber: commit.prNumber, + prUrl: commit.prUrl, + commitSha: commit.commitSha, + commitUrl: commit.commitUrl, + author: { + login: commit.author.login, + name: commit.author.name, + email: commit.author.email, + url: commit.author.url, + display: commit.author.display, + }, + reference: { + type: commit.reference.type, + id: commit.reference.id, + url: commit.reference.url, + label: commit.reference.label, + }, + })), + })); + + return JSON.stringify(payload, null, 2); + } + + throw new Error(`Unsupported format: ${format}`); +} + +module.exports = { + buildChangelogEntries, + renderChangelog, +}; diff --git a/scripts/tasks/generate-changelog/index.js b/scripts/tasks/generate-changelog/index.js new file mode 100644 index 0000000000000..816ad7959bfbe --- /dev/null +++ b/scripts/tasks/generate-changelog/index.js @@ -0,0 +1,158 @@ +'use strict'; + +const {stablePackages} = require('../../../ReactVersions'); +const {parseArgs} = require('./args'); +const { + fetchNpmInfo, + collectCommitsSince, + loadCommitDetails, + extractPrNumber, + fetchPullRequestMetadata, +} = require('./data'); +const {summarizePackages} = require('./summaries'); +const {buildChangelogEntries, renderChangelog} = require('./formatters'); +const {noopLogger} = require('./utils'); + +async function main() { + const {packageSpecs, summarizer, debug, format} = parseArgs( + process.argv.slice(2) + ); + const log = debug + ? (...args) => console.log('[generate-changelog]', ...args) + : noopLogger; + + const allStablePackages = Object.keys(stablePackages); + const packageTargets = new Map(); + for (let i = 0; i < packageSpecs.length; i++) { + const spec = packageSpecs[i]; + if (!allStablePackages.includes(spec.name)) { + throw new Error( + `Package "${spec.name}" is not listed in stablePackages.` + ); + } + if (packageTargets.has(spec.name)) { + throw new Error(`Package "${spec.name}" was specified more than once.`); + } + packageTargets.set(spec.name, spec); + } + + const targetPackages = packageSpecs.map(spec => spec.name); + log( + `Starting changelog generation for: ${packageSpecs + .map(spec => `${spec.name}@${spec.displayVersion || spec.version}`) + .join(', ')}` + ); + + const packageInfoMap = new Map(); + const packageInfoResults = await Promise.all( + targetPackages.map(async pkg => { + const info = await fetchNpmInfo(pkg, {log}); + return {pkg, info}; + }) + ); + for (let i = 0; i < packageInfoResults.length; i++) { + const entry = packageInfoResults[i]; + packageInfoMap.set(entry.pkg, entry.info); + } + + const commitPackagesMap = new Map(); + const commitCollections = await Promise.all( + targetPackages.map(async pkg => { + const {gitHead} = packageInfoMap.get(pkg); + const commits = await collectCommitsSince(pkg, gitHead, {log}); + log(`Package ${pkg} has ${commits.length} commit(s) since ${gitHead}.`); + return {pkg, commits}; + }) + ); + for (let i = 0; i < commitCollections.length; i++) { + const entry = commitCollections[i]; + const pkg = entry.pkg; + const commits = entry.commits; + for (let j = 0; j < commits.length; j++) { + const sha = commits[j]; + if (!commitPackagesMap.has(sha)) { + commitPackagesMap.set(sha, new Set()); + } + commitPackagesMap.get(sha).add(pkg); + } + } + log(`Found ${commitPackagesMap.size} commits touching target packages.`); + + if (commitPackagesMap.size === 0) { + console.log('No commits found for the selected packages.'); + return; + } + + const commitDetails = await Promise.all( + Array.from(commitPackagesMap.entries()).map( + async ([sha, packagesTouched]) => { + const detail = await loadCommitDetails(sha, {log}); + detail.packages = packagesTouched; + detail.prNumber = extractPrNumber(detail.subject, detail.body); + return detail; + } + ) + ); + + commitDetails.sort((a, b) => a.timestamp - b.timestamp); + log(`Ordered ${commitDetails.length} commit(s) chronologically.`); + + const commitsByPackage = new Map(); + commitDetails.forEach(commit => { + commit.packages.forEach(pkgName => { + if (!commitsByPackage.has(pkgName)) { + commitsByPackage.set(pkgName, []); + } + commitsByPackage.get(pkgName).push(commit); + }); + }); + + const uniquePrNumbers = Array.from( + new Set(commitDetails.map(commit => commit.prNumber).filter(Boolean)) + ); + log(`Identified ${uniquePrNumbers.length} unique PR number(s).`); + + const prMetadata = new Map(); + log(`Summarizer selected: ${summarizer || 'none (using commit titles)'}`); + const prMetadataResults = await Promise.all( + uniquePrNumbers.map(async prNumber => { + const meta = await fetchPullRequestMetadata(prNumber, {log}); + return {prNumber, meta}; + }) + ); + for (let i = 0; i < prMetadataResults.length; i++) { + const entry = prMetadataResults[i]; + if (entry.meta) { + prMetadata.set(entry.prNumber, entry.meta); + } + } + log(`Fetched metadata for ${prMetadata.size} PR(s).`); + + const summariesByPackage = await summarizePackages({ + summarizer, + packageSpecs, + packageTargets, + commitsByPackage, + log, + }); + + const changelogEntries = buildChangelogEntries({ + packageSpecs, + commitsByPackage, + summariesByPackage, + prMetadata, + }); + + log('Generated changelog sections.'); + const output = renderChangelog(changelogEntries, format); + console.log(output); +} + +if (require.main === module) { + main().catch(error => { + process.stderr.write(`${error.message}\n`); + process.exit(1); + }); +} else { + module.exports = main; +} diff --git a/scripts/tasks/generate-changelog/summaries.js b/scripts/tasks/generate-changelog/summaries.js new file mode 100644 index 0000000000000..d0b73851efd8c --- /dev/null +++ b/scripts/tasks/generate-changelog/summaries.js @@ -0,0 +1,306 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const {execFileAsync, repoRoot, noopLogger} = require('./utils'); + +function readChangelogSnippet(preferredPackage) { + const cacheKey = + preferredPackage === 'eslint-plugin-react-hooks' + ? preferredPackage + : 'root'; + if (!readChangelogSnippet.cache) { + readChangelogSnippet.cache = new Map(); + } + const cache = readChangelogSnippet.cache; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const targetPath = + preferredPackage === 'eslint-plugin-react-hooks' + ? path.join( + repoRoot, + 'packages', + 'eslint-plugin-react-hooks', + 'CHANGELOG.md' + ) + : path.join(repoRoot, 'CHANGELOG.md'); + + let content = ''; + try { + content = fs.readFileSync(targetPath, 'utf8'); + } catch { + content = ''; + } + + const snippet = content.slice(0, 4000); + cache.set(cacheKey, snippet); + return snippet; +} + +function sanitizeSummary(text) { + if (!text) { + return ''; + } + + const trimmed = text.trim(); + const withoutBullet = trimmed.replace(/^([-*]\s+|\d+\s*[\.)]\s+)/, ''); + + return withoutBullet.replace(/\s+/g, ' ').trim(); +} + +async function summarizePackages({ + summarizer, + packageSpecs, + packageTargets, + commitsByPackage, + log, +}) { + const summariesByPackage = new Map(); + if (!summarizer) { + packageSpecs.forEach(spec => { + const commits = commitsByPackage.get(spec.name) || []; + const summaryMap = new Map(); + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + summaryMap.set(commit.sha, commit.subject); + } + summariesByPackage.set(spec.name, summaryMap); + }); + return summariesByPackage; + } + + const tasks = packageSpecs.map(spec => { + const commits = commitsByPackage.get(spec.name) || []; + return summarizePackageCommits({ + summarizer, + spec, + commits, + packageTargets, + allPackageSpecs: packageSpecs, + log, + }); + }); + + const results = await Promise.all(tasks); + results.forEach(entry => { + summariesByPackage.set(entry.packageName, entry.summaries); + }); + return summariesByPackage; +} + +async function summarizePackageCommits({ + summarizer, + spec, + commits, + packageTargets, + allPackageSpecs, + log, +}) { + const summaries = new Map(); + if (commits.length === 0) { + return {packageName: spec.name, summaries}; + } + + const rootStyle = readChangelogSnippet('root'); + const hooksStyle = readChangelogSnippet('eslint-plugin-react-hooks'); + const targetList = allPackageSpecs.map( + targetSpec => + `${targetSpec.name}@${targetSpec.displayVersion || targetSpec.version}` + ); + const payload = commits.map(commit => { + const packages = Array.from(commit.packages || []).sort(); + const usesHooksStyle = (commit.packages || new Set()).has( + 'eslint-plugin-react-hooks' + ); + const packagesWithVersions = packages.map(pkgName => { + const targetSpec = packageTargets.get(pkgName); + if (!targetSpec) { + return pkgName; + } + return `${pkgName}@${targetSpec.displayVersion || targetSpec.version}`; + }); + return { + sha: commit.sha, + packages, + packagesWithVersions, + style: usesHooksStyle ? 'eslint-plugin-react-hooks' : 'root', + subject: commit.subject, + body: commit.body || '', + }; + }); + + const promptParts = [ + `You are preparing changelog summaries for ${spec.name} ${ + spec.displayVersion || spec.version + }.`, + 'The broader release includes:', + ...targetList.map(line => `- ${line}`), + '', + 'For each commit payload, write a single concise sentence without a leading bullet.', + 'Match the tone and formatting of the provided style samples. Do not mention commit hashes.', + 'Return a JSON array where each element has the shape `{ "sha": "", "summary": "" }`.', + 'The JSON must contain one entry per commit in the same order they are provided.', + 'Use `"root"` style unless the payload specifies `"eslint-plugin-react-hooks"`, in which case use that style sample.', + '', + '--- STYLE: root ---', + rootStyle, + '--- END STYLE ---', + '', + '--- STYLE: eslint-plugin-react-hooks ---', + hooksStyle, + '--- END STYLE ---', + '', + `Commits affecting ${spec.name}:`, + ]; + + payload.forEach((item, index) => { + promptParts.push( + `Commit ${index + 1}:`, + `sha: ${item.sha}`, + `style: ${item.style}`, + `packages: ${item.packagesWithVersions.join(', ') || 'none'}`, + `subject: ${item.subject}`, + 'body:', + item.body || '(empty)', + '' + ); + }); + promptParts.push('Return ONLY the JSON array.', ''); + + const prompt = promptParts.join('\n'); + log( + `Invoking ${summarizer} for ${payload.length} commit summaries targeting ${spec.name}.` + ); + log(`Summarizer prompt length: ${prompt.length} characters.`); + + try { + const raw = await runSummarizer(summarizer, prompt); + log(`Summarizer output length: ${raw.length}`); + const parsed = parseSummariesResponse(raw); + if (!parsed) { + throw new Error('Unable to parse summarizer output.'); + } + parsed.forEach(entry => { + const summary = sanitizeSummary(entry.summary || ''); + if (summary) { + summaries.set(entry.sha, summary); + } + }); + } catch (error) { + if (log !== noopLogger) { + log( + `Warning: failed to summarize commits for ${spec.name} with ${summarizer}. Falling back to subjects. ${error.message}` + ); + if (error && error.stack) { + log(error.stack); + } + } + } + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + if (!summaries.has(commit.sha)) { + summaries.set(commit.sha, commit.subject); + } + } + + log(`Summaries available for ${summaries.size} commit(s) for ${spec.name}.`); + + return {packageName: spec.name, summaries}; +} + +async function runSummarizer(command, prompt) { + const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024}; + + if (command === 'codex') { + const {stdout} = await execFileAsync( + 'codex', + ['exec', '--json', prompt], + options + ); + return parseCodexSummary(stdout); + } + + if (command === 'claude') { + const {stdout} = await execFileAsync('claude', ['-p', prompt], options); + return stripClaudeBanner(stdout); + } + + throw new Error(`Unsupported summarizer command: ${command}`); +} + +function parseCodexSummary(output) { + let last = ''; + const lines = output.split('\n'); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed) { + continue; + } + try { + const event = JSON.parse(trimmed); + if ( + event.type === 'item.completed' && + event.item?.type === 'agent_message' + ) { + last = event.item.text || ''; + } + } catch { + last = trimmed; + } + } + return last || output; +} + +function stripClaudeBanner(text) { + return text + .split('\n') + .filter( + line => + line.trim() !== + 'Claude Code at Meta (https://fburl.com/claude.code.users)' + ) + .join('\n') + .trim(); +} + +function parseSummariesResponse(output) { + const trimmed = output.trim(); + const candidates = trimmed + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + + for (let i = candidates.length - 1; i >= 0; i--) { + const candidate = candidates[i]; + if (!candidate) { + continue; + } + try { + const parsed = JSON.parse(candidate); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Try the next candidate. + } + } + + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Fall through. + } + + return null; +} + +module.exports = { + summarizePackages, +}; diff --git a/scripts/tasks/generate-changelog/utils.js b/scripts/tasks/generate-changelog/utils.js new file mode 100644 index 0000000000000..fe4069eca02d2 --- /dev/null +++ b/scripts/tasks/generate-changelog/utils.js @@ -0,0 +1,62 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const {execFile} = require('child_process'); +const {promisify} = require('util'); + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function isCommandAvailable(command) { + const paths = (process.env.PATH || '').split(path.delimiter); + const extensions = + process.platform === 'win32' && process.env.PATHEXT + ? process.env.PATHEXT.split(';') + : ['']; + + for (let i = 0; i < paths.length; i++) { + const dir = paths[i]; + if (!dir) { + continue; + } + for (let j = 0; j < extensions.length; j++) { + const ext = extensions[j]; + const fullPath = path.join(dir, `${command}${ext}`); + try { + fs.accessSync(fullPath, fs.constants.X_OK); + return true; + } catch { + // Keep searching. + } + } + } + return false; +} + +function noopLogger() {} + +function escapeCsvValue(value) { + if (value == null) { + return ''; + } + + const stringValue = String(value).replace(/\r?\n|\r/g, ' '); + if (stringValue.includes('"') || stringValue.includes(',')) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; +} + +function toCsvRow(values) { + return values.map(escapeCsvValue).join(','); +} + +module.exports = { + execFileAsync, + repoRoot, + isCommandAvailable, + noopLogger, + escapeCsvValue, + toCsvRow, +}; From b4455a6ee6450fae830ae0b6e53e77f7a147bc27 Mon Sep 17 00:00:00 2001 From: Michael H Date: Tue, 28 Oct 2025 09:06:45 +1100 Subject: [PATCH 7/7] [react-dom] Include all Node.js APIs in Bun entrypoint for `/server` (#34193) --- packages/react-dom/npm/server.bun.js | 2 ++ packages/react-dom/server.bun.js | 14 ++++++++++ .../src/server/react-dom-server.bun.js | 11 ++++++++ .../src/server/react-dom-server.bun.stable.js | 11 ++++++++ .../src/ReactServerStreamConfigBun.js | 27 ++++++++++++++++--- scripts/rollup/bundles.js | 2 +- scripts/shared/inlinedHostConfigs.js | 2 ++ 7 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/react-dom/npm/server.bun.js b/packages/react-dom/npm/server.bun.js index bb44b38ec3c77..ec94e0f0bc4b5 100644 --- a/packages/react-dom/npm/server.bun.js +++ b/packages/react-dom/npm/server.bun.js @@ -12,6 +12,8 @@ if (process.env.NODE_ENV === 'production') { exports.version = b.version; exports.renderToReadableStream = b.renderToReadableStream; +exports.renderToPipeableStream = b.renderToPipeableStream; +exports.resumeToPipeableStream = b.resumeToPipeableStream; exports.resume = b.resume; exports.renderToString = l.renderToString; exports.renderToStaticMarkup = l.renderToStaticMarkup; diff --git a/packages/react-dom/server.bun.js b/packages/react-dom/server.bun.js index 7d054e5534e2b..13e312e559a97 100644 --- a/packages/react-dom/server.bun.js +++ b/packages/react-dom/server.bun.js @@ -38,3 +38,17 @@ export function resume() { arguments, ); } + +export function renderToPipeableStream() { + return require('./src/server/react-dom-server.bun').renderToPipeableStream.apply( + this, + arguments, + ); +} + +export function resumeToPipeableStream() { + return require('./src/server/react-dom-server.bun').resumeToPipeableStream.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/src/server/react-dom-server.bun.js b/packages/react-dom/src/server/react-dom-server.bun.js index 5ca420c2305ec..17c9c1f465aca 100644 --- a/packages/react-dom/src/server/react-dom-server.bun.js +++ b/packages/react-dom/src/server/react-dom-server.bun.js @@ -8,3 +8,14 @@ */ export * from './ReactDOMFizzServerBun.js'; +export { + renderToPipeableStream, + resumeToPipeableStream, + resume, +} from './ReactDOMFizzServerNode.js'; +export { + prerenderToNodeStream, + prerender, + resumeAndPrerenderToNodeStream, + resumeAndPrerender, +} from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-dom/src/server/react-dom-server.bun.stable.js b/packages/react-dom/src/server/react-dom-server.bun.stable.js index 4d17773002f7f..50c83508ba909 100644 --- a/packages/react-dom/src/server/react-dom-server.bun.stable.js +++ b/packages/react-dom/src/server/react-dom-server.bun.stable.js @@ -8,3 +8,14 @@ */ export {renderToReadableStream, version} from './ReactDOMFizzServerBun.js'; +export { + renderToPipeableStream, + resume, + resumeToPipeableStream, +} from './ReactDOMFizzServerNode.js'; +export { + prerenderToNodeStream, + prerender, + resumeAndPrerenderToNodeStream, + resumeAndPrerender, +} from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index ef53ca96236bc..a9079bee43a65 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -9,13 +9,22 @@ /* global Bun */ +import type {Writable} from 'stream'; + type BunReadableStreamController = ReadableStreamController & { end(): mixed, write(data: Chunk | BinaryChunk): void, error(error: Error): void, flush?: () => void, }; -export type Destination = BunReadableStreamController; + +interface MightBeFlushable { + flush?: () => void; +} + +export type Destination = + | BunReadableStreamController + | (Writable & MightBeFlushable); export type PrecomputedChunk = string; export opaque type Chunk = string; @@ -46,6 +55,7 @@ export function writeChunk( return; } + // $FlowFixMe[incompatible-call]: write() is compatible with both types in Bun destination.write(chunk); } @@ -53,6 +63,7 @@ export function writeChunkAndReturn( destination: Destination, chunk: PrecomputedChunk | Chunk | BinaryChunk, ): boolean { + // $FlowFixMe[incompatible-call]: write() is compatible with both types in Bun return !!destination.write(chunk); } @@ -86,11 +97,21 @@ export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { } export function closeWithError(destination: Destination, error: mixed): void { + // $FlowFixMe[incompatible-use] // $FlowFixMe[method-unbinding] if (typeof destination.error === 'function') { // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. destination.error(error); - } else { + + // $FlowFixMe[incompatible-use] + // $FlowFixMe[method-unbinding] + } else if (typeof destination.destroy === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + destination.destroy(error); + + // $FlowFixMe[incompatible-use] + // $FlowFixMe[method-unbinding] + } else if (typeof destination.close === 'function') { // Earlier implementations doesn't support this method. In that environment you're // supposed to throw from a promise returned but we don't return a promise in our // approach. We could fork this implementation but this is environment is an edge @@ -101,7 +122,7 @@ export function closeWithError(destination: Destination, error: mixed): void { } } -export function createFastHash(input: string): string | number { +export function createFastHash(input: string): number { return Bun.hash(input); } diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 69d0b534fce61..c4176099b7622 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -405,7 +405,7 @@ const bundles = [ global: 'ReactDOMServer', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react', 'react-dom'], + externals: ['react', 'react-dom', 'crypto', 'stream', 'util'], }, /******* React DOM Fizz Server External Runtime *******/ diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 801060c4c5455..ebdbf6cf52f82 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -250,6 +250,8 @@ module.exports = [ 'react-dom/server.bun', 'react-dom/src/server/react-dom-server.bun', 'react-dom/src/server/ReactDOMFizzServerBun.js', + 'react-dom/src/server/ReactDOMFizzServerNode.js', + 'react-dom/src/server/ReactDOMFizzStaticNode.js', 'react-dom-bindings', 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js',