Skip to content

Commit

Permalink
Refactor changelog parsing and generation
Browse files Browse the repository at this point in the history
The `auto-changelog.js` script has been refactoring into various
different modules. This was done in preparation for migrating this to
a separate repository, where it can be used in our libraries as well.

Functionally this should act _mostly_ the same way, but there have been
some changes. It was difficult to make this a pure refactor because of
the strategy used to validate the changelog and ensure each addition
remained valid. Instead of being updated in-place, the changelog is now
parsed upfront and stored as a "Changelog" instance, which is a new
class that was written to allow only valid changes. The new changelog
is then stringified and completely overwrites the old one.

The parsing had to be much more strict, as any unanticipated content
would otherwise be erased unintentionally. This script now also
normalizes the formatting of the changelog (though the individual
change descriptions are still unformatted).

The changelog stringification now accommodates non-linear releases as
well. For example, you can now release v1.0.1 *after* v2.0.0, and it
will be listed in chronological order while also correctly constructing
the `compare` URLs for each release.
  • Loading branch information
Gudahtt committed Apr 8, 2021
1 parent b65a94b commit 459b991
Show file tree
Hide file tree
Showing 7 changed files with 618 additions and 157 deletions.
177 changes: 20 additions & 157 deletions development/auto-changelog.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
#!/usr/bin/env node
const fs = require('fs').promises;
const assert = require('assert').strict;

const path = require('path');
const { escapeRegExp } = require('lodash');
const { version } = require('../app/manifest/_base.json');
const runCommand = require('./lib/runCommand');
const { updateChangelog } = require('./lib/changelog/updateChangelog');
const { unreleased } = require('./lib/changelog/constants');

const URL = '/~https://github.com/MetaMask/metamask-extension';
const REPO_URL = '/~https://github.com/MetaMask/metamask-extension';

const command = 'yarn update-changelog';

const helpText = `Usage: ${command} [--rc] [-h|--help]
Update CHANGELOG.md with any changes made since the most recent release.
Options:
--rc Add new changes to the current release header, rather than to the
'Unreleased' section.
'${unreleased}' section.
-h, --help Display this help and exit.
New commits will be added to the "Unreleased" section (or to the section for the
New commits will be added to the "${unreleased}" section (or to the section for the
current release if the '--rc' flag is used) in reverse chronological order. Any
commits for PRs that are represented already in the changelog will be ignored.
If the '--rc' flag is used and the section for the current release does not yet
exist, it will be created.
`;
Expand All @@ -42,158 +44,19 @@ async function main() {
}
}

await runCommand('git', ['fetch', '--tags']);

const [mostRecentTagCommitHash] = await runCommand('git', [
'rev-list',
'--tags',
'--max-count=1',
]);
const [mostRecentTag] = await runCommand('git', [
'describe',
'--tags',
mostRecentTagCommitHash,
]);
assert.equal(mostRecentTag[0], 'v', 'Most recent tag should start with v');

const commitsSinceLastRelease = await runCommand('git', [
'rev-list',
`${mostRecentTag}..HEAD`,
]);

const commitEntries = [];
for (const commit of commitsSinceLastRelease) {
const [subject] = await runCommand('git', [
'show',
'-s',
'--format=%s',
commit,
]);

let prNumber;
let description = subject;

// Squash & Merge: the commit subject is parsed as `<description> (#<PR ID>)`
if (subject.match(/\(#\d+\)/u)) {
const matchResults = subject.match(/\(#(\d+)\)/u);
prNumber = matchResults[1];
description = subject.match(/^(.+)\s\(#\d+\)/u)[1];
// Merge: the PR ID is parsed from the git subject (which is of the form `Merge pull request
// #<PR ID> from <branch>`, and the description is assumed to be the first line of the body.
// If no body is found, the description is set to the commit subject
} else if (subject.match(/#\d+\sfrom/u)) {
const matchResults = subject.match(/#(\d+)\sfrom/u);
prNumber = matchResults[1];
const [firstLineOfBody] = await runCommand('git', [
'show',
'-s',
'--format=%b',
commit,
]);
description = firstLineOfBody || subject;
}
// Otherwise:
// Normal commits: The commit subject is the description, and the PR ID is omitted.

commitEntries.push({ prNumber, description });
}

const changelogFilename = path.resolve(__dirname, '..', 'CHANGELOG.md');
const changelog = await fs.readFile(changelogFilename, { encoding: 'utf8' });
const changelogLines = changelog.split('\n');

const prNumbersWithChangelogEntries = [];
for (const line of changelogLines) {
const matchResults = line.match(/- \[#(\d+)\]/u);
if (matchResults === null) {
continue;
}
const prNumber = matchResults[1];
prNumbersWithChangelogEntries.push(prNumber);
}

const changelogEntries = [];
for (const { prNumber, description } of commitEntries) {
if (prNumbersWithChangelogEntries.includes(prNumber)) {
continue;
}

let changelogEntry;
if (prNumber) {
const prefix = `[#${prNumber}](${URL}/pull/${prNumber})`;
changelogEntry = `- ${prefix}: ${description}`;
} else {
changelogEntry = `- ${description}`;
}
changelogEntries.push(changelogEntry);
}

if (changelogEntries.length === 0) {
console.log('CHANGELOG required no updates');
return;
}

const versionHeader = `## [${version}]`;
const escapedVersionHeader = escapeRegExp(versionHeader);
const currentDevelopBranchHeader = '## [Unreleased]';
const currentReleaseHeaderPattern = isReleaseCandidate
? // This ensures this doesn't match on a version with a suffix
// e.g. v9.0.0 should not match on the header v9.0.0-beta.0
`${escapedVersionHeader}$|${escapedVersionHeader}\\s`
: escapeRegExp(currentDevelopBranchHeader);

let releaseHeaderIndex = changelogLines.findIndex((line) =>
line.match(new RegExp(currentReleaseHeaderPattern, 'u')),
);
if (releaseHeaderIndex === -1) {
if (!isReleaseCandidate) {
throw new Error(
`Failed to find release header '${currentDevelopBranchHeader}'`,
);
}

// Add release header if not found
const firstReleaseHeaderIndex = changelogLines.findIndex((line) =>
line.match(/## \[\d+\.\d+\.\d+\]/u),
);
const [, previousVersion] = changelogLines[firstReleaseHeaderIndex].match(
/## \[(\d+\.\d+\.\d+)\]/u,
);
changelogLines.splice(firstReleaseHeaderIndex, 0, versionHeader, '');
releaseHeaderIndex = firstReleaseHeaderIndex;

// Update release link reference definitions
// A link reference definition is added for the new release, and the
// "Unreleased" header is updated to point at the range of commits merged
// after the most recent release.
const unreleasedLinkIndex = changelogLines.findIndex((line) =>
line.match(/\[Unreleased\]:/u),
);
changelogLines.splice(
unreleasedLinkIndex,
1,
`[Unreleased]: ${URL}/compare/v${version}...HEAD`,
`[${version}]: ${URL}/compare/v${previousVersion}...v${version}`,
);
}

// Ensure "Uncategorized" header is present
const uncategorizedHeaderIndex = releaseHeaderIndex + 1;
const uncategorizedHeader = '### Uncategorized';
if (changelogLines[uncategorizedHeaderIndex] !== '### Uncategorized') {
const hasEmptyLine = changelogLines[uncategorizedHeaderIndex] === '';
changelogLines.splice(
uncategorizedHeaderIndex,
0,
uncategorizedHeader,
// Ensure an empty line follows the new header
...(hasEmptyLine ? [] : ['']),
);
}

changelogLines.splice(uncategorizedHeaderIndex + 1, 0, ...changelogEntries);
const updatedChangelog = changelogLines.join('\n');
await fs.writeFile(changelogFilename, updatedChangelog);
const changelogContent = await fs.readFile(changelogFilename, {
encoding: 'utf8',
});

const newChangelogContent = await updateChangelog({
changelogContent,
currentVersion: version,
repoUrl: REPO_URL,
isReleaseCandidate,
});

await fs.writeFile(changelogFilename, newChangelogContent);

console.log('CHANGELOG updated');
}
Expand Down
Loading

0 comments on commit 459b991

Please sign in to comment.