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}...`
).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('|');
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