@samitouri / QOS-React-2 / commits / 69f3e9d034

[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

lauren committed Oct 27, 2025 at 17:54 UTC 69f3e9d0346a04af55de05a08d7aa2df17035849
1 file changed +213 -33
scripts/tasks/generate-changelog.js
+213 -33
@@ -23,7 +23,7 @@ const repoRoot = path.resolve(__dirname, '..', '..');
23 function parseArgs(argv) {
24 const parser = yargs(argv)
25 .usage(
26 - 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [<pkg@version> ...]'
26 + 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [--format <text|csv|json>] [<pkg@version> ...]'
27 )
28 .example(
29 '$0 --codex eslint-plugin-react-hooks@7.0.1',
@@ -50,6 +50,12 @@ function parseArgs(argv) {
50 describe: 'Enable verbose debug logging.',
51 default: false,
52 })
53 + .option('format', {
54 + type: 'string',
55 + describe: 'Output format for the generated changelog.',
56 + choices: ['text', 'csv', 'json'],
57 + default: 'text',
58 + })
59 .help('help')
60 .alias('h', 'help')
61 .version(false)
@@ -61,6 +67,7 @@ function parseArgs(argv) {
67 const args = parser.scriptName('generate-changelog').parse();
68 const packageSpecs = [];
69 const debug = !!args.debug;
70 + const format = args.format || 'text';
71 let summarizer = null;
72 if (args.codex && args.claude) {
73 throw new Error('Choose either --codex or --claude, not both.');
@@ -123,6 +130,7 @@ function parseArgs(argv) {
130
131 return {
132 debug,
133 + format,
134 summarizer,
135 packageSpecs,
136 };
@@ -484,6 +492,22 @@ async function summarizePackageCommits({
492
493 function noopLogger() {}
494
495 +function escapeCsvValue(value) {
496 + if (value == null) {
497 + return '';
498 + }
499 +
500 + const stringValue = String(value).replace(/\r?\n|\r/g, ' ');
501 + if (stringValue.includes('"') || stringValue.includes(',')) {
502 + return `"${stringValue.replace(/"/g, '""')}"`;
503 + }
504 + return stringValue;
505 +}
506 +
507 +function toCsvRow(values) {
508 + return values.map(escapeCsvValue).join(',');
509 +}
510 +
511 async function runSummarizer(command, prompt) {
512 const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024};
513
@@ -634,7 +658,9 @@ async function fetchPullRequestMetadata(prNumber, {log}) {
658 }
659
660 async function main() {
637 - const {packageSpecs, summarizer, debug} = parseArgs(process.argv.slice(2));
661 + const {packageSpecs, summarizer, debug, format} = parseArgs(
662 + process.argv.slice(2)
663 + );
664 const log = debug
665 ? (...args) => console.log('[generate-changelog]', ...args)
666 : noopLogger;
@@ -754,60 +780,214 @@ async function main() {
780 log,
781 });
782
757 - const outputLines = [];
783 + const noChangesMessage = 'No changes since the last release.';
784 + const changelogEntries = [];
785 for (let i = 0; i < packageSpecs.length; i++) {
786 const spec = packageSpecs[i];
760 - outputLines.push(`## ${spec.name}@${spec.displayVersion || spec.version}`);
787 + const versionText = spec.displayVersion || spec.version;
788 const commitsForPackage = commitsByPackage.get(spec.name) || [];
789 + const entry = {
790 + package: spec.name,
791 + version: versionText,
792 + hasChanges: commitsForPackage.length > 0,
793 + commits: [],
794 + note: null,
795 + };
796
763 - if (commitsForPackage.length === 0) {
764 - outputLines.push('* No changes since the last release.');
765 - outputLines.push('');
797 + if (!entry.hasChanges) {
798 + entry.note = noChangesMessage;
799 + changelogEntries.push(entry);
800 continue;
801 }
802
769 - commitsForPackage.forEach(commit => {
803 + const summaryMap = summariesByPackage.get(spec.name) || new Map();
804 + entry.commits = commitsForPackage.map(commit => {
805 if (commit.prNumber && prMetadata.has(commit.prNumber)) {
771 - commit.authorLogin = prMetadata.get(commit.prNumber).authorLogin;
806 + const metadata = prMetadata.get(commit.prNumber);
807 + if (metadata && metadata.authorLogin) {
808 + commit.authorLogin = metadata.authorLogin;
809 + }
810 }
811
774 - const prFragment = commit.prNumber
775 - ? `[#${commit.prNumber}](https://github.com/facebook/react/pull/${commit.prNumber})`
776 - : `commit ${commit.sha.slice(0, 7)}`;
812 + let summary = summaryMap.get(commit.sha) || commit.subject;
813 + if (commit.prNumber) {
814 + const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`);
815 + summary = summary.replace(prPattern, '').trim();
816 + }
817
778 - let authorFragment = commit.authorLogin
779 - ? `[@${commit.authorLogin}](https://github.com/${commit.authorLogin})`
780 - : commit.authorName || 'unknown author';
818 + const prNumber = commit.prNumber || null;
819 + const prUrl = prNumber
820 + ? `https://github.com/facebook/react/pull/${prNumber}`
821 + : null;
822 + const commitSha = commit.sha;
823 + const commitUrl = `https://github.com/facebook/react/commit/${commitSha}`;
824 +
825 + const authorLogin = commit.authorLogin || null;
826 + const authorName = commit.authorName || null;
827 + const authorEmail = commit.authorEmail || null;
828 +
829 + let authorUrl = null;
830 + let authorDisplay = authorName || 'unknown author';
831 +
832 + if (authorLogin) {
833 + authorUrl = `https://github.com/${authorLogin}`;
834 + authorDisplay = `[@${authorLogin}](${authorUrl})`;
835 + } else if (authorName && authorName.startsWith('@')) {
836 + const username = authorName.slice(1);
837 + authorUrl = `https://github.com/${username}`;
838 + authorDisplay = `[@${username}](${authorUrl})`;
839 + }
840
782 - if (
783 - !commit.authorLogin &&
784 - commit.authorName &&
785 - commit.authorName.startsWith('@')
786 - ) {
787 - const username = commit.authorName.slice(1);
788 - authorFragment = `[@${username}](https://github.com/${username})`;
841 + const referenceDisplay = prNumber
842 + ? `[#${prNumber}](${prUrl})`
843 + : `commit ${commitSha.slice(0, 7)}`;
844 + const referenceType = prNumber ? 'pr' : 'commit';
845 + const referenceId = prNumber ? `#${prNumber}` : commitSha.slice(0, 7);
846 + const referenceUrl = prNumber ? prUrl : commitUrl;
847 +
848 + return {
849 + summary,
850 + prNumber,
851 + prUrl,
852 + commitSha,
853 + commitUrl,
854 + authorLogin,
855 + authorName,
856 + authorEmail,
857 + authorUrl,
858 + authorDisplay,
859 + referenceDisplay,
860 + referenceType,
861 + referenceId,
862 + referenceUrl,
863 + };
864 + });
865 +
866 + changelogEntries.push(entry);
867 + }
868 +
869 + log('Generated changelog sections.');
870 + if (format === 'text') {
871 + const outputLines = [];
872 + for (let i = 0; i < changelogEntries.length; i++) {
873 + const entry = changelogEntries[i];
874 + outputLines.push(`## ${entry.package}@${entry.version}`);
875 + if (!entry.hasChanges) {
876 + outputLines.push(`* ${entry.note}`);
877 + outputLines.push('');
878 + continue;
879 }
880
791 - const summaryMap = summariesByPackage.get(spec.name) || new Map();
792 - let summary = summaryMap.get(commit.sha) || commit.subject;
881 + entry.commits.forEach(commit => {
882 + outputLines.push(
883 + `* ${commit.summary} (${commit.referenceDisplay} by ${commit.authorDisplay})`
884 + );
885 + });
886 + outputLines.push('');
887 + }
888
794 - if (commit.prNumber) {
795 - const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`);
796 - summary = summary.replace(prPattern, '').trim();
889 + while (outputLines.length && outputLines[outputLines.length - 1] === '') {
890 + outputLines.pop();
891 + }
892 +
893 + console.log(outputLines.join('\n'));
894 + return;
895 + }
896 +
897 + if (format === 'csv') {
898 + const header = [
899 + 'package',
900 + 'version',
901 + 'summary',
902 + 'reference_type',
903 + 'reference_id',
904 + 'reference_url',
905 + 'author_name',
906 + 'author_login',
907 + 'author_url',
908 + 'author_email',
909 + 'commit_sha',
910 + 'commit_url',
911 + ];
912 + const rows = [header];
913 + changelogEntries.forEach(entry => {
914 + if (!entry.hasChanges) {
915 + rows.push([
916 + entry.package,
917 + entry.version,
918 + entry.note,
919 + '',
920 + '',
921 + '',
922 + '',
923 + '',
924 + '',
925 + '',
926 + '',
927 + '',
928 + ]);
929 + return;
930 }
931
799 - outputLines.push(`* ${summary} (${prFragment} by ${authorFragment})`);
932 + entry.commits.forEach(commit => {
933 + const authorName =
934 + commit.authorName ||
935 + (commit.authorLogin ? `@${commit.authorLogin}` : 'unknown author');
936 + rows.push([
937 + entry.package,
938 + entry.version,
939 + commit.summary,
940 + commit.referenceType,
941 + commit.referenceId,
942 + commit.referenceUrl,
943 + authorName,
944 + commit.authorLogin || '',
945 + commit.authorUrl || '',
946 + commit.authorEmail || '',
947 + commit.commitSha,
948 + commit.commitUrl,
949 + ]);
950 + });
951 });
952
802 - outputLines.push('');
953 + const csvLines = rows.map(toCsvRow);
954 + console.log(csvLines.join('\n'));
955 + return;
956 }
957
805 - while (outputLines.length && outputLines[outputLines.length - 1] === '') {
806 - outputLines.pop();
958 + if (format === 'json') {
959 + const payload = changelogEntries.map(entry => ({
960 + package: entry.package,
961 + version: entry.version,
962 + hasChanges: entry.hasChanges,
963 + note: entry.hasChanges ? undefined : entry.note,
964 + commits: entry.commits.map(commit => ({
965 + summary: commit.summary,
966 + prNumber: commit.prNumber,
967 + prUrl: commit.prUrl,
968 + commitSha: commit.commitSha,
969 + commitUrl: commit.commitUrl,
970 + author: {
971 + login: commit.authorLogin,
972 + name: commit.authorName,
973 + email: commit.authorEmail,
974 + url: commit.authorUrl,
975 + display: commit.authorDisplay,
976 + },
977 + reference: {
978 + type: commit.referenceType,
979 + id: commit.referenceId,
980 + url: commit.referenceUrl,
981 + label: commit.referenceDisplay,
982 + },
983 + })),
984 + }));
985 +
986 + console.log(JSON.stringify(payload, null, 2));
987 + return;
988 }
989
809 - log('Generated changelog sections.');
810 - console.log(outputLines.join('\n'));
990 + throw new Error(`Unsupported format: ${format}`);
991 }
992
993 main().catch(error => {