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.feature/default_network_editable
parent
c339f28ce8
commit
312f2afc41
@ -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<string>} CategoryChanges |
||||
*/ |
||||
|
||||
/** |
||||
* Release changes, organized by category |
||||
* @typedef {Record<keyof ChangeCategories, CategoryChanges>} ReleaseChanges |
||||
*/ |
||||
|
||||
/** |
||||
* Changelog changes, organized by release and by category. |
||||
* @typedef {Record<Version|Unreleased, ReleaseChanges>} 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<ReleaseMetadata>} 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; |
@ -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<string, string>} 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<keyof ChangeCategories>} |
||||
*/ |
||||
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, |
||||
}; |
@ -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 }; |
@ -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 `<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', |
||||
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 }; |
Loading…
Reference in new issue