Commit b0403d0c authored by Robert Knight's avatar Robert Knight

Modify `update-changelog.js` to generate the list of changes itself

In preparation for executing the client release process entirely on
Jenkins, make update-changelog.js automatically update CHANGELOG.md with
a list of changes since the most recent tag, instead of requiring a
developer to do this manually.

This will lose the advantages of having a changelog written by humans, but
we primarily use other channels nowadays to notify staff and users about
Hypothesis product changes.

 - Add scripts/generate-change-list.js which generates a nicely
   formatted list of changes since the last release.
 - Use functions from generate-change-list.js to auto-populate section
   for new release in CHANGELOG.md when running `yarn version`.
parent d0bb1615
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
"bugs": "https://github.com/hypothesis/client/issues", "bugs": "https://github.com/hypothesis/client/issues",
"repository": "hypothesis/client", "repository": "hypothesis/client",
"devDependencies": { "devDependencies": {
"@octokit/rest": "^15.13.0",
"angular": "^1.6.9", "angular": "^1.6.9",
"angular-mocks": "^1.6.9", "angular-mocks": "^1.6.9",
"angular-route": "^1.6.9", "angular-route": "^1.6.9",
...@@ -102,6 +103,7 @@ ...@@ -102,6 +103,7 @@
"vinyl": "^1.1.1", "vinyl": "^1.1.1",
"watchify": "^3.7.0", "watchify": "^3.7.0",
"websocket": "^1.0.22", "websocket": "^1.0.22",
"wrap-text": "^1.0.7",
"zen-observable": "^0.3.0" "zen-observable": "^0.3.0"
}, },
"browserify": { "browserify": {
......
...@@ -5,4 +5,7 @@ ...@@ -5,4 +5,7 @@
"rules": { "rules": {
"no-console": "off", "no-console": "off",
}, },
"parserOptions": {
"ecmaVersion": 2018
}
} }
'use strict';
const { execSync } = require('child_process');
const wrapText = require('wrap-text');
/**
* Return a `Date` indicating when a Git tag was created.
*/
function getTagDate(tag) {
const result = execSync(`git tag --list "${tag}" "--format=%(taggerdate)"`, {
encoding: 'utf-8',
});
return new Date(result.trim());
}
/**
* Return the name of the most recently created Git tag.
*/
function getLastTag() {
const result = execSync('git tag --list --sort=-taggerdate', {
encoding: 'utf-8',
});
const tags = result.split('\n').map(line => line.trim());
if (tags.length === 0) {
return null;
}
return tags[0];
}
/**
* Iterate over pages of items in a GitHub API response and yield each item.
*/
async function* iterateItems(octokit, response) {
let isFirstPage = true;
while (isFirstPage || octokit.hasNextPage(response)) {
isFirstPage = false;
for (let item of response.data) {
yield item;
}
response = await octokit.getNextPage(response);
}
}
/**
* Return a list of PRs merged since `tag`, sorted in ascending order of merge date.
*/
async function getPRsMergedSince(octokit, org, repo, tag) {
const tagDate = getTagDate(tag);
let response = await octokit.pullRequests.getAll({
owner: org,
repo,
state: 'closed',
sort: 'updated',
direction: 'desc',
});
const prs = [];
for await (const pr of iterateItems(octokit, response)) {
if (!pr.merged_at) {
// This PR was closed without being merged.
continue;
}
// Stop once we get to a PR that was last updated before the tag was created.
const lastUpdateDate = new Date(pr.updated_at);
if (lastUpdateDate < tagDate) {
break;
}
// Only include PRs which were merged _after_ the tag was created.
const mergeDate = new Date(pr.merged_at);
if (mergeDate > tagDate) {
prs.push(pr);
}
}
// Sort PRs by merge date in ascending order.
return prs.sort((a, b) => {
const aMergedAt = new Date(a.merged_at);
const bMergedAt = new Date(b.merged_at);
return aMergedAt < bMergedAt ? -1 : 1;
});
}
/**
* Format a list of pull requests from the GitHub API into a markdown list.
*
* Each item includes the PR title, number and link. For example:
*
* ```
* - Fix clicking "Frobnob" button not frobnobbing [#123](
* https://github.com/hypothesis/client/pulls/123).
*
* - Fix clicking "Foobar" button not foobaring [#124](
* https://github.com/hypothesis/client/pulls/124).
* ```
*/
function formatChangeList(pullRequests) {
return pullRequests
.map(pr => `- ${pr.title} [#${pr.number}](${pr.url})`)
.map(item => wrapText(item, 90))
// Align the start of lines after the first with the text in the first line.
.map(item => item.replace(/\n/mg, '\n '))
.join('\n\n');
}
/**
* Return a markdown-formatted changelog of changes since a given tag, suitable
* for inclusion in a CHANGELOG.md file.
*
* If no tag is specified, default to the most recently created tag.
*/
async function changelistSinceTag(octokit, tag=getLastTag()) {
const org = 'hypothesis';
const repo = 'client';
const mergedPRs = await getPRsMergedSince(octokit, org, repo, tag);
return formatChangeList(mergedPRs);
}
module.exports = {
changelistSinceTag,
};
...@@ -8,22 +8,44 @@ ...@@ -8,22 +8,44 @@
'use strict'; 'use strict';
const fs = require('fs'); const fs = require('fs');
const process = require('process');
const octokit = require('@octokit/rest')();
const pkg = require('../package.json'); const pkg = require('../package.json');
const { changelistSinceTag } = require('./generate-change-list');
const dateStr = new Date().toISOString().slice(0,10); /**
const versionLine = `## [${pkg.version}] - ${dateStr}`; * Update CHANGELOG.md with details of pull requests merged since the previous
* release.
*/
async function updateChangeLog() {
if (process.env.GITHUB_TOKEN) {
octokit.authenticate({
type: 'oauth',
token: process.env.GITHUB_TOKEN,
});
} else {
console.warn('GITHUB_TOKEN env var not set. API calls may hit rate limits.');
}
const changelogPath = require.resolve('../CHANGELOG.md'); const dateStr = new Date().toISOString().slice(0,10);
const changelog = fs.readFileSync(changelogPath).toString(); const changelist = await changelistSinceTag(octokit);
const updatedChangelog = changelog.split('\n')
.map(ln => ln.match(/\[Unreleased\]/) ? versionLine : ln)
.join('\n');
if (updatedChangelog === changelog) { const changelogPath = require.resolve('../CHANGELOG.md');
console.error('Failed to find "Unreleased" section in changelog'); const changelog = fs.readFileSync(changelogPath).toString();
process.exit(1); const updatedChangelog = changelog.replace(
} '# Change Log',
`# Change Log
fs.writeFileSync(changelogPath, updatedChangelog); ## [${pkg.version}] - ${dateStr}
### Changed
${changelist}
`);
fs.writeFileSync(changelogPath, updatedChangelog);
}
updateChangeLog();
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment