Skip to content

Commit f7ad2e6

Browse files
committed
Add an interactive release notes app (scripts/release-notes/) for triaging commits and generating markdown.
Run yarn gen-data -v <version> to pull commits since a tag, then yarn dev to launch a Vite app where you can mark commits to include, tag and group them, filter the list, and get a live markdown preview with copy-to-clipboard. Triage state persists to a local JSON file.
1 parent b16b768 commit f7ad2e6

17 files changed

Lines changed: 2229 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ packages/react-devtools-inline/dist
4141
packages/react-devtools-shell/dist
4242
packages/react-devtools-timeline/dist
4343

44+
scripts/release-notes/node_modules
45+
scripts/release-notes/state.json

scripts/release-notes/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
data/

scripts/release-notes/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Release Notes Generator
2+
3+
Tool for generating release notes.
4+
5+
## Setup
6+
7+
```sh
8+
cd scripts/release-notes
9+
yarn install
10+
```
11+
12+
## Usage
13+
14+
### 1. Generate commit data
15+
16+
```sh
17+
yarn gen-data -v <version>
18+
```
19+
20+
This exports all commits since the given git tag to `data/commits.json`. It also resolves GitHub usernames for each commit author via the GitHub API (requires `gh` CLI to be authenticated).
21+
22+
Example:
23+
```sh
24+
yarn gen-data -v 19.1.0
25+
```
26+
27+
### 2. Run the app
28+
29+
```sh
30+
yarn dev
31+
```
32+
33+
### 3. Triage commits
34+
35+
- **Include/Reviewed checkboxes** — mark commits to include in the release notes or mark as reviewed (reviewed-only commits fade out)
36+
- **Tags** — assign custom tags to group related commits together
37+
- **Filters** — filter the table by text search, reviewed status, category, or tag
38+
39+
## State
40+
41+
Triage state (selections, tags, assignments) is saved to `state.json` automatically. This is gitignored. Regenerating commit data does not affect saved state.

scripts/release-notes/gen-data.mjs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {execSync} from 'child_process';
9+
import {writeFileSync, mkdirSync} from 'fs';
10+
import {join, dirname} from 'path';
11+
import {fileURLToPath} from 'url';
12+
13+
const __dirname = dirname(fileURLToPath(import.meta.url));
14+
const OUTPUT_DIR = join(__dirname, 'data');
15+
const OUTPUT_FILE = join(OUTPUT_DIR, 'commits.json');
16+
17+
function parseArgs() {
18+
const args = process.argv.slice(2);
19+
const vIndex = args.indexOf('-v');
20+
if (vIndex === -1 || vIndex + 1 >= args.length) {
21+
console.error('Usage: node gen-data.mjs -v <version>');
22+
console.error('Example: node gen-data.mjs -v 19.2.0');
23+
process.exit(1);
24+
}
25+
return args[vIndex + 1];
26+
}
27+
28+
function resolveTag(version) {
29+
const tag = version.startsWith('v') ? version : `v${version}`;
30+
try {
31+
execSync(`git tag -l "${tag}" | grep -q .`, {stdio: 'pipe'});
32+
} catch {
33+
console.error(`Error: git tag "${tag}" not found.`);
34+
console.error(
35+
'Available recent tags:',
36+
execSync('git tag --sort=-creatordate | head -5').toString().trim()
37+
);
38+
process.exit(1);
39+
}
40+
return tag;
41+
}
42+
43+
function resolveGitHubUsernames(commits, repo) {
44+
// Dedupe by author name — only need one commit per unique author
45+
const authorToHash = new Map();
46+
for (const commit of commits) {
47+
if (!authorToHash.has(commit.author)) {
48+
authorToHash.set(commit.author, commit.fullHash);
49+
}
50+
}
51+
52+
const authorToUsername = new Map();
53+
const entries = Array.from(authorToHash.entries());
54+
console.log(`Resolving GitHub usernames for ${entries.length} unique authors...`);
55+
56+
for (const [author, hash] of entries) {
57+
try {
58+
const login = execSync(
59+
`gh api repos/${repo}/commits/${hash} --jq '.author.login'`,
60+
{stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000}
61+
)
62+
.toString()
63+
.trim();
64+
if (login && login !== 'null') {
65+
authorToUsername.set(author, login);
66+
}
67+
} catch {
68+
// Silently skip — will fall back to display name
69+
}
70+
}
71+
72+
console.log(`Resolved ${authorToUsername.size}/${entries.length} usernames.`);
73+
return authorToUsername;
74+
}
75+
76+
function getCommits(lastRelease) {
77+
const listOfCommits = execSync(
78+
`git log --pretty=format:"%h|%ai|%aN|%ae" ${lastRelease}...`
79+
).toString();
80+
81+
const summary = execSync(
82+
`git log --pretty=format:"%s" ${lastRelease}...`
83+
)
84+
.toString()
85+
.split('\n');
86+
87+
const body = execSync(
88+
`git log --pretty=format:"%b<!----!>" ${lastRelease}...`
89+
)
90+
.toString()
91+
.split('<!----!>\n');
92+
93+
const commits = listOfCommits.split('\n').map((commitMessage, index) => {
94+
const diffMatch = body[index]?.match(/D\d+/);
95+
const diff = diffMatch != null && diffMatch[0];
96+
const [hash, date, name] = commitMessage.split('|');
97+
return {
98+
hash: hash.slice(0, 7),
99+
fullHash: hash,
100+
summary: summary[index],
101+
message: body[index],
102+
author: name,
103+
diff,
104+
date,
105+
};
106+
});
107+
108+
return commits;
109+
}
110+
111+
// Detect the GitHub repo from the git remote
112+
function getRepo() {
113+
try {
114+
const remote = execSync('git remote get-url origin', {stdio: 'pipe'})
115+
.toString()
116+
.trim();
117+
const match = remote.match(/github\.com[:/](.+?)(?:\.git)?$/);
118+
if (match) return match[1];
119+
} catch {
120+
// fall through
121+
}
122+
return 'facebook/react';
123+
}
124+
125+
const version = parseArgs();
126+
const lastRelease = resolveTag(version);
127+
const commits = getCommits(lastRelease);
128+
const repo = getRepo();
129+
const usernameMap = resolveGitHubUsernames(commits, repo);
130+
131+
// Attach github username to each commit
132+
for (const commit of commits) {
133+
const username = usernameMap.get(commit.author);
134+
if (username) {
135+
commit.github = username;
136+
}
137+
}
138+
139+
mkdirSync(OUTPUT_DIR, {recursive: true});
140+
writeFileSync(OUTPUT_FILE, JSON.stringify({lastRelease, commits}, null, 2));
141+
142+
console.log(
143+
`Wrote ${commits.length} commits (since ${lastRelease}) to data/commits.json`
144+
);

scripts/release-notes/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>React Release Notes</title>
7+
</head>
8+
<body>
9+
<div id="app"></div>
10+
<script type="module" src="/src/main.jsx"></script>
11+
</body>
12+
</html>

scripts/release-notes/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "release-notes",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite",
7+
"build": "vite build",
8+
"gen-data": "node gen-data.mjs"
9+
},
10+
"dependencies": {
11+
"react": "^18.2.0",
12+
"react-dom": "^18.2.0",
13+
"@tanstack/react-table": "^8.20.0"
14+
},
15+
"devDependencies": {
16+
"vite": "^6.0.0",
17+
"@vitejs/plugin-react": "^4.3.0"
18+
}
19+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {readFileSync, writeFileSync, existsSync} from 'fs';
9+
import {join} from 'path';
10+
11+
const STATE_FILE = join(process.cwd(), 'data', 'state.json');
12+
13+
function getDefaultState() {
14+
return {
15+
includedCommits: {},
16+
reviewedCommits: {},
17+
customTags: [],
18+
tagAssignments: {},
19+
};
20+
}
21+
22+
function readState() {
23+
if (existsSync(STATE_FILE)) {
24+
try {
25+
return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
26+
} catch {
27+
return getDefaultState();
28+
}
29+
}
30+
return getDefaultState();
31+
}
32+
33+
function writeState(state) {
34+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
35+
}
36+
37+
export default function apiPlugin() {
38+
return {
39+
name: 'release-notes-api',
40+
configureServer(server) {
41+
server.middlewares.use((req, res, next) => {
42+
if (req.url === '/api/state' && req.method === 'GET') {
43+
const state = readState();
44+
res.setHeader('Content-Type', 'application/json');
45+
res.end(JSON.stringify(state));
46+
return;
47+
}
48+
49+
if (req.url === '/api/state' && req.method === 'POST') {
50+
let body = '';
51+
req.on('data', chunk => {
52+
body += chunk;
53+
});
54+
req.on('end', () => {
55+
try {
56+
const state = JSON.parse(body);
57+
writeState(state);
58+
res.setHeader('Content-Type', 'application/json');
59+
res.end(JSON.stringify({ok: true}));
60+
} catch {
61+
res.statusCode = 400;
62+
res.end(JSON.stringify({error: 'Invalid JSON'}));
63+
}
64+
});
65+
return;
66+
}
67+
68+
next();
69+
});
70+
},
71+
};
72+
}

0 commit comments

Comments
 (0)