From 57f1a08601ae3e75d9d31ad0e38d5aee69334039 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 20 Apr 2021 12:28:20 -0230 Subject: [PATCH] Add `validate` command The validate command will validate the formatting of the changelog. If the `--rc` flag is used, it will also ensure that the current version is included as a release header, and that there are no unreleased changes. The CLI command displays a rudimentary diff if formatting problems are detected. This can be improved later with colours, and with highlighting within each line to show what has changed. It also doesn't yet highlight whitespace changes. The validation logic is also exposed via the API as a function called `validateChangelog`. It doesn't include the fancy diff output, but it does throw an error with all of the required information for someone to construct the same output. Tests have been added to comprehensively test the changelog validation. Closes #11 --- README.md | 36 +++ package.json | 1 + src/cli.js | 121 +++++++-- src/generateDiff.js | 41 ++++ src/index.js | 6 + src/validateChangelog.js | 74 ++++++ src/validateChangelog.test.js | 445 ++++++++++++++++++++++++++++++++++ yarn.lock | 5 + 8 files changed, 703 insertions(+), 26 deletions(-) create mode 100644 src/generateDiff.js create mode 100644 src/validateChangelog.js create mode 100644 src/validateChangelog.test.js diff --git a/README.md b/README.md index 740edf3..f4a7f6a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ or ## CLI Usage +### Update + To update the 'Unreleased' section of the changelog: `npx @metamask/auto-changelog update` @@ -22,6 +24,16 @@ To update the current release section of the changelog: `npx @metamask/auto-changelog update --rc` +### Validate + +To validate the changelog: + +`npx @metamask/auto-changelog validate` + +To validate the changelog in a release candidate environment: + +`npx @metamask/auto-changelog validate --rc` + ## API Usage Each supported command is a separate named export. @@ -46,6 +58,30 @@ const updatedChangelog = updateChangelog({ await fs.writeFile('CHANEGLOG.md', updatedChangelog); ``` +### `validateChangelog` + +This command validates the changelog + +```javascript +const fs = require('fs').promises; +const { validateChangelog } = require('@metamask/auto-changelog'); + +const oldChangelog = await fs.readFile('CHANEGLOG.md', { + encoding: 'utf8', +}); +try { + validateChangelog({ + changelogContent: oldChangelog, + currentVersion: '1.0.0', + repoUrl: 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }); + // changelog is valid! +} catch (error) { + // changelog is invalid +} +``` + ## Testing Run `yarn test` to run the tests once. diff --git a/package.json b/package.json index 63bbdbb..1a68f96 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "cross-spawn": "^7.0.3", + "diff": "^5.0.0", "semver": "^7.3.5", "yargs": "^16.2.0" }, diff --git a/src/cli.js b/src/cli.js index 77f6547..19bd7fd 100644 --- a/src/cli.js +++ b/src/cli.js @@ -7,6 +7,11 @@ const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); const { updateChangelog } = require('./updateChangelog'); +const { generateDiff } = require('./generateDiff'); +const { + validateChangelog, + ChangelogFormattingError, +} = require('./validateChangelog'); const { unreleased } = require('./constants'); const updateEpilog = `New commits will be added to the "${unreleased}" section (or \ @@ -17,36 +22,109 @@ 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.`; +const validateEpilog = `This does not ensure that the changelog is complete, \ +or that each change is in the correct section. It just ensures that the \ +formatting is correct. Verification of the contents is left for manual review.`; + // eslint-disable-next-line node/no-process-env const npmPackageVersion = process.env.npm_package_version; // eslint-disable-next-line node/no-process-env const npmPackageRepositoryUrl = process.env.npm_package_repository_url; +async function readChangelog(changelogFilename) { + return await fs.readFile(changelogFilename, { + encoding: 'utf8', + }); +} + +async function saveChangelog(changelogFilename, newChangelogContent) { + await fs.writeFile(changelogFilename, newChangelogContent); +} + +async function update({ + changelogFilename, + currentVersion, + isReleaseCandidate, +}) { + const changelogContent = await readChangelog(changelogFilename); + + const newChangelogContent = await updateChangelog({ + changelogContent, + currentVersion, + repoUrl: npmPackageRepositoryUrl, + isReleaseCandidate, + }); + + await saveChangelog(changelogFilename, newChangelogContent); + console.log('CHANGELOG updated'); +} + +async function validate({ + changelogFilename, + currentVersion, + isReleaseCandidate, +}) { + const changelogContent = await readChangelog(changelogFilename); + + try { + validateChangelog({ + changelogContent, + currentVersion, + repoUrl: npmPackageRepositoryUrl, + isReleaseCandidate, + }); + } catch (error) { + if (error instanceof ChangelogFormattingError) { + const { validChangelog, invalidChangelog } = error.data; + const diff = generateDiff(validChangelog, invalidChangelog); + console.error(`Changelog not well-formatted.\nDiff:\n${diff}`); + process.exit(1); + } + throw error; + } +} + +function configureCommonCommandOptions(_yargs) { + return _yargs + .option('file', { + default: 'CHANGELOG.md', + description: 'The changelog file path', + type: 'string', + }) + .option('currentVersion', { + default: npmPackageVersion, + description: + 'The current version of the project that the changelog belongs to.', + type: 'string', + }); +} + async function main() { const { argv } = yargs(hideBin(process.argv)) .command( 'update', 'Update CHANGELOG.md with any changes made since the most recent release.\nUsage: $0 update [options]', (_yargs) => - _yargs + configureCommonCommandOptions(_yargs) .option('rc', { default: false, description: `Add new changes to the current release header, rather than to the '${unreleased}' section.`, type: 'boolean', }) - .option('file', { - default: 'CHANGELOG.md', - description: 'The changelog file path', - type: 'string', - }) - .option('currentVersion', { - default: npmPackageVersion, - description: - 'The current version of the project that the changelog belongs to.', - type: 'string', - }) .epilog(updateEpilog), ) + .command( + 'validate', + 'Validate the changelog, ensuring that it is well-formatted.\nUsage: $0 validate [options]', + (_yargs) => + configureCommonCommandOptions(_yargs) + .option('rc', { + default: false, + description: `Verify that the current version has a release header in the changelog`, + type: 'boolean', + }) + .epilog(validateEpilog), + ) .strict() .demandCommand() .help('help') @@ -86,20 +164,11 @@ async function main() { process.exit(1); } - const changelogContent = await fs.readFile(changelogFilename, { - encoding: 'utf8', - }); - - const newChangelogContent = await updateChangelog({ - changelogContent, - currentVersion, - repoUrl: npmPackageRepositoryUrl, - isReleaseCandidate, - }); - - await fs.writeFile(changelogFilename, newChangelogContent); - - console.log('CHANGELOG updated'); + if (argv._ && argv._[0] === 'update') { + await update({ changelogFilename, currentVersion, isReleaseCandidate }); + } else if (argv._ && argv._[0] === 'validate') { + await validate({ changelogFilename, currentVersion, isReleaseCandidate }); + } } main().catch((error) => { diff --git a/src/generateDiff.js b/src/generateDiff.js new file mode 100644 index 0000000..6820c63 --- /dev/null +++ b/src/generateDiff.js @@ -0,0 +1,41 @@ +const diff = require('diff'); + +/** + * Generates a diff between two multi-line strings. The resulting diff shows + * any changes using '-' and '+' to indicate the "old" and "new" version + * respectively, and includes 2 lines of unchanged content around each changed + * section where possible. + * @param {string} before - The string representing the base for the comparison. + * @param {string} after - The string representing the changes being compared. + * @returns {string} The genereated text diff + */ +function generateDiff(before, after) { + const changes = diff.diffLines(before, after); + const diffLines = []; + const preceedingContext = []; + for (const { added, removed, value } of changes) { + const lines = value.split('\n'); + // remove trailing newline + lines.pop(); + if (added || removed) { + if (preceedingContext.length) { + diffLines.push(...preceedingContext); + preceedingContext.splice(0, preceedingContext.length); + } + diffLines.push(...lines.map((line) => `${added ? '+' : '-'}${line}`)); + } else { + // If a changed line has been included already, add up to 2 lines of context + if (diffLines.length) { + diffLines.push(...lines.slice(0, 2).map((line) => ` ${line}`)); + lines.splice(0, 2); + } + // stash last 2 lines for context in case there is another change + if (lines.length) { + preceedingContext.push(...lines.slice(-2)); + } + } + } + return diffLines.join('\n'); +} + +module.exports = { generateDiff }; diff --git a/src/index.js b/src/index.js index 3958448..1024e96 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,11 @@ const { updateChangelog } = require('./updateChangelog'); +const { + ChangelogFormattingError, + validateChangelog, +} = require('./validateChangelog'); module.exports = { + ChangelogFormattingError, updateChangelog, + validateChangelog, }; diff --git a/src/validateChangelog.js b/src/validateChangelog.js new file mode 100644 index 0000000..f372d06 --- /dev/null +++ b/src/validateChangelog.js @@ -0,0 +1,74 @@ +const { parseChangelog } = require('./parseChangelog'); + +/** + * @typedef {import('./constants.js').Version} Version + */ + +/** + * Represents a formatting error in a changelog. + */ +class ChangelogFormattingError extends Error { + /** + * @param {Object} options + * @param {string} options.validChangelog - The string contents of the well- + * formatted changelog. + * @param {string} options.invalidChangelog - The string contents of the + * malformed changelog. + */ + constructor({ validChangelog, invalidChangelog }) { + super('Changelog is not well-formatted'); + this.data = { + validChangelog, + invalidChangelog, + }; + } +} + +/** + * Validates that a changelog is well-formatted. + * @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, this + * command will also ensure the current version is represented in the + * changelog with a release header, and that there are no unreleased changes + * present. + */ +function validateChangelog({ + changelogContent, + currentVersion, + repoUrl, + isReleaseCandidate, +}) { + const changelog = parseChangelog({ changelogContent, repoUrl }); + + // Ensure release header exists, if necessary + if ( + isReleaseCandidate && + !changelog + .getReleases() + .find((release) => release.version === currentVersion) + ) { + throw new Error( + `Current version missing from changelog: '${currentVersion}'`, + ); + } + + const hasUnreleasedChanges = changelog.getUnreleasedChanges().length !== 0; + if (isReleaseCandidate && hasUnreleasedChanges) { + throw new Error('Unreleased changes present in the changelog'); + } + + const validChangelog = changelog.toString(); + if (validChangelog !== changelogContent) { + throw new ChangelogFormattingError({ + validChangelog, + invalidChangelog: changelogContent, + }); + } +} + +module.exports = { validateChangelog, ChangelogFormattingError }; diff --git a/src/validateChangelog.test.js b/src/validateChangelog.test.js new file mode 100644 index 0000000..1f0efa3 --- /dev/null +++ b/src/validateChangelog.test.js @@ -0,0 +1,445 @@ +const { validateChangelog } = require('./validateChangelog'); + +const emptyChangelog = `# 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] + +[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/ +`; + +const changelogWithReleases = `# 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 +- Something else + +## [0.0.2] - 2020-01-01 +### Fixed +- Something + +## [0.0.1] - 2020-01-01 +### Changed +- Something + +[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 +[0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2 +[0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 +`; + +const branchingChangelog = `# 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] + +## [0.0.3] - 2020-01-01 +### Fixed +- Security fix + +## [1.0.0] - 2020-01-01 +### Changed +- Something else + +## [0.0.2] - 2020-01-01 +### Fixed +- Something + +## [0.0.1] - 2020-01-01 +### Changed +- Something + +[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD +[0.0.3]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v0.0.3 +[1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 +[0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2 +[0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 +`; + +describe('validateChangelog', () => { + it('should throw for an empty string', () => { + expect(() => + validateChangelog({ + changelogContent: '', + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Failed to find Unreleased header'); + }); + + it('should not throw for any empty valid changelog', () => { + expect(() => + validateChangelog({ + changelogContent: emptyChangelog, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).not.toThrow(); + }); + + it('should not throw for a valid changelog with multiple releases', () => { + expect(() => + validateChangelog({ + changelogContent: changelogWithReleases, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).not.toThrow(); + }); + + it('should throw when the title is different', () => { + const changelogWithDifferentTitle = changelogWithReleases.replace( + '# Changelog', + '# Custom Title', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithDifferentTitle, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw when the changelog description is different', () => { + const changelogWithDifferentDescription = changelogWithReleases.replace( + 'All notable changes', + 'A random assortment of changes', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithDifferentDescription, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw when there are whitespace changes', () => { + const changelogWithExtraWhitespace = `${changelogWithReleases}\n`; + expect(() => + validateChangelog({ + changelogContent: changelogWithExtraWhitespace, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw when a release header is malformed', () => { + const changelogWithMalformedReleaseHeader = changelogWithReleases.replace( + '[1.0.0] - 2020-01-01', + '1.0.0 - 2020-01-01', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithMalformedReleaseHeader, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow(`Unrecognized line: '## 1.0.0 - 2020-01-01'`); + }); + + it('should throw when there are extraneous header contents', () => { + const changelogWithExtraHeaderContents = changelogWithReleases.replace( + '[1.0.0] - 2020-01-01', + '[1.0.0] - 2020-01-01 [extra contents]', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithExtraHeaderContents, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw when a change category is unrecognized', () => { + const changelogWithUnrecognizedChangeCategory = changelogWithReleases.replace( + '### Changed', + '### Updated', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithUnrecognizedChangeCategory, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow(`Unrecognized category: 'Updated'`); + }); + + it('should throw when the Unreleased section is missing', () => { + const changelogWithoutUnreleased = changelogWithReleases.replace( + /## \[Unreleased\]\n\n/u, + '', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithoutUnreleased, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Failed to find Unreleased header'); + }); + + it('should throw if the wrong repo URL is used', () => { + expect(() => + validateChangelog({ + changelogContent: changelogWithReleases, + currentVersion: '1.0.0', + repoUrl: 'https://github.com/DifferentOrganization/DifferentRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw if a comparison release link is missing', () => { + const changelogWithoutReleaseLink = changelogWithReleases.replace( + '[1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0\n', + '', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithoutReleaseLink, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw if the first release link is missing', () => { + const changelogWithoutFirstReleaseLink = changelogWithReleases.replace( + '[0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1\n', + '', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithoutFirstReleaseLink, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw if release links are in a different order than the release headers', () => { + const thirdReleaseLink = + '[1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0'; + const secondReleaseLink = + '[0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2'; + const changelogWithoutFirstReleaseLink = changelogWithReleases.replace( + `${thirdReleaseLink}\n${secondReleaseLink}`, + `${secondReleaseLink}\n${thirdReleaseLink}`, + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithoutFirstReleaseLink, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should not throw for changelog with branching releases', () => { + expect(() => + validateChangelog({ + changelogContent: branchingChangelog, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).not.toThrow(); + }); + + it(`should throw if the highest version isn't compared with the Unreleased changes`, () => { + const changelogWithInvalidUnreleasedComparison = branchingChangelog.replace( + '[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD', + '[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.3...HEAD', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithInvalidUnreleasedComparison, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw if there are decreasing comparisons', () => { + const changelogWithDecreasingComparison = branchingChangelog.replace( + '[0.0.3]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v0.0.3', + '[0.0.3]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...v0.0.3', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithDecreasingComparison, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw if the unreleased link points at anything other than the bare repository when there are no releases', () => { + const changelogWithIncorrectUnreleasedLink = emptyChangelog.replace( + '[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/', + '[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithIncorrectUnreleasedLink, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw if the bare unreleased link is missing a trailing slash', () => { + const changelogWithoutUnreleasedLinkTrailingSlash = emptyChangelog.replace( + '[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/', + '[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithoutUnreleasedLinkTrailingSlash, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow('Changelog is not well-formatted'); + }); + + it('should throw if a change category is missing', () => { + const changelogWithoutChangeCategory = changelogWithReleases.replace( + '### Changed\n', + '', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithoutChangeCategory, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow("Category missing for change: '- Something else'"); + }); + + it("should throw if a change isn't prefixed by '- '", () => { + const changelogWithInvalidChangePrefix = changelogWithReleases.replace( + '- Something', + 'Something', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithInvalidChangePrefix, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).toThrow(`Unrecognized line: 'Something else'`); + }); + + it('should not throw if the current version release header is missing', () => { + expect(() => + validateChangelog({ + changelogContent: changelogWithReleases, + currentVersion: '1.0.1', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).not.toThrow(); + }); + + it('should not throw if there are unreleased changes', () => { + const changelogWithUnreleasedChanges = changelogWithReleases.replace( + '## [Unreleased]', + '## [Unreleased]\n### Changed\n- More changes', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithUnreleasedChanges, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: false, + }), + ).not.toThrow(); + }); + + describe('isReleaseCandidate', () => { + it('should throw if the current version release header is missing', () => { + expect(() => + validateChangelog({ + changelogContent: changelogWithReleases, + currentVersion: '1.0.1', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: true, + }), + ).toThrow(`Current version missing from changelog: '1.0.1'`); + }); + + it('should throw if there are unreleased changes', () => { + const changelogWithUnreleasedChanges = changelogWithReleases.replace( + '## [Unreleased]', + '## [Unreleased]\n### Changed\n- More changes', + ); + expect(() => + validateChangelog({ + changelogContent: changelogWithUnreleasedChanges, + currentVersion: '1.0.0', + repoUrl: + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: true, + }), + ).toThrow('Unreleased changes present in the changelog'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 78f4ea1..630a44e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1384,6 +1384,11 @@ diff-sequences@^26.6.2: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== +diff@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"