Skip to content

Commit

Permalink
Add a simple helper tool for commits categorization (#4546)
Browse files Browse the repository at this point in the history
Summary:
Our release process is not fully automated. The big part of the manual work is changes/commits categorization and generation of Release Notes.

This is a simple tool that will render a list of commits since the last release, and by clicking on the commit card, you can switch its' category. On the right side you can see the generated release notes.

Pull Request resolved: #4546

Test Plan: {F1169060630} https://pxl.cl/3XFLF

Reviewed By: tyao1

Differential Revision: D51991742

Pulled By: alunyov

fbshipit-source-id: 3d2b5e7cc30111332c74292646645f61518f44a2
  • Loading branch information
alunyov authored and facebook-github-bot committed Dec 8, 2023
1 parent 88680ee commit 2b2254d
Show file tree
Hide file tree
Showing 3 changed files with 482 additions and 0 deletions.
120 changes: 120 additions & 0 deletions scripts/release-notes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* 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.
*
* @format
* @oncall relay
*/

'use strict';

const {execSync} = require('child_process');
const {existsSync, readFileSync} = require('fs');
const http = require('http');

/**
* This function will create a simple HTTP server that will show the list
* of commits to the Relay repo since the last release.
*
* To run the app:
* ```
* node ./scripts/release-notes.js
* ```
*
* And follow the link, printed in console: http://localhost:3123
*/
function main() {
log('Generating release notes...');

const server = http.createServer((request, response) => {
// Supported Static Resources
if (request.url.endsWith('.css') || request.url.endsWith('.js')) {
const path = `./scripts/release-notes/${request.url}`;
if (!existsSync(path)) {
response.writeHead(404, {'Content-Type': 'text/plain'});
response.write('Not Found.');
response.end();
return;
}

const data = readFileSync(path);
response.writeHead(200, {'Content-Type': 'text/plain'});
response.write(data);
response.end();
return;
}

const [commits, lastRelease] = getData();

response.writeHead(200, {'Content-Type': 'text/html'});
response.write(
`
<html>
<head>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<title>Relay commits since ${lastRelease}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script type="text/babel" src="/App.js"></script>
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<App commits={${JSON.stringify(
commits,
)}} lastRelease="${lastRelease}" />);
</script>
</body>
</html>
`,
);
response.end();
});

const PORT = 3123;
server.listen(PORT);
log(`Release notes App started at http://localhost:${PORT}`);
}

function getData() {
const lastRelease = execSync('git describe --tags --abbrev=0')
.toString()
.trim();

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 [hash, date, name, _email] = commitMessage.split('|');
return {
hash,
summary: summary[index],
message: body[index],
author: name,
date,
};
});

return [commits, lastRelease];
}

function log(message) {
// eslint-disable-next-line no-console
console.log(message);
}

main();
242 changes: 242 additions & 0 deletions scripts/release-notes/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* 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.
*
* @format
* @oncall relay
*/

'use strict';

/* eslint-disable react/react-in-jsx-scope*/

const {useEffect, useState} = React;

const CATEGORIES = [
['BUGFIX', 3],
['IMPROVEMENTS', 4],
['DOCS', 5],
['NEW_API', 2],
['BREAKING', 1],
['MISC', 6],
['EXPERIMENTAL', 7],
['SKIP', 8],
];

const CATEGORIES_NAMES = {
BUGFIX: 'Bug fixes',
IMPROVEMENTS: 'Improvements',
DOCS: 'Documentation Improvements',
NEW_API: 'Added',
BREAKING: 'Breaking Changes',
MISC: 'Miscellaneous',
EXPERIMENTAL: 'Experimental Changes',
SKIP: 'Skipped in Release Notes',
};

const REPO_URL = 'https://github.com/facebook/relay';

const CommitCard = ({
message,
summary,
author,
date,
selectedCategory,
onCategoryChange,
}) => {
const handleClick = event => {
const category = CATEGORIES.findIndex(cat => cat[0] === selectedCategory);
let nextCategory;
if (event.type === 'contextmenu') {
event.preventDefault();
nextCategory = -1;
} else {
nextCategory = category + 1;
if (nextCategory == CATEGORIES.length) {
nextCategory = -1; // Reset selected category
}
}
onCategoryChange(nextCategory > -1 ? CATEGORIES[nextCategory][0] : null);
};

return (
<button
className={`commit ${selectedCategory}`}
onClick={handleClick}
onContextMenu={handleClick}
title={message}>
<p className="summary">{summary}</p>
<p className="author">{author}</p>
</button>
);
};

// eslint-disable-next-line no-unused-vars
function App({commits, lastRelease}) {
let initialState = localStorage.getItem('selectedCategories');
if (initialState != null) {
initialState = JSON.parse(initialState);
} else {
initialState = {};
}
const [selectedCategories, setSelectedCategories] = useState(initialState);
useEffect(() => {
localStorage.setItem(
'selectedCategories',
JSON.stringify(selectedCategories),
);
}, [selectedCategories]);

return (
<>
<h1>Relay commits since {lastRelease}</h1>
<div className="instructions">
<p>
Click on the commit card to change it's category. Possible categories
are:{' '}
</p>
<Categories />
</div>
<div className="layout">
<section className="commits">
{commits.map((commit, index) => {
return (
<CommitCard
key={index}
message={commit.message}
summary={commit.summary}
author={commit.author}
date={commit.date}
selectedCategory={selectedCategories[commit.hash]}
onCategoryChange={category => {
setSelectedCategories({
...selectedCategories,
[commit.hash]: category,
});
}}
/>
);
})}
</section>
<section className="release_notes">
<GeneratedReleaseNotes
lastRelease={lastRelease}
commits={commits}
selectedCategories={selectedCategories}
/>
</section>
</div>
</>
);
}

function Categories() {
return (
<ul className="categories">
{CATEGORIES.map(([category]) => {
return (
<li className={`category ${category}`} key={category}>
{category}
</li>
);
})}
</ul>
);
}

function GeneratedReleaseNotes({commits, selectedCategories, lastRelease}) {
const categorizedCommits = new Map();
const categories = Array.from(CATEGORIES);
categories
.sort(([, orderA], [, orderB]) => orderA - orderB)
.forEach(([category]) => {
categorizedCommits.set(category, []);
});

let hasBreakingChanges = false;
let hasNewApi = false;

const nonCategorizedCommits = [];

commits.forEach(commit => {
const commitCategory = selectedCategories[commit.hash];
if (commitCategory != null) {
const categoryCommits = categorizedCommits.get(commitCategory);
if (categoryCommits != null) {
categoryCommits.push(commit);
if (commitCategory === 'BREAKING') {
hasBreakingChanges = true;
}
if (commitCategory === 'NEW_API') {
hasNewApi = true;
}
}
} else {
nonCategorizedCommits.push(commit);
}
});

return (
<div className="release_notes_content">
<h1>
Version {nextReleaseVersion(lastRelease, hasBreakingChanges, hasNewApi)}{' '}
Release Notes
</h1>
<div>
{Array.from(categorizedCommits).map(([category, commits]) => {
if (commits.length) {
return (
<div key={category}>
<h2>{CATEGORIES_NAMES[category]}</h2>
<CommitList commits={commits} />
</div>
);
} else {
return null;
}
})}
<h3>Non-categorized commits</h3>
<CommitList commits={nonCategorizedCommits} />
</div>
</div>
);
}

function CommitList({commits}) {
return (
<ul>
{commits.map(commit => {
return (
<li key={commit.hash}>
[
<a href={`${REPO_URL}/commit/${commit.hash}`} target="_blank">
{commit.hash}
</a>
]: {capitalize(commit.summary)} by {commit.author}
</li>
);
})}
</ul>
);
}

function nextReleaseVersion(lastRelease, hasBreakingChanges, hasNewApi) {
const [major, minor, patch] = lastRelease.replace('v', '').split('.');
if (hasBreakingChanges) {
return `${next(major)}.0.0`;
} else if (hasNewApi) {
return `${major}.${next(minor)}.0`;
} else {
return `${major}.${minor}.${next(patch)}`;
}
}

function next(versionStr) {
return parseInt(versionStr, 10) + 1;
}

function capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
Loading

0 comments on commit 2b2254d

Please sign in to comment.