Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,5 @@ packages/react-devtools-inline/dist
packages/react-devtools-shell/dist
packages/react-devtools-timeline/dist

scripts/release-notes/node_modules
scripts/release-notes/state.json
10 changes: 10 additions & 0 deletions scripts/release-notes/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use strict';

module.exports = {
parserOptions: {
sourceType: 'module',
},
rules: {
strict: 'off',
},
};
1 change: 1 addition & 0 deletions scripts/release-notes/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
data/
41 changes: 41 additions & 0 deletions scripts/release-notes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Release Notes Generator

Tool for generating release notes.

## Setup

```sh
cd scripts/release-notes
yarn install
```

## Usage

### 1. Generate commit data

```sh
yarn gen-data -v <version>
```

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).

Example:
```sh
yarn gen-data -v 19.1.0
```

### 2. Run the app

```sh
yarn dev
```

### 3. Triage commits

- **Include/Reviewed checkboxes** — mark commits to include in the release notes or mark as reviewed (reviewed-only commits fade out)
- **Tags** — assign custom tags to group related commits together
- **Filters** — filter the table by text search, reviewed status, category, or tag

## State

Triage state (selections, tags, assignments) is saved to `state.json` automatically. This is gitignored. Regenerating commit data does not affect saved state.
144 changes: 144 additions & 0 deletions scripts/release-notes/gen-data.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* 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.
*/

import {execSync} from 'child_process';
import {writeFileSync, mkdirSync} from 'fs';
import {join, dirname} from 'path';
import {fileURLToPath} from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const OUTPUT_DIR = join(__dirname, 'data');
const OUTPUT_FILE = join(OUTPUT_DIR, 'commits.json');

function parseArgs() {
const args = process.argv.slice(2);
const vIndex = args.indexOf('-v');
if (vIndex === -1 || vIndex + 1 >= args.length) {
console.error('Usage: node gen-data.mjs -v <version>');
console.error('Example: node gen-data.mjs -v 19.2.0');
process.exit(1);
}
return args[vIndex + 1];
}

function resolveTag(version) {
const tag = version.startsWith('v') ? version : `v${version}`;
try {
execSync(`git tag -l "${tag}" | grep -q .`, {stdio: 'pipe'});
} catch {
console.error(`Error: git tag "${tag}" not found.`);
console.error(
'Available recent tags:',
execSync('git tag --sort=-creatordate | head -5').toString().trim()
);
process.exit(1);
}
return tag;
}

function resolveGitHubUsernames(commits, repo) {
// Dedupe by author name — only need one commit per unique author
const authorToHash = new Map();
for (const commit of commits) {
if (!authorToHash.has(commit.author)) {
authorToHash.set(commit.author, commit.fullHash);
}
}

const authorToUsername = new Map();
const entries = Array.from(authorToHash.entries());
console.log(`Resolving GitHub usernames for ${entries.length} unique authors...`);

for (const [author, hash] of entries) {
try {
const login = execSync(
`gh api repos/${repo}/commits/${hash} --jq '.author.login'`,
{stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000}
)
.toString()
.trim();
if (login && login !== 'null') {
authorToUsername.set(author, login);
}
} catch {
// Silently skip — will fall back to display name
}
}

console.log(`Resolved ${authorToUsername.size}/${entries.length} usernames.`);
return authorToUsername;
}

function getCommits(lastRelease) {
const listOfCommits = execSync(
`git log --pretty=format:"%h|%ai|%aN|%ae" ${lastRelease}...`
Comment thread
greptile-apps[bot] marked this conversation as resolved.
).toString();

const summary = execSync(
`git log --pretty=format:"%s" ${lastRelease}...`
)
.toString()
.split('\n');

const body = execSync(
`git log --pretty=format:"%b<!----!>" ${lastRelease}...`
)
.toString()
.split('<!----!>\n');

const commits = listOfCommits.split('\n').map((commitMessage, index) => {
const diffMatch = body[index]?.match(/D\d+/);
const diff = diffMatch != null && diffMatch[0];
const [hash, date, name] = commitMessage.split('|');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pipe delimiter can split author names incorrectly

The format string on line 78 is %h|%ai|%aN|%ae, producing 4 pipe-delimited fields. The destructuring here only captures 3 values (hash, date, name), which works in the common case. However, if an author's Git display name (%aN) contains a | character, split('|') would produce more than 4 segments, and name would be truncated to only the portion before the first | in the name.

Consider using a delimiter that's less likely to appear in names (e.g., \x00 null byte), or using a fixed number of splits:

Suggested change
const [hash, date, name] = commitMessage.split('|');
const parts = commitMessage.split('|');
const [hash, date] = parts;
const name = parts.slice(2, -1).join('|');

This takes the email (last field) off the end and joins the remaining middle fields back, preserving any | in the author name.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/release-notes/gen-data.mjs
Line: 96

Comment:
**Pipe delimiter can split author names incorrectly**

The format string on line 78 is `%h|%ai|%aN|%ae`, producing 4 pipe-delimited fields. The destructuring here only captures 3 values (`hash`, `date`, `name`), which works in the common case. However, if an author's Git display name (`%aN`) contains a `|` character, `split('|')` would produce more than 4 segments, and `name` would be truncated to only the portion before the first `|` in the name.

Consider using a delimiter that's less likely to appear in names (e.g., `\x00` null byte), or using a fixed number of splits:

```suggestion
    const parts = commitMessage.split('|');
    const [hash, date] = parts;
    const name = parts.slice(2, -1).join('|');
```

This takes the email (last field) off the end and joins the remaining middle fields back, preserving any `|` in the author name.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

return {
hash: hash.slice(0, 7),
fullHash: hash,
summary: summary[index],
message: body[index],
author: name,
diff,
date,
};
});

return commits;
}

// Detect the GitHub repo from the git remote
function getRepo() {
try {
const remote = execSync('git remote get-url origin', {stdio: 'pipe'})
.toString()
.trim();
const match = remote.match(/github\.com[:/](.+?)(?:\.git)?$/);
if (match) return match[1];
} catch {
// fall through
}
return 'facebook/react';
}

const version = parseArgs();
const lastRelease = resolveTag(version);
const commits = getCommits(lastRelease);
const repo = getRepo();
const usernameMap = resolveGitHubUsernames(commits, repo);

// Attach github username to each commit
for (const commit of commits) {
const username = usernameMap.get(commit.author);
if (username) {
commit.github = username;
}
}

mkdirSync(OUTPUT_DIR, {recursive: true});
writeFileSync(OUTPUT_FILE, JSON.stringify({lastRelease, commits}, null, 2));

console.log(
`Wrote ${commits.length} commits (since ${lastRelease}) to data/commits.json`
);
12 changes: 12 additions & 0 deletions scripts/release-notes/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Release Notes</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
19 changes: 19 additions & 0 deletions scripts/release-notes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "release-notes",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"gen-data": "node gen-data.mjs"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tanstack/react-table": "^8.20.0"
},
"devDependencies": {
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.3.0"
}
}
72 changes: 72 additions & 0 deletions scripts/release-notes/plugins/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* 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.
*/

import {readFileSync, writeFileSync, existsSync} from 'fs';
import {join} from 'path';

const STATE_FILE = join(process.cwd(), 'data', 'state.json');

function getDefaultState() {
return {
includedCommits: {},
reviewedCommits: {},
customTags: [],
tagAssignments: {},
};
}

function readState() {
if (existsSync(STATE_FILE)) {
try {
return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
} catch {
return getDefaultState();
}
}
return getDefaultState();
}

function writeState(state) {
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}

export default function apiPlugin() {
return {
name: 'release-notes-api',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === '/api/state' && req.method === 'GET') {
const state = readState();
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(state));
return;
}

if (req.url === '/api/state' && req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk;
});
req.on('end', () => {
try {
const state = JSON.parse(body);
writeState(state);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ok: true}));
} catch {
res.statusCode = 400;
res.end(JSON.stringify({error: 'Invalid JSON'}));
}
});
return;
}

next();
});
},
};
}
Loading
Loading