-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a simple helper tool for commits categorization (#4546)
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
1 parent
88680ee
commit 2b2254d
Showing
3 changed files
with
482 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
Oops, something went wrong.