From c9245796c59e100847acaa4278399ae6908b7fd3 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Fri, 28 Oct 2022 10:00:15 -0230 Subject: [PATCH] Support alternate tag prefixes We should support alternate prefixes in version tags. At the moment the project assumes that the prefix v is always used. But some projects use no prefix, or include the package as a prefix to the version (e.g. in monorepos). Fixes #116 --- src/changelog.test.ts | 12 ++++++++- src/changelog.ts | 32 +++++++++++++++++++----- src/cli.ts | 23 ++++++++++++++++-- src/init.test.ts | 6 +++++ src/init.ts | 11 +++++++-- src/parse-changelog.ts | 5 +++- src/update-changelog.ts | 51 +++++++++++++++++++++++++++++++-------- src/validate-changelog.ts | 5 +++- 8 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/changelog.test.ts b/src/changelog.test.ts index f669630..cadf47f 100644 --- a/src/changelog.test.ts +++ b/src/changelog.test.ts @@ -13,7 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 describe('Changelog', () => { it('should allow creating an empty changelog', () => { - const changelog = new Changelog({ repoUrl: 'fake://metamask.io' }); + const changelog = new Changelog({ + repoUrl: 'fake://metamask.io', + }); + expect(changelog.toString()).toStrictEqual(emptyChangelog); + }); + + it('should allow creating an empty changelog with a custom tag prefix', () => { + const changelog = new Changelog({ + repoUrl: 'fake://metamask.io', + tagPrefix: 'example@v', + }); expect(changelog.toString()).toStrictEqual(emptyChangelog); }); }); diff --git a/src/changelog.ts b/src/changelog.ts index c53d8db..2d05078 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -157,11 +157,13 @@ function getTagUrl(repoUrl: string, tag: string) { * previous release. * * @param repoUrl - The URL for the GitHub repository. + * @param tagPrefix - The prefix used in tags before the version number. * @param releases - The releases to generate link definitions for. * @returns The stringified release link definitions. */ function stringifyLinkReferenceDefinitions( repoUrl: string, + tagPrefix: string, releases: ReleaseMetadata[], ) { // A list of release versions in descending SemVer order @@ -187,7 +189,7 @@ function stringifyLinkReferenceDefinitions( // the link definition. const unreleasedLinkReferenceDefinition = `[${unreleased}]: ${ hasReleases - ? getCompareUrl(repoUrl, `v${latestSemverVersion}`, 'HEAD') + ? getCompareUrl(repoUrl, `${tagPrefix}${latestSemverVersion}`, 'HEAD') : withTrailingSlash(repoUrl) }`; @@ -199,7 +201,7 @@ function stringifyLinkReferenceDefinitions( .map(({ version }) => { let diffUrl; if (version === chronologicalVersions[chronologicalVersions.length - 1]) { - diffUrl = getTagUrl(repoUrl, `v${version}`); + diffUrl = getTagUrl(repoUrl, `${tagPrefix}${version}`); } else { const versionIndex = chronologicalVersions.indexOf(version); const previousVersion = chronologicalVersions @@ -208,8 +210,12 @@ function stringifyLinkReferenceDefinitions( return semver.gt(version, releaseVersion); }); diffUrl = previousVersion - ? getCompareUrl(repoUrl, `v${previousVersion}`, `v${version}`) - : getTagUrl(repoUrl, `v${version}`); + ? getCompareUrl( + repoUrl, + `${tagPrefix}${previousVersion}`, + `${tagPrefix}${version}`, + ) + : getTagUrl(repoUrl, `${tagPrefix}${version}`); } return `[${version}]: ${diffUrl}`; }) @@ -249,16 +255,26 @@ export default class Changelog { private _repoUrl: string; + private _tagPrefix: string; + /** * Construct an empty changelog. * * @param options - Changelog options. * @param options.repoUrl - The GitHub repository URL for the current project. + * @param options.tagPrefix - The prefix used in tags before the version number. */ - constructor({ repoUrl }: { repoUrl: string }) { + constructor({ + repoUrl, + tagPrefix = 'v', + }: { + repoUrl: string; + tagPrefix?: string; + }) { this._releases = []; this._changes = { [unreleased]: {} }; this._repoUrl = repoUrl; + this._tagPrefix = tagPrefix; } /** @@ -436,6 +452,10 @@ ${changelogDescription} ${stringifyReleases(this._releases, this._changes)} -${stringifyLinkReferenceDefinitions(this._repoUrl, this._releases)}`; +${stringifyLinkReferenceDefinitions( + this._repoUrl, + this._tagPrefix, + this._releases, +)}`; } } diff --git a/src/cli.ts b/src/cli.ts index cd6a5ca..540dfc3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,6 +97,7 @@ type UpdateOptions = { repoUrl: string; isReleaseCandidate: boolean; projectRootDirectory?: string; + tagPrefix: string; }; /** @@ -108,6 +109,7 @@ type UpdateOptions = { * @param options.isReleaseCandidate - Whether the current branch is a release candidate or not. * @param options.repoUrl - The GitHub repository URL for the current project. * @param options.projectRootDirectory - The root project directory. + * @param options.tagPrefix - The prefix used in tags before the version number. */ async function update({ changelogPath, @@ -115,6 +117,7 @@ async function update({ isReleaseCandidate, repoUrl, projectRootDirectory, + tagPrefix, }: UpdateOptions) { const changelogContent = await readChangelog(changelogPath); @@ -124,6 +127,7 @@ async function update({ repoUrl, isReleaseCandidate, projectRootDirectory, + tagPrefixes: [tagPrefix], }); if (newChangelogContent) { @@ -139,6 +143,7 @@ type ValidateOptions = { currentVersion?: Version; isReleaseCandidate: boolean; repoUrl: string; + tagPrefix: string; }; /** @@ -149,12 +154,14 @@ type ValidateOptions = { * @param options.currentVersion - The current project version. * @param options.isReleaseCandidate - Whether the current branch is a release candidate or not. * @param options.repoUrl - The GitHub repository URL for the current project. + * @param options.tagPrefix - The prefix used in tags before the version number. */ async function validate({ changelogPath, currentVersion, isReleaseCandidate, repoUrl, + tagPrefix, }: ValidateOptions) { const changelogContent = await readChangelog(changelogPath); @@ -164,6 +171,7 @@ async function validate({ currentVersion, repoUrl, isReleaseCandidate, + tagPrefix, }); } catch (error) { if (error instanceof ChangelogFormattingError) { @@ -182,6 +190,7 @@ async function validate({ type InitOptions = { changelogPath: string; repoUrl: string; + tagPrefix: string; }; /** @@ -190,9 +199,10 @@ type InitOptions = { * @param options - Initialization options. * @param options.changelogPath - The path to the changelog file. * @param options.repoUrl - The GitHub repository URL for the current project. + * @param options.tagPrefix - The prefix used in tags before the version number. */ -async function init({ changelogPath, repoUrl }: InitOptions) { - const changelogContent = await createEmptyChangelog({ repoUrl }); +async function init({ changelogPath, repoUrl, tagPrefix }: InitOptions) { + const changelogContent = await createEmptyChangelog({ repoUrl, tagPrefix }); await saveChangelog(changelogPath, changelogContent); } @@ -222,6 +232,11 @@ function configureCommonCommandOptions(_yargs: Argv) { .option('root', { description: rootDescription, type: 'string', + }) + .option('tagPrefix', { + default: 'v', + description: 'The prefix used in tags before the version number.', + type: 'string', }); } @@ -282,6 +297,7 @@ async function main() { rc: isReleaseCandidate, repo: repoUrl, root: projectRootDirectory, + tagPrefix, } = argv; if (isReleaseCandidate && !currentVersion) { @@ -358,6 +374,7 @@ async function main() { isReleaseCandidate, repoUrl, projectRootDirectory, + tagPrefix, }); } else if (command === 'validate') { await validate({ @@ -365,11 +382,13 @@ async function main() { currentVersion, isReleaseCandidate, repoUrl, + tagPrefix, }); } else if (command === 'init') { await init({ changelogPath, repoUrl, + tagPrefix, }); } } diff --git a/src/init.test.ts b/src/init.test.ts index a77aca2..a73aa1d 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -19,4 +19,10 @@ describe('createEmptyChangelog', () => { emptyChangelog, ); }); + + it('creates an empty changelog with a custom tag prefix', () => { + expect( + createEmptyChangelog({ repoUrl: exampleRepoUrl, tagPrefix: 'foo' }), + ).toStrictEqual(emptyChangelog); + }); }); diff --git a/src/init.ts b/src/init.ts index 2fc2252..d9f3050 100644 --- a/src/init.ts +++ b/src/init.ts @@ -5,9 +5,16 @@ import Changelog from './changelog'; * * @param options - Changelog options. * @param options.repoUrl - The GitHub repository URL for the current project. + * @param options.tagPrefix - The prefix used in tags before the version number. * @returns The initial changelog text. */ -export function createEmptyChangelog({ repoUrl }: { repoUrl: string }) { - const changelog = new Changelog({ repoUrl }); +export function createEmptyChangelog({ + repoUrl, + tagPrefix = 'v', +}: { + repoUrl: string; + tagPrefix?: string; +}) { + const changelog = new Changelog({ repoUrl, tagPrefix }); return changelog.toString(); } diff --git a/src/parse-changelog.ts b/src/parse-changelog.ts index de15afa..e6a4223 100644 --- a/src/parse-changelog.ts +++ b/src/parse-changelog.ts @@ -28,17 +28,20 @@ function isValidChangeCategory(category: string): category is ChangeCategory { * @param options - Options. * @param options.changelogContent - The changelog to parse. * @param options.repoUrl - The GitHub repository URL for the current project. + * @param options.tagPrefix - The prefix used in tags before the version number. * @returns A changelog instance that reflects the changelog text provided. */ export function parseChangelog({ changelogContent, repoUrl, + tagPrefix = 'v', }: { changelogContent: string; repoUrl: string; + tagPrefix?: string; }) { const changelogLines = changelogContent.split('\n'); - const changelog = new Changelog({ repoUrl }); + const changelog = new Changelog({ repoUrl, tagPrefix }); const unreleasedHeaderIndex = changelogLines.indexOf(`## [${unreleased}]`); if (unreleasedHeaderIndex === -1) { diff --git a/src/update-changelog.ts b/src/update-changelog.ts index 4b3fc33..74685d2 100644 --- a/src/update-changelog.ts +++ b/src/update-changelog.ts @@ -5,23 +5,41 @@ import { ChangeCategory, Version } from './constants'; import type Changelog from './changelog'; /** - * Get the most recent tag. + * Get the most recent tag for a project. * + * @param options - Options. + * @param options.tagPrefixes - A list of tag prefixes to look for, where the first is the intended + * prefix and each subsequent prefix is a fallback in case the previous tag prefixes are not found. * @returns The most recent tag. */ -async function getMostRecentTag() { - const revListArgs = ['rev-list', '--tags', '--max-count=1', '--date-order']; - const results = await runCommand('git', revListArgs); - if (results.length === 0) { +async function getMostRecentTag({ + tagPrefixes, +}: { + tagPrefixes: [string, ...string[]]; +}) { + let mostRecentTagCommitHash: string | null = null; + for (const tagPrefix of tagPrefixes) { + const revListArgs = [ + 'rev-list', + `--tags=${tagPrefix}*`, + '--max-count=1', + '--date-order', + ]; + const results = await runCommand('git', revListArgs); + if (results.length) { + mostRecentTagCommitHash = results[0]; + break; + } + } + + if (mostRecentTagCommitHash === null) { return null; } - const [mostRecentTagCommitHash] = results; const [mostRecentTag] = await runCommand('git', [ 'describe', '--tags', mostRecentTagCommitHash, ]); - assert.equal(mostRecentTag?.[0], 'v', 'Most recent tag should start with v'); return mostRecentTag; } @@ -140,6 +158,7 @@ export type UpdateChangelogOptions = { repoUrl: string; isReleaseCandidate: boolean; projectRootDirectory?: string; + tagPrefixes?: [string, ...string[]]; }; /** @@ -159,6 +178,8 @@ export type UpdateChangelogOptions = { * filter results from various git commands. This path is assumed to be either * absolute, or relative to the current directory. Defaults to the root of the * current git repository. + * @param options.tagPrefixes - A list of tag prefixes to look for, where the first is the intended + * prefix and each subsequent prefix is a fallback in case the previous tag prefixes are not found. * @returns The updated changelog text. */ export async function updateChangelog({ @@ -167,19 +188,29 @@ export async function updateChangelog({ repoUrl, isReleaseCandidate, projectRootDirectory, + tagPrefixes = ['v'], }: UpdateChangelogOptions) { if (isReleaseCandidate && !currentVersion) { throw new Error( `A version must be specified if 'isReleaseCandidate' is set.`, ); } - const changelog = parseChangelog({ changelogContent, repoUrl }); + const changelog = parseChangelog({ + changelogContent, + repoUrl, + tagPrefix: tagPrefixes[0], + }); // Ensure we have all tags on remote await runCommand('git', ['fetch', '--tags']); - const mostRecentTag = await getMostRecentTag(); + const mostRecentTag = await getMostRecentTag({ + tagPrefixes, + }); - if (isReleaseCandidate && mostRecentTag === `v${currentVersion}`) { + if ( + isReleaseCandidate && + mostRecentTag === `${tagPrefixes[0]}${currentVersion}` + ) { throw new Error( `Current version already has tag, which is unexpected for a release candidate.`, ); diff --git a/src/validate-changelog.ts b/src/validate-changelog.ts index 7fb72fe..2d3e727 100644 --- a/src/validate-changelog.ts +++ b/src/validate-changelog.ts @@ -70,6 +70,7 @@ type ValidateChangelogOptions = { currentVersion?: Version; repoUrl: string; isReleaseCandidate: boolean; + tagPrefix?: string; }; /** @@ -85,6 +86,7 @@ type ValidateChangelogOptions = { * the midst of release preparation or not. If this is set, this command will * also ensure the current version is represented in the changelog with a * header, and that there are no unreleased changes present. + * @param options.tagPrefix - The prefix used in tags before the version number. * @throws `InvalidChangelogError` - Will throw if the changelog is invalid * @throws `MissingCurrentVersionError` - Will throw if `isReleaseCandidate` is * `true` and the changelog is missing the release header for the current @@ -100,8 +102,9 @@ export function validateChangelog({ currentVersion, repoUrl, isReleaseCandidate, + tagPrefix = 'v', }: ValidateChangelogOptions) { - const changelog = parseChangelog({ changelogContent, repoUrl }); + const changelog = parseChangelog({ changelogContent, repoUrl, tagPrefix }); const hasUnreleasedChanges = Object.keys(changelog.getUnreleasedChanges()).length !== 0; const releaseChanges = currentVersion