From c086bc2bf79c7959afee474169f01a5b9aa4f732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sun, 19 Sep 2021 17:56:22 +0200 Subject: [PATCH] chore: add release notes tooling (#665) --- tools/release-notes/README.md | 10 ++++ tools/release-notes/release-notes-md.ejs | 31 ++++++++++ tools/release-notes/release-notes.js | 76 ++++++++++++++++++++++++ tools/release-notes/release-notes.json | 5 ++ 4 files changed, 122 insertions(+) create mode 100644 tools/release-notes/README.md create mode 100644 tools/release-notes/release-notes-md.ejs create mode 100644 tools/release-notes/release-notes.js create mode 100644 tools/release-notes/release-notes.json diff --git a/tools/release-notes/README.md b/tools/release-notes/README.md new file mode 100644 index 000000000..90eb018a9 --- /dev/null +++ b/tools/release-notes/README.md @@ -0,0 +1,10 @@ +## Requirements +``` +npm install -g git-release-notes +``` + +## Usage +``` +git release-notes -f release-notes.json PREVIOUS..CURRENT release-notes-md.ejs +``` +Where PREVIOUS is the previous release tag, and CURRENT is the current release tag diff --git a/tools/release-notes/release-notes-md.ejs b/tools/release-notes/release-notes-md.ejs new file mode 100644 index 000000000..afde8c298 --- /dev/null +++ b/tools/release-notes/release-notes-md.ejs @@ -0,0 +1,31 @@ +<% +const typeGroups = { + feats: { title: 'Features:', types: ['feat'] }, + fixes: { title: 'Fixes:', types: ['fix'] }, + etc: { + title: 'Other changes (not related to library code):', + types: ['docs','style','refactor','perf','test','build','ci','chore'] + }, + unknown: { title: 'Unknown:', types: ['?'] }, +} + +const commitTypes = { + feat: '✨', fix: '🐛', docs: '📚', style: '💎', + refactor: '🔨', perf: '🚀', test: '🚨', build: '📦', + ci: '⚙️', chore: '🔧', ['?']: '❓', +} + +for(const group of Object.values(typeGroups)){ + const groupCommits = commits.filter(c => group.types.includes(c.type)); + if (groupCommits.length < 1) continue; +%> +## <%=group.title%> +<% for (const {issue, title, authorName, authorUser, scope, type} of groupCommits) { %> +* <%=commitTypes[type]%> +<%=issue ? ` [[#${issue}](https://github.com/icsharpcode/SharpZipLib/pull/${issue})]\n` : ''-%> +<%=scope ? ` \`${scope}\`\n` : ''-%> + __<%=title-%>__ + by <%=authorUser ? `[_${authorName}_](https://github.com/${authorUser})` : `_${authorName}_`%> +<% } %> + +<% } %> diff --git a/tools/release-notes/release-notes.js b/tools/release-notes/release-notes.js new file mode 100644 index 000000000..ce18ccac5 --- /dev/null +++ b/tools/release-notes/release-notes.js @@ -0,0 +1,76 @@ +const https = require('https') + +const authorUsers = {} + +/** + * @param {string} email + * @param {string} prId + * @returns {Promise} User login if found */ +const getAuthorUser = async (email, prId) => { + const lookupUser = authorUsers[email]; + if (lookupUser) return lookupUser; + + const match = /[0-9]+\+([^@]+)@users\.noreply\.github\.com/.exec(email); + if (match) { + return match[1]; + } + + const pr = await new Promise((resolve, reject) => { + console.warn(`Looking up GitHub user for PR #${prId} (${email})...`) + https.get(`https://api.github.com/repos/icsharpcode/sharpziplib/pulls/${prId}`, { + headers: {Accept: 'application/vnd.github.v3+json', 'User-Agent': 'release-notes-script/0.3.1'} + }, (res) => { + res.setEncoding('utf8'); + let chunks = ''; + res.on('data', (chunk) => chunks += chunk); + res.on('end', () => resolve(JSON.parse(chunks))); + res.on('error', reject); + }).on('error', reject); + }).catch(e => { + console.error(`Could not get GitHub user (${email}): ${e}}`) + return null; + }); + + if (!pr) { + console.error(`Could not get GitHub user (${email})}`) + return null; + } else { + const user = pr.user.login; + console.warn(`Resolved email ${email} to user ${user}`) + authorUsers[email] = user; + return user; + } +} + +/** + * @typedef {{issue?: string, sha1: string, authorEmail: string, title: string, type: string}} Commit + * @param {{commits: Commit[], range: string, dateFnsFormat: ()=>any, debug: (...p[]) => void}} data + * @param {(data: {commits: Commit[], extra: {[key: string]: any}}) => void} callback + * */ +module.exports = (data, callback) => { + // Migrates commits in the old format to conventional commit style, omitting any commits in neither format + const normalizedCommits = data.commits.flatMap(c => { + if (c.type) return [c] + const match = /^(?:Merge )?(?:PR ?)?#(\d+):? (.*)/.exec(c.title) + if (match != null) { + const [, issue, title] = match + return [{...c, title, issue, type: '?'}] + } else { + console.warn(`Skipping commit [${c.sha1.substr(0, 7)}] "${c.title}"!`); + return []; + } + }); + + const commitAuthoredBy = email => commit => commit.authorEmail === email && commit.issue ? [commit.issue] : [] + const authorEmails = new Set(normalizedCommits.map(c => c.authorEmail)); + Promise.all( + Array + .from(authorEmails.values(), e => [e, normalizedCommits.flatMap(commitAuthoredBy(e))]) + .map(async ([email, prs]) => [email, await getAuthorUser(email, ...prs)]) + ) + .then(Object.fromEntries) + .then(authorUsers => callback({ + commits: normalizedCommits.map(c => ({...c, authorUser: authorUsers[c.authorEmail]})), + extra: {} + })) +}; diff --git a/tools/release-notes/release-notes.json b/tools/release-notes/release-notes.json new file mode 100644 index 000000000..7ad7733d1 --- /dev/null +++ b/tools/release-notes/release-notes.json @@ -0,0 +1,5 @@ +{ + "title" : "^([a-z]+)(?:\\(([\\w\\$\\.]*)\\))?\\: (.*?)(?: \\(#(\\d+)\\))?$", + "meaning": ["type", "scope", "title", "issue"], + "script": "release-notes.js" +}