Replace `auto-changelog` script (#10993)
The `auto-changelog` script has been replaced with the package `@metamask/auto-changelog`. This package includes a script that has an `update` command that is roughly equivalent to the old `auto-changelog.js` script, except better. The script also has a `validate` command. The `repository` field was added to `package.json` because it's utilized by the `auto-changelog` script, and this was easier than specifying the repository URL with a CLI argument.feature/default_network_editable
parent
0e17ad3450
commit
20b0346d8b
@ -1,67 +0,0 @@ |
||||
#!/usr/bin/env node
|
||||
const fs = require('fs').promises; |
||||
|
||||
const path = require('path'); |
||||
const { version } = require('../app/manifest/_base.json'); |
||||
const { updateChangelog } = require('./lib/changelog/updateChangelog'); |
||||
const { unreleased } = require('./lib/changelog/constants'); |
||||
|
||||
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. |
||||
-h, --help Display this help and exit. |
||||
|
||||
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. |
||||
`;
|
||||
|
||||
async function main() { |
||||
const args = process.argv.slice(2); |
||||
let isReleaseCandidate = false; |
||||
|
||||
for (const arg of args) { |
||||
if (arg === '--rc') { |
||||
isReleaseCandidate = true; |
||||
} else if (['--help', '-h'].includes(arg)) { |
||||
console.log(helpText); |
||||
process.exit(0); |
||||
} else { |
||||
console.error( |
||||
`Unrecognized argument: ${arg}\nTry '${command} --help' for more information.\n`, |
||||
); |
||||
process.exit(1); |
||||
} |
||||
} |
||||
|
||||
const changelogFilename = path.resolve(__dirname, '..', 'CHANGELOG.md'); |
||||
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'); |
||||
} |
||||
|
||||
main().catch((error) => { |
||||
console.error(error); |
||||
process.exit(1); |
||||
}); |
@ -1,305 +0,0 @@ |
||||
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); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Migrate all unreleased changes to a release section. |
||||
* |
||||
* Changes are migrated in their existing categories, and placed above any |
||||
* pre-existing changes in that category. |
||||
* |
||||
* @param {Version} version - The release version to migrate unreleased |
||||
* changes to. |
||||
*/ |
||||
migrateUnreleasedChangesToRelease(version) { |
||||
const releaseChanges = this._changes[version]; |
||||
if (!releaseChanges) { |
||||
throw new Error(`Specified release version does not exist: '${version}'`); |
||||
} |
||||
|
||||
const unreleasedChanges = this._changes[unreleased]; |
||||
|
||||
for (const category of Object.keys(unreleasedChanges)) { |
||||
if (releaseChanges[category]) { |
||||
releaseChanges[category] = [ |
||||
...unreleasedChanges[category], |
||||
...releaseChanges[category], |
||||
]; |
||||
} else { |
||||
releaseChanges[category] = unreleasedChanges[category]; |
||||
} |
||||
} |
||||
this._changes[unreleased] = {}; |
||||
} |
||||
|
||||
/** |
||||
* 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; |
@ -1,68 +0,0 @@ |
||||
/** |
||||
* 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, |
||||
}; |
@ -1,84 +0,0 @@ |
||||
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 }; |
@ -1,171 +0,0 @@ |
||||
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), |
||||
); |
||||
|
||||
const hasUnreleasedChanges = changelog.getUnreleasedChanges().length !== 0; |
||||
if ( |
||||
newCommits.length === 0 && |
||||
(!isReleaseCandidate || hasUnreleasedChanges) |
||||
) { |
||||
return undefined; |
||||
} |
||||
|
||||
// Ensure release header exists, if necessary
|
||||
if ( |
||||
isReleaseCandidate && |
||||
!changelog |
||||
.getReleases() |
||||
.find((release) => release.version === currentVersion) |
||||
) { |
||||
changelog.addRelease({ version: currentVersion }); |
||||
} |
||||
|
||||
if (isReleaseCandidate && hasUnreleasedChanges) { |
||||
changelog.migrateUnreleasedChangesToRelease(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 }; |
@ -1,79 +0,0 @@ |
||||
const spawn = require('cross-spawn'); |
||||
|
||||
/** |
||||
* Run a command to completion using the system shell. |
||||
* |
||||
* This will run a command with the specified arguments, and resolve when the |
||||
* process has exited. The STDOUT stream is monitored for output, which is |
||||
* returned after being split into lines. All output is expected to be UTF-8 |
||||
* encoded, and empty lines are removed from the output. |
||||
* |
||||
* Anything received on STDERR is assumed to indicate a problem, and is tracked |
||||
* as an error. |
||||
* |
||||
* @param {string} command - The command to run |
||||
* @param {Array<string>} [args] - The arguments to pass to the command |
||||
* @returns {Array<string>} Lines of output received via STDOUT |
||||
*/ |
||||
async function runCommand(command, args) { |
||||
const output = []; |
||||
let mostRecentError; |
||||
let errorSignal; |
||||
let errorCode; |
||||
const internalError = new Error('Internal'); |
||||
try { |
||||
await new Promise((resolve, reject) => { |
||||
const childProcess = spawn(command, args, { encoding: 'utf8' }); |
||||
childProcess.stdout.setEncoding('utf8'); |
||||
childProcess.stderr.setEncoding('utf8'); |
||||
|
||||
childProcess.on('error', (error) => { |
||||
mostRecentError = error; |
||||
}); |
||||
|
||||
childProcess.stdout.on('data', (message) => { |
||||
const nonEmptyLines = message.split('\n').filter((line) => line !== ''); |
||||
output.push(...nonEmptyLines); |
||||
}); |
||||
|
||||
childProcess.stderr.on('data', (message) => { |
||||
mostRecentError = new Error(message.trim()); |
||||
}); |
||||
|
||||
childProcess.once('exit', (code, signal) => { |
||||
if (code === 0) { |
||||
return resolve(); |
||||
} |
||||
errorCode = code; |
||||
errorSignal = signal; |
||||
return reject(internalError); |
||||
}); |
||||
}); |
||||
} catch (error) { |
||||
/** |
||||
* The error is re-thrown here in an `async` context to preserve the stack trace. If this was |
||||
* was thrown inside the Promise constructor, the stack trace would show a few frames of |
||||
* Node.js internals then end, without indicating where `runCommand` was called. |
||||
*/ |
||||
if (error === internalError) { |
||||
let errorMessage; |
||||
if (errorCode !== null && errorSignal !== null) { |
||||
errorMessage = `Terminated by signal '${errorSignal}'; exited with code '${errorCode}'`; |
||||
} else if (errorSignal !== null) { |
||||
errorMessage = `Terminaled by signal '${errorSignal}'`; |
||||
} else if (errorCode === null) { |
||||
errorMessage = 'Exited with no code or signal'; |
||||
} else { |
||||
errorMessage = `Exited with code '${errorCode}'`; |
||||
} |
||||
const improvedError = new Error(errorMessage); |
||||
if (mostRecentError) { |
||||
improvedError.cause = mostRecentError; |
||||
} |
||||
throw improvedError; |
||||
} |
||||
} |
||||
return output; |
||||
} |
||||
|
||||
module.exports = runCommand; |
Loading…
Reference in new issue