diff --git a/src/changelog.ts b/src/changelog.ts index 2f61d62..4b0e9eb 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -7,6 +7,7 @@ import { unreleased, Version, } from './constants'; +import { PackageRename } from './shared-types'; const changelogTitle = '# Changelog'; const changelogDescription = `All notable changes to this project will be documented in this file. @@ -165,24 +166,50 @@ function getTagUrl(repoUrl: string, tag: string) { * @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. + * @param packageRename - The package rename properties + * An optional, which is required only in case of package renamed. * @returns The stringified release link definitions. */ function stringifyLinkReferenceDefinitions( repoUrl: string, tagPrefix: string, releases: ReleaseMetadata[], + packageRename?: PackageRename, ) { - // A list of release versions in descending SemVer order - const descendingSemverVersions = releases - .map(({ version }) => version) - .sort((a: Version, b: Version) => { - return semver.gt(a, b) ? -1 : 1; - }); - const latestSemverVersion = descendingSemverVersions[0]; - // A list of release versions in chronological order - const chronologicalVersions = releases.map(({ version }) => version); - const hasReleases = chronologicalVersions.length > 0; + const unreleasedLinkReferenceDefinition = + getUnreleasedLinkReferenceDefinition( + repoUrl, + tagPrefix, + releases, + packageRename, + ); + + const releaseLinkReferenceDefinitions = getReleaseLinkReferenceDefinitions( + repoUrl, + tagPrefix, + releases, + packageRename, + ).join('\n'); + return `${unreleasedLinkReferenceDefinition}\n${releaseLinkReferenceDefinitions}${ + releases.length > 0 ? '\n' : '' + }`; +} +/** + * Get a string of unreleased link reference definition. + * + * @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. + * @param packageRename - The package rename properties. + * @returns A unreleased link reference definition string. + */ +function getUnreleasedLinkReferenceDefinition( + repoUrl: string, + tagPrefix: string, + releases: ReleaseMetadata[], + packageRename?: PackageRename, +): string { // 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. @@ -193,42 +220,102 @@ function stringifyLinkReferenceDefinitions( // // If there have not been any releases yet, the repo URL is used directly as // the link definition. - const unreleasedLinkReferenceDefinition = `[${unreleased}]: ${ + + // A list of release versions in descending SemVer order + const descendingSemverVersions = releases + .map(({ version }) => version) + .sort((a: Version, b: Version) => { + return semver.gt(a, b) ? -1 : 1; + }); + const latestSemverVersion = descendingSemverVersions[0]; + const hasReleases = descendingSemverVersions.length > 0; + // if there is a package renamed, the tag prefix before the rename will be considered for compare + // [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/test@0.0.2...HEAD + const tagPrefixToCompare = + packageRename && packageRename.versionBeforeRename === latestSemverVersion + ? packageRename.tagPrefixBeforeRename + : tagPrefix; + + return `[${unreleased}]: ${ hasReleases - ? getCompareUrl(repoUrl, `${tagPrefix}${latestSemverVersion}`, 'HEAD') + ? getCompareUrl( + repoUrl, + `${tagPrefixToCompare}${latestSemverVersion}`, + 'HEAD', + ) : withTrailingSlash(repoUrl) }`; +} +/** + * Get a list of release link reference definitions. + * + * @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. + * @param packageRename - The package rename properties. + * @returns A list of release link reference definitions. + */ +function getReleaseLinkReferenceDefinitions( + repoUrl: string, + tagPrefix: string, + releases: ReleaseMetadata[], + packageRename?: PackageRename, +): string[] { // 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 }) => { - let diffUrl; - if (version === chronologicalVersions[chronologicalVersions.length - 1]) { - diffUrl = getTagUrl(repoUrl, `${tagPrefix}${version}`); + const chronologicalVersions = releases.map(({ version }) => version); + let tagPrefixToCompare = tagPrefix; + const releaseLinkReferenceDefinitions = releases.map(({ version }) => { + let diffUrl; + // once the version matches with versionBeforeRename, rest of the lines in changelog will be assumed as migrated tags + if (packageRename && packageRename.versionBeforeRename === version) { + tagPrefixToCompare = packageRename.tagPrefixBeforeRename; + } + + if (version === chronologicalVersions[chronologicalVersions.length - 1]) { + diffUrl = getTagUrl(repoUrl, `${tagPrefixToCompare}${version}`); + } else { + const versionIndex = chronologicalVersions.indexOf(version); + const previousVersion = chronologicalVersions + .slice(versionIndex) + .find((releaseVersion: Version) => { + return semver.gt(version, releaseVersion); + }); + + if (previousVersion) { + if ( + packageRename && + packageRename.versionBeforeRename === previousVersion + ) { + // The package was renamed at this version + // (the tag prefix holds the new name). + diffUrl = getCompareUrl( + repoUrl, + `${packageRename.tagPrefixBeforeRename}${previousVersion}`, + `${tagPrefix}${version}`, + ); + } else { + // If the package was ever renamed, it was not renamed at this version, + // so use either the old tag prefix or the new tag prefix. + // If the package was never renamed, use the tag prefix as it is. + diffUrl = getCompareUrl( + repoUrl, + `${tagPrefixToCompare}${previousVersion}`, + `${tagPrefixToCompare}${version}`, + ); + } } else { - const versionIndex = chronologicalVersions.indexOf(version); - const previousVersion = chronologicalVersions - .slice(versionIndex) - .find((releaseVersion: Version) => { - return semver.gt(version, releaseVersion); - }); - diffUrl = previousVersion - ? getCompareUrl( - repoUrl, - `${tagPrefix}${previousVersion}`, - `${tagPrefix}${version}`, - ) - : getTagUrl(repoUrl, `${tagPrefix}${version}`); + // This is the smallest release. + diffUrl = getTagUrl(repoUrl, `${tagPrefixToCompare}${version}`); } - return `[${version}]: ${diffUrl}`; - }) - .join('\n'); - return `${unreleasedLinkReferenceDefinition}\n${releaseLinkReferenceDefinitions}${ - releases.length > 0 ? '\n' : '' - }`; + } + return `[${version}]: ${diffUrl}`; + }); + + return releaseLinkReferenceDefinitions; } type AddReleaseOptions = { @@ -265,6 +352,8 @@ export default class Changelog { #formatter: Formatter; + readonly #packageRename: PackageRename | undefined; + /** * Construct an empty changelog. * @@ -272,21 +361,26 @@ export default class Changelog { * @param options.repoUrl - The GitHub repository URL for the current project. * @param options.tagPrefix - The prefix used in tags before the version number. * @param options.formatter - A function that formats the changelog string. + * @param options.packageRename - The package rename properties. + * An optional, which is required only in case of package renamed. */ constructor({ repoUrl, tagPrefix = 'v', formatter = (changelog) => changelog, + packageRename, }: { repoUrl: string; tagPrefix?: string; formatter?: Formatter; + packageRename?: PackageRename; }) { this.#releases = []; this.#changes = { [unreleased]: {} }; this.#repoUrl = repoUrl; this.#tagPrefix = tagPrefix; this.#formatter = formatter; + this.#packageRename = packageRename; } /** @@ -468,6 +562,7 @@ ${stringifyLinkReferenceDefinitions( this.#repoUrl, this.#tagPrefix, this.#releases, + this.#packageRename, )}`; return this.#formatter(changelog); diff --git a/src/cli.ts b/src/cli.ts index 440c69f..da5cbb0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { unreleased, Version } from './constants'; import { generateDiff } from './generate-diff'; import { createEmptyChangelog } from './init'; import { getRepositoryUrl } from './repo'; +import { PackageRename } from './shared-types'; import { updateChangelog } from './update-changelog'; import { ChangelogFormattingError, @@ -142,6 +143,10 @@ type ValidateOptions = { tagPrefix: string; fix: boolean; formatter: Formatter; + /** + * The package rename properties, used in case of package is renamed + */ + packageRename?: PackageRename; }; /** @@ -155,6 +160,8 @@ type ValidateOptions = { * @param options.tagPrefix - The prefix used in tags before the version number. * @param options.fix - Whether to attempt to fix the changelog or not. * @param options.formatter - A custom Markdown formatter to use. + * @param options.packageRename - The package rename properties. + * An optional, which is required only in case of package renamed. */ async function validate({ changelogPath, @@ -164,6 +171,7 @@ async function validate({ tagPrefix, fix, formatter, + packageRename, }: ValidateOptions) { const changelogContent = await readChangelog(changelogPath); @@ -175,6 +183,7 @@ async function validate({ isReleaseCandidate, tagPrefix, formatter, + packageRename, }); return undefined; } catch (error) { @@ -257,6 +266,14 @@ function configureCommonCommandOptions(_yargs: Argv) { default: 'v', description: 'The prefix used in tags before the version number.', type: 'string', + }) + .option('versionBeforePackageRename', { + description: 'A version of the package before being renamed.', + type: 'string', + }) + .option('tagPrefixBeforePackageRename', { + description: 'A tag prefix of the package before being renamed.', + type: 'string', }); } @@ -332,6 +349,8 @@ async function main() { tagPrefix, fix, prettier: usePrettier, + versionBeforePackageRename, + tagPrefixBeforePackageRename, } = argv; let { currentVersion } = argv; @@ -408,6 +427,15 @@ async function main() { return exitWithError(`Invalid repo URL: '${repoUrl}'`); } + if ( + (versionBeforePackageRename && !tagPrefixBeforePackageRename) || + (!versionBeforePackageRename && tagPrefixBeforePackageRename) + ) { + return exitWithError( + '--version-before-package-rename and --tag-prefix-before-package-rename must be given together or not at all.', + ); + } + let changelogPath = changelogFilename; if (!path.isAbsolute(changelogFilename) && projectRootDirectory) { changelogPath = path.resolve(projectRootDirectory, changelogFilename); @@ -447,6 +475,13 @@ async function main() { formatter, }); } else if (command === 'validate') { + let packageRename: PackageRename | undefined; + if (versionBeforePackageRename && tagPrefixBeforePackageRename) { + packageRename = { + versionBeforeRename: versionBeforePackageRename, + tagPrefixBeforeRename: tagPrefixBeforePackageRename, + }; + } await validate({ changelogPath, currentVersion, @@ -455,6 +490,7 @@ async function main() { tagPrefix, fix, formatter, + packageRename, }); } else if (command === 'init') { await init({ diff --git a/src/parse-changelog.test.ts b/src/parse-changelog.test.ts index 8f2bd81..c16f2d2 100644 --- a/src/parse-changelog.test.ts +++ b/src/parse-changelog.test.ts @@ -756,4 +756,71 @@ describe('parseChangelog', () => { `Unrecognized line: '* Very very very very very very very very very very very very very very very ver...'`, ); }); + + it('should parse changelog with releases which has renamed package', () => { + const changelog = parseChangelog({ + changelogContent: outdent` + # Changelog + 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). + + ## [Unreleased] + + ## [1.0.0] - 2020-01-01 + ### Changed + - package renamed + + ## [0.0.2] - 2020-01-01 + ### Fixed + - Something + + ## [0.0.1] - 2020-01-01 + ### Changed + - Something + + [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/@metamask/test@1.0.0...HEAD + [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/test@0.0.2...@metamask/test@1.0.0 + [0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/test@0.0.1...test@0.0.2 + [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/test@0.0.1 + `, + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + }); + + expect(changelog.getReleases()).toStrictEqual([ + { date: '2020-01-01', status: undefined, version: '1.0.0' }, + { date: '2020-01-01', status: undefined, version: '0.0.2' }, + { date: '2020-01-01', status: undefined, version: '0.0.1' }, + ]); + + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: ['package renamed'], + }); + + expect(changelog.getReleaseChanges('0.0.2')).toStrictEqual({ + Fixed: ['Something'], + }); + + expect(changelog.getReleaseChanges('0.0.1')).toStrictEqual({ + Changed: ['Something'], + }); + + expect(changelog.getRelease('1.0.0')).toStrictEqual({ + date: '2020-01-01', + status: undefined, + version: '1.0.0', + }); + + expect(changelog.getStringifiedRelease('1.0.0')).toStrictEqual(outdent` + ## [1.0.0] - 2020-01-01 + ### Changed + - package renamed`); + expect(changelog.getRelease('2.0.0')).toBeUndefined(); + expect(() => changelog.getStringifiedRelease('2.0.0')).toThrow( + "Specified release version does not exist: '2.0.0'", + ); + expect(changelog.getUnreleasedChanges()).toStrictEqual({}); + }); }); diff --git a/src/parse-changelog.ts b/src/parse-changelog.ts index 63dd689..ed4a022 100644 --- a/src/parse-changelog.ts +++ b/src/parse-changelog.ts @@ -2,6 +2,7 @@ import semver from 'semver'; import Changelog, { Formatter } from './changelog'; import { ChangeCategory, unreleased } from './constants'; +import { PackageRename } from './shared-types'; /** * Truncate the given string at 80 characters. @@ -32,6 +33,8 @@ function isValidChangeCategory(category: string): category is ChangeCategory { * @param options.repoUrl - The GitHub repository URL for the current project. * @param options.tagPrefix - The prefix used in tags before the version number. * @param options.formatter - A custom Markdown formatter to use. + * @param options.packageRename - The package rename properties + * An optional, which is required only in case of package renamed. * @returns A changelog instance that reflects the changelog text provided. */ export function parseChangelog({ @@ -39,14 +42,21 @@ export function parseChangelog({ repoUrl, tagPrefix = 'v', formatter = undefined, + packageRename, }: { changelogContent: string; repoUrl: string; tagPrefix?: string; formatter?: Formatter; + packageRename?: PackageRename; }) { const changelogLines = changelogContent.split('\n'); - const changelog = new Changelog({ repoUrl, tagPrefix, formatter }); + const changelog = new Changelog({ + repoUrl, + tagPrefix, + formatter, + packageRename, + }); const unreleasedHeaderIndex = changelogLines.indexOf(`## [${unreleased}]`); if (unreleasedHeaderIndex === -1) { diff --git a/src/shared-types.ts b/src/shared-types.ts new file mode 100644 index 0000000..96d48e4 --- /dev/null +++ b/src/shared-types.ts @@ -0,0 +1,13 @@ +/** + * Package rename properties + */ +export type PackageRename = { + /** + * The version of the package before being renamed. + */ + versionBeforeRename: string; + /** + * The tag prefix of the package before being renamed. + */ + tagPrefixBeforeRename: string; +}; diff --git a/src/validate-changelog.test.ts b/src/validate-changelog.test.ts index 396a753..def393c 100644 --- a/src/validate-changelog.test.ts +++ b/src/validate-changelog.test.ts @@ -132,6 +132,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 `; +const changelogWithRenamedPackage = `# Changelog +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). + +## [Unreleased] + +## [1.0.0] - 2020-01-01 +### Changed +- package renamed + +## [0.0.2] - 2020-01-01 +### Fixed +- Something + +## [0.0.1] - 2020-01-01 +### Changed +- Something + +[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/@metamask/test@1.0.0...HEAD +[1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/test@0.0.2...@metamask/test@1.0.0 +[0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/test@0.0.1...test@0.0.2 +[0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/test@0.0.1 +`; + describe('validateChangelog', () => { it('should not throw for any empty valid changelog', () => { expect(() => @@ -686,4 +712,22 @@ describe('validateChangelog', () => { ).not.toThrow(); }); }); + + // when the package has been renamed from `test` to `@metamast/test` + it('should not throw for a valid changelog with renamed package', () => { + expect(() => + validateChangelog({ + changelogContent: changelogWithRenamedPackage, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + tagPrefix: '@metamask/test@', + packageRename: { + versionBeforeRename: '0.0.2', + tagPrefixBeforeRename: 'test@', + }, + }), + ).not.toThrow(); + }); }); diff --git a/src/validate-changelog.ts b/src/validate-changelog.ts index 046fc91..05771c2 100644 --- a/src/validate-changelog.ts +++ b/src/validate-changelog.ts @@ -1,6 +1,7 @@ import { Formatter } from './changelog'; import { Version, ChangeCategory } from './constants'; import { parseChangelog } from './parse-changelog'; +import { PackageRename } from './shared-types'; /** * Indicates that the changelog is invalid. @@ -73,6 +74,10 @@ type ValidateChangelogOptions = { isReleaseCandidate: boolean; tagPrefix?: string; formatter?: Formatter; + /** + * The package rename properties, used in case of package is renamed + */ + packageRename?: PackageRename; }; /** @@ -90,6 +95,8 @@ type ValidateChangelogOptions = { * header, and that there are no unreleased changes present. * @param options.tagPrefix - The prefix used in tags before the version number. * @param options.formatter - A custom Markdown formatter to use. + * @param options.packageRename - The package rename properties. + * An optional, which is required only in case of package renamed. * @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 @@ -107,12 +114,14 @@ export function validateChangelog({ isReleaseCandidate, tagPrefix = 'v', formatter = undefined, + packageRename, }: ValidateChangelogOptions) { const changelog = parseChangelog({ changelogContent, repoUrl, tagPrefix, formatter, + packageRename, }); const hasUnreleasedChanges = Object.keys(changelog.getUnreleasedChanges()).length !== 0;