[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 => {