From 312f2afc41cae9d08690fee621ed6424a2cc04bb Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 8 Apr 2021 16:14:30 -0230 Subject: [PATCH] Refactor changelog parsing and generation (#10847) 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. --- development/auto-changelog.js | 177 ++---------- development/lib/changelog/changelog.js | 275 +++++++++++++++++++ development/lib/changelog/constants.js | 68 +++++ development/lib/changelog/parseChangelog.js | 84 ++++++ development/lib/changelog/updateChangelog.js | 163 +++++++++++ package.json | 1 + yarn.lock | 7 + 7 files changed, 618 insertions(+), 157 deletions(-) create mode 100644 development/lib/changelog/changelog.js create mode 100644 development/lib/changelog/constants.js create mode 100644 development/lib/changelog/parseChangelog.js create mode 100644 development/lib/changelog/updateChangelog.js diff --git a/development/auto-changelog.js b/development/auto-changelog.js index 8a6d8f08a..455bf5beb 100755 --- a/development/auto-changelog.js +++ b/development/auto-changelog.js @@ -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. `; @@ -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 ` (#)` - 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 - // # from `, 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'); } diff --git a/development/lib/changelog/changelog.js b/development/lib/changelog/changelog.js new file mode 100644 index 000000000..07c0cd01c --- /dev/null +++ b/development/lib/changelog/changelog.js @@ -0,0 +1,275 @@ +const semver = require('semver'); + +const { orderedChangeCategories, unreleased } = require('./constants'); + +const changelogTitle = '# Changelog'; +const changelogDescription = `All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).`; + +// Stringification helpers + +function stringifyCategory(category, changes) { + const categoryHeader = `### ${category}`; + if (changes.length === 0) { + return categoryHeader; + } + const changeDescriptions = changes + .map((description) => `- ${description}`) + .join('\n'); + return `${categoryHeader}\n${changeDescriptions}`; +} + +function stringifyRelease(version, categories, { date, status } = {}) { + const releaseHeader = `## [${version}]${date ? ` - ${date}` : ''}${ + status ? ` [${status}]` : '' + }`; + const categorizedChanges = orderedChangeCategories + .filter((category) => categories[category]) + .map((category) => { + const changes = categories[category]; + return stringifyCategory(category, changes); + }) + .join('\n\n'); + if (categorizedChanges === '') { + return releaseHeader; + } + return `${releaseHeader}\n${categorizedChanges}`; +} + +function stringifyReleases(releases, changes) { + const stringifiedUnreleased = stringifyRelease( + unreleased, + changes[unreleased], + ); + const stringifiedReleases = releases.map(({ version, date, status }) => { + const categories = changes[version]; + return stringifyRelease(version, categories, { date, status }); + }); + + return [stringifiedUnreleased, ...stringifiedReleases].join('\n\n'); +} + +function withTrailingSlash(url) { + return url.endsWith('/') ? url : `${url}/`; +} + +function getCompareUrl(repoUrl, firstRef, secondRef) { + return `${withTrailingSlash(repoUrl)}compare/${firstRef}...${secondRef}`; +} + +function getTagUrl(repoUrl, tag) { + return `${withTrailingSlash(repoUrl)}releases/tag/${tag}`; +} + +function stringifyLinkReferenceDefinitions(repoUrl, releases) { + const orderedReleases = releases + .map(({ version }) => version) + .sort((a, b) => semver.gt(a, b)); + + // The "Unreleased" section represents all changes made since the *highest* + // release, not the most recent release. This is to accomodate patch releases + // of older versions that don't represent the latest set of changes. + // + // For example, if a library has a v2.0.0 but the v1.0.0 release needed a + // security update, the v1.0.1 release would then be the most recent, but the + // range of unreleased changes would remain `v2.0.0...HEAD`. + const unreleasedLinkReferenceDefinition = `[${unreleased}]: ${getCompareUrl( + repoUrl, + `v${orderedReleases[0]}`, + 'HEAD', + )}`; + + // The "previous" release that should be used for comparison is not always + // the most recent release chronologically. The _highest_ version that is + // lower than the current release is used as the previous release, so that + // patch releases on older releases can be accomodated. + const releaseLinkReferenceDefinitions = releases + .map(({ version }) => { + if (version === orderedReleases[orderedReleases.length - 1]) { + return `[${version}]: ${getTagUrl(repoUrl, `v${version}`)}`; + } + const versionIndex = orderedReleases.indexOf(version); + const previousVersion = orderedReleases + .slice(versionIndex) + .find((releaseVersion) => { + return semver.gt(version, releaseVersion); + }); + return `[${version}]: ${getCompareUrl( + repoUrl, + `v${previousVersion}`, + `v${version}`, + )}`; + }) + .join('\n'); + return `${unreleasedLinkReferenceDefinition}\n${releaseLinkReferenceDefinitions}${ + releases.length > 0 ? '\n' : '' + }`; +} + +/** + * @typedef {import('./constants.js').Unreleased} Unreleased + * @typedef {import('./constants.js').ChangeCategories ChangeCategories} + */ +/** + * @typedef {import('./constants.js').Version} Version + */ +/** + * Release metadata. + * @typedef {Object} ReleaseMetadata + * @property {string} date - An ISO-8601 formatted date, representing the + * release date. + * @property {string} status -The status of the release (e.g. 'WITHDRAWN', 'DEPRECATED') + * @property {Version} version - The version of the current release. + */ + +/** + * Category changes. A list of changes in a single category. + * @typedef {Array} CategoryChanges + */ + +/** + * Release changes, organized by category + * @typedef {Record} ReleaseChanges + */ + +/** + * Changelog changes, organized by release and by category. + * @typedef {Record} ChangelogChanges + */ + +/** + * A changelog that complies with the ["keep a changelog" v1.1.0 guidelines]{@link https://keepachangelog.com/en/1.0.0/}. + * + * This changelog starts out completely empty, and allows new releases and + * changes to be added such that the changelog remains compliant at all times. + * This can be used to help validate the contents of a changelog, normalize + * formatting, update a changelog, or build one from scratch. + */ +class Changelog { + /** + * Construct an empty changelog + * + * @param {Object} options + * @param {string} options.repoUrl - The GitHub repository URL for the current project + */ + constructor({ repoUrl }) { + this._releases = []; + this._changes = { [unreleased]: {} }; + this._repoUrl = repoUrl; + } + + /** + * Add a release to the changelog + * + * @param {Object} options + * @param {boolean} [options.addToStart] - Determines whether the release is + * added to the top or bottom of the changelog. This defaults to 'true' + * because new releases should be added to the top of the changelog. This + * should be set to 'false' when parsing a changelog top-to-bottom. + * @param {string} [options.date] - An ISO-8601 formatted date, representing the + * release date. + * @param {string} [options.status] - The status of the release (e.g. + * 'WITHDRAWN', 'DEPRECATED') + * @param {Version} options.version - The version of the current release, + * which should be a [semver]{@link https://semver.org/spec/v2.0.0.html}- + * compatible version. + */ + addRelease({ addToStart = true, date, status, version }) { + if (!version) { + throw new Error('Version required'); + } else if (semver.valid(version) === null) { + throw new Error(`Not a valid semver version: '${version}'`); + } else if (this._changes[version]) { + throw new Error(`Release already exists: '${version}'`); + } + + this._changes[version] = {}; + const newRelease = { version, date, status }; + if (addToStart) { + this._releases.unshift(newRelease); + } else { + this._releases.push(newRelease); + } + } + + /** + * Add a change to the changelog + * + * @param {Object} options + * @param {boolean} [options.addToStart] - Determines whether the change is + * added to the top or bottom of the list of changes in this category. This + * defaults to 'true' because changes should be in reverse-chronological + * order. This should be set to 'false' when parsing a changelog top-to- + * bottom. + * @param {string} options.category - The category of the change. + * @param {string} options.description - The description of the change. + * @param {Version} [options.version] - The version this change was released + * in. If this is not given, the change is assumed to be unreleased. + */ + addChange({ addToStart = true, category, description, version }) { + if (!category) { + throw new Error('Category required'); + } else if (!orderedChangeCategories.includes(category)) { + throw new Error(`Unrecognized category: '${category}'`); + } else if (!description) { + throw new Error('Description required'); + } else if (version !== undefined && !this._changes[version]) { + throw new Error(`Specified release version does not exist: '${version}'`); + } + + const release = version + ? this._changes[version] + : this._changes[unreleased]; + + if (!release[category]) { + release[category] = []; + } + if (addToStart) { + release[category].unshift(description); + } else { + release[category].push(description); + } + } + + /** + * Gets the metadata for all releases. + * @returns {Array} The metadata for each release. + */ + getReleases() { + return this._releases; + } + + /** + * Gets the changes in the given release, organized by category. + * @param {Version} version - The version of the release being retrieved. + * @returns {ReleaseChanges} The changes included in the given released. + */ + getReleaseChanges(version) { + return this._changes[version]; + } + + /** + * Gets all changes that have not yet been released + * @returns {ReleaseChanges} The changes that have not yet been released. + */ + getUnreleasedChanges() { + return this._changes[unreleased]; + } + + /** + * The stringified changelog, formatted according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + * @returns {string} The stringified changelog. + */ + toString() { + return `${changelogTitle} +${changelogDescription} + +${stringifyReleases(this._releases, this._changes)} + +${stringifyLinkReferenceDefinitions(this._repoUrl, this._releases)}`; + } +} + +module.exports = Changelog; diff --git a/development/lib/changelog/constants.js b/development/lib/changelog/constants.js new file mode 100644 index 000000000..c2b8ae008 --- /dev/null +++ b/development/lib/changelog/constants.js @@ -0,0 +1,68 @@ +/** + * Version string + * @typedef {string} Version - A [SemVer]{@link https://semver.org/spec/v2.0.0.html}- + * compatible version string. + */ + +/** + * Change categories. + * + * Most of these categories are from [Keep a Changelog]{@link https://keepachangelog.com/en/1.0.0/}. + * The "Uncategorized" category was added because we have many changes from + * older releases that would be difficult to categorize. + * + * @typedef {Record} ChangeCategories + * @property {'Added'} Added - for new features. + * @property {'Changed'} Changed - for changes in existing functionality. + * @property {'Deprecated'} Deprecated - for soon-to-be removed features. + * @property {'Fixed'} Fixed - for any bug fixes. + * @property {'Removed'} Removed - for now removed features. + * @property {'Security'} Security - in case of vulnerabilities. + * @property {'Uncategorized'} Uncategorized - for any changes that have not + * yet been categorized. + */ + +/** + * @type {ChangeCategories} + */ +const changeCategories = { + Added: 'Added', + Changed: 'Changed', + Deprecated: 'Deprecated', + Fixed: 'Fixed', + Removed: 'Removed', + Security: 'Security', + Uncategorized: 'Uncategorized', +}; + +/** + * Change categories in the order in which they should be listed in the + * changelog. + * + * @type {Array} + */ +const orderedChangeCategories = [ + 'Uncategorized', + 'Added', + 'Changed', + 'Deprecated', + 'Removed', + 'Fixed', + 'Security', +]; + +/** + * The header for the section of the changelog listing unreleased changes. + * @typedef {'Unreleased'} Unreleased + */ + +/** + * @type {Unreleased} + */ +const unreleased = 'Unreleased'; + +module.exports = { + changeCategories, + orderedChangeCategories, + unreleased, +}; diff --git a/development/lib/changelog/parseChangelog.js b/development/lib/changelog/parseChangelog.js new file mode 100644 index 000000000..228da3635 --- /dev/null +++ b/development/lib/changelog/parseChangelog.js @@ -0,0 +1,84 @@ +const Changelog = require('./changelog'); +const { unreleased } = require('./constants'); + +function truncated(line) { + return line.length > 80 ? `${line.slice(0, 80)}...` : line; +} + +/** + * Constructs a Changelog instance that represents the given changelog, which + * is parsed for release and change informatino. + * @param {Object} options + * @param {string} options.changelogContent - The changelog to parse + * @param {string} options.repoUrl - The GitHub repository URL for the current + * project. + * @returns {Changelog} A changelog instance that reflects the changelog text + * provided. + */ +function parseChangelog({ changelogContent, repoUrl }) { + const changelogLines = changelogContent.split('\n'); + const changelog = new Changelog({ repoUrl }); + + const unreleasedHeaderIndex = changelogLines.indexOf(`## [${unreleased}]`); + if (unreleasedHeaderIndex === -1) { + throw new Error(`Failed to find ${unreleased} header`); + } + const unreleasedLinkReferenceDefinition = changelogLines.findIndex((line) => { + return line.startsWith(`[${unreleased}]: `); + }); + if (unreleasedLinkReferenceDefinition === -1) { + throw new Error(`Failed to find ${unreleased} link reference definition`); + } + + const contentfulChangelogLines = changelogLines + .slice(unreleasedHeaderIndex + 1, unreleasedLinkReferenceDefinition) + .filter((line) => line !== ''); + + let mostRecentRelease; + let mostRecentCategory; + for (const line of contentfulChangelogLines) { + if (line.startsWith('## [')) { + const results = line.match( + /^## \[(\d+\.\d+\.\d+)\](?: - (\d\d\d\d-\d\d-\d\d))?(?: \[(\w+)\])?/u, + ); + if (results === null) { + throw new Error(`Malformed release header: '${truncated(line)}'`); + } + mostRecentRelease = results[1]; + mostRecentCategory = undefined; + const date = results[2]; + const status = results[3]; + changelog.addRelease({ + addToStart: false, + date, + status, + version: mostRecentRelease, + }); + } else if (line.startsWith('### ')) { + const results = line.match(/^### (\w+)$\b/u); + if (results === null) { + throw new Error(`Malformed category header: '${truncated(line)}'`); + } + mostRecentCategory = results[1]; + } else if (line.startsWith('- ')) { + if (mostRecentCategory === undefined) { + throw new Error(`Category missing for change: '${truncated(line)}'`); + } + const description = line.slice(2); + changelog.addChange({ + addToStart: false, + category: mostRecentCategory, + description, + version: mostRecentRelease, + }); + } else if (mostRecentRelease === null) { + continue; + } else { + throw new Error(`Unrecognized line: '${truncated(line)}'`); + } + } + + return changelog; +} + +module.exports = { parseChangelog }; diff --git a/development/lib/changelog/updateChangelog.js b/development/lib/changelog/updateChangelog.js new file mode 100644 index 000000000..2198b8f0a --- /dev/null +++ b/development/lib/changelog/updateChangelog.js @@ -0,0 +1,163 @@ +const assert = require('assert').strict; +const runCommand = require('../runCommand'); +const { parseChangelog } = require('./parseChangelog'); +const { changeCategories } = require('./constants'); + +async function getMostRecentTag() { + 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'); + return mostRecentTag; +} + +async function getCommits(commitHashes) { + const commits = []; + for (const commitHash of commitHashes) { + const [subject] = await runCommand('git', [ + 'show', + '-s', + '--format=%s', + commitHash, + ]); + + let prNumber; + let description = subject; + + // Squash & Merge: the commit subject is parsed as ` (#)` + 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 + // # from `, 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', + commitHash, + ]); + description = firstLineOfBody || subject; + } + // Otherwise: + // Normal commits: The commit subject is the description, and the PR ID is omitted. + + commits.push({ prNumber, description }); + } + return commits; +} + +function getAllChangeDescriptions(changelog) { + const releases = changelog.getReleases(); + const changeDescriptions = Object.values( + changelog.getUnreleasedChanges(), + ).flat(); + for (const release of releases) { + changeDescriptions.push( + ...Object.values(changelog.getReleaseChanges(release.version)).flat(), + ); + } + return changeDescriptions; +} + +function getAllLoggedPrNumbers(changelog) { + const changeDescriptions = getAllChangeDescriptions(changelog); + + const prNumbersWithChangelogEntries = []; + for (const description of changeDescriptions) { + const matchResults = description.match(/^\[#(\d+)\]/u); + if (matchResults === null) { + continue; + } + const prNumber = matchResults[1]; + prNumbersWithChangelogEntries.push(prNumber); + } + + return prNumbersWithChangelogEntries; +} + +/** + * @typedef {import('./constants.js').Version} Version + */ + +/** + * Update a changelog with any commits made since the last release. Commits for + * PRs that are already included in the changelog are omitted. + * @param {Object} options + * @param {string} options.changelogContent - The current changelog + * @param {Version} options.currentVersion - The current version + * @param {string} options.repoUrl - The GitHub repository URL for the current + * project. + * @param {boolean} options.isReleaseCandidate - Denotes whether the current + * project is in the midst of release preparation or not. If this is set, any + * new changes are listed under the current release header. Otherwise, they + * are listed under the 'Unreleased' section. + * @returns + */ +async function updateChangelog({ + changelogContent, + currentVersion, + repoUrl, + isReleaseCandidate, +}) { + const changelog = parseChangelog({ changelogContent, repoUrl }); + + // Ensure we have all tags on remote + await runCommand('git', ['fetch', '--tags']); + const mostRecentTag = await getMostRecentTag(); + const commitsHashesSinceLastRelease = await runCommand('git', [ + 'rev-list', + `${mostRecentTag}..HEAD`, + ]); + const commits = await getCommits(commitsHashesSinceLastRelease); + + const loggedPrNumbers = getAllLoggedPrNumbers(changelog); + const newCommits = commits.filter( + ({ prNumber }) => !loggedPrNumbers.includes(prNumber), + ); + + if (newCommits.length === 0) { + return undefined; + } + + // Ensure release header exists, if necessary + if ( + isReleaseCandidate && + !changelog + .getReleases() + .find((release) => release.version === currentVersion) + ) { + changelog.addRelease({ currentVersion }); + } + + const newChangeEntries = newCommits.map(({ prNumber, description }) => { + if (prNumber) { + const prefix = `[#${prNumber}](${repoUrl}/pull/${prNumber})`; + return `${prefix}: ${description}`; + } + return description; + }); + + for (const description of newChangeEntries.reverse()) { + changelog.addChange({ + version: isReleaseCandidate ? currentVersion : undefined, + category: changeCategories.Uncategorized, + description, + }); + } + + return changelog.toString(); +} + +module.exports = { updateChangelog }; diff --git a/package.json b/package.json index 2fe6ed9d6..30c09015a 100644 --- a/package.json +++ b/package.json @@ -280,6 +280,7 @@ "sass": "^1.32.4", "sass-loader": "^10.1.1", "selenium-webdriver": "4.0.0-alpha.7", + "semver": "^7.3.5", "serve-handler": "^6.1.2", "sinon": "^9.0.0", "source-map": "^0.7.2", diff --git a/yarn.lock b/yarn.lock index b47f81af3..28b7220d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23334,6 +23334,13 @@ semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: dependencies: lru-cache "^6.0.0" +semver@^7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + semver@~5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e"