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..9559582 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "node": ">=12.0.0" }, "files": [ - "src/" + "src/*.js", + "!src/*.test.js" ], "repository": { "type": "git", @@ -30,6 +31,7 @@ }, "dependencies": { "cross-spawn": "^7.0.3", + "diff": "^5.0.0", "semver": "^7.3.5", "yargs": "^16.2.0" }, @@ -44,6 +46,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^3.3.1", "jest": "^26.4.2", + "outdent": "^0.8.0", "prettier": "^2.2.1" } } diff --git a/src/cli.js b/src/cli.js index 557a686..2b67202 100644 --- a/src/cli.js +++ b/src/cli.js @@ -7,6 +7,12 @@ const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); const { updateChangelog } = require('./updateChangelog'); +const { generateDiff } = require('./generateDiff'); +const { + ChangelogFormattingError, + InvalidChangelogError, + validateChangelog, +} = require('./validateChangelog'); const { unreleased } = require('./constants'); const updateEpilog = `New commits will be added to the "${unreleased}" section (or \ @@ -17,6 +23,10 @@ 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 @@ -32,36 +42,110 @@ function isValidUrl(proposedUrl) { } } +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, + repoUrl, +}) { + const changelogContent = await readChangelog(changelogFilename); + + const newChangelogContent = await updateChangelog({ + changelogContent, + currentVersion, + repoUrl, + isReleaseCandidate, + }); + + await saveChangelog(changelogFilename, newChangelogContent); + console.log('CHANGELOG updated'); +} + +async function validate({ + changelogFilename, + currentVersion, + isReleaseCandidate, + repoUrl, +}) { + const changelogContent = await readChangelog(changelogFilename); + + try { + validateChangelog({ + changelogContent, + currentVersion, + repoUrl, + isReleaseCandidate, + }); + } catch (error) { + if (error instanceof ChangelogFormattingError) { + const { validChangelog, invalidChangelog } = error.data; + const diff = generateDiff(validChangelog, invalidChangelog); + console.error(`Changelog not well-formatted. Diff:\n\n${diff}`); + process.exit(1); + } else if (error instanceof InvalidChangelogError) { + console.error(`Changelog is invalid: ${error.message}`); + 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', + }) + .option('repo', { + default: npmPackageRepositoryUrl, + description: `The GitHub repository URL`, + 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', - }) - .option('repo', { - default: npmPackageRepositoryUrl, - description: `The GitHub repository URL`, - 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') @@ -106,20 +190,21 @@ async function main() { process.exit(1); } - const changelogContent = await fs.readFile(changelogFilename, { - encoding: 'utf8', - }); - - const newChangelogContent = await updateChangelog({ - changelogContent, - currentVersion, - repoUrl, - isReleaseCandidate, - }); - - await fs.writeFile(changelogFilename, newChangelogContent); - - console.log('CHANGELOG updated'); + if (argv._ && argv._[0] === 'update') { + await update({ + changelogFilename, + currentVersion, + isReleaseCandidate, + repoUrl, + }); + } else if (argv._ && argv._[0] === 'validate') { + await validate({ + changelogFilename, + currentVersion, + isReleaseCandidate, + repoUrl, + }); + } } main().catch((error) => { diff --git a/src/generateDiff.js b/src/generateDiff.js new file mode 100644 index 0000000..3f635e9 --- /dev/null +++ b/src/generateDiff.js @@ -0,0 +1,91 @@ +const diff = require('diff'); + +/** + * Splits string into lines, excluding the newline at the end of each + * line. The trailing newline is optional. + * @param {string} value - The string value to split into lines + * @returns {Array} The lines, without trailing newlines + */ +function getTrimmedLines(value) { + const trimmedValue = value.endsWith('\n') + ? value.substring(0, value.length - 1) + : value; + return trimmedValue.split('\n'); +} + +/** + * Generates a diff between two multi-line string files. 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 diffResult = diff.diffLines(before, after); + const penultimateDiffResult = diffResult[diffResult.length - 2] || {}; + // `diffLines` will always return at least one change object + const lastDiffResult = diffResult[diffResult.length - 1]; + + // Add notice about newline at end of file + if (!lastDiffResult.value.endsWith('\n')) { + lastDiffResult.noNewline = true; + } + // If the last change is an addition and the penultimate change is a + // removal, then the last line of the file is also in the penultimate change. + // That's why we're checking to see if the newline notice is needed here as + // well. + if ( + lastDiffResult.added && + penultimateDiffResult.removed && + !penultimateDiffResult.value.endsWith('\n') + ) { + penultimateDiffResult.noNewline = true; + } + + const diffLines = diffResult.flatMap( + ({ added, noNewline, removed, value }, index) => { + const lines = getTrimmedLines(value); + const changedLines = []; + if (added || removed) { + // Add up to 2 lines of context before each change + const previousContext = diffResult[index - 1]; + if ( + previousContext && + !previousContext.added && + !previousContext.removed + ) { + // The diff result prior to an unchanged result is guaranteed to be + // either an addition or a removal + const previousChange = diffResult[index - 2]; + const hasPreviousChange = previousChange !== undefined; + const previousContextLines = getTrimmedLines(previousContext.value); + // Avoid repeating context that has already been included in diff + if (!hasPreviousChange || previousContextLines.length >= 3) { + const linesOfContext = + hasPreviousChange && previousContextLines.length === 3 ? 1 : 2; + const previousTwoLines = previousContextLines + .slice(-1 * linesOfContext) + .map((line) => ` ${line}`); + changedLines.push(...previousTwoLines); + } + } + changedLines.push( + ...lines.map((line) => `${added ? '+' : '-'}${line}`), + ); + } else if (index > 0) { + // Add up to 2 lines of context following a change + const nextTwoLines = lines.slice(0, 2).map((line) => ` ${line}`); + changedLines.push(...nextTwoLines); + } + if (noNewline) { + changedLines.push('\\ No newline at end of file'); + } + return changedLines; + }, + ); + return diffLines.join('\n'); +} + +module.exports = { generateDiff }; diff --git a/src/generateDiff.test.js b/src/generateDiff.test.js new file mode 100644 index 0000000..9590dd1 --- /dev/null +++ b/src/generateDiff.test.js @@ -0,0 +1,325 @@ +const _outdent = require('outdent'); + +const outdent = _outdent({ trimTrailingNewline: false }); +const { generateDiff } = require('./generateDiff'); + +const testCases = [ + { + description: 'should return an empty string when comparing empty files', + before: '\n', + after: '\n', + expected: '', + }, + { + description: 'should return an empty string when comparing identical files', + before: 'abc\n', + after: 'abc\n', + expected: '', + }, + { + description: 'should display one-line diff', + before: 'abc\n', + after: '123\n', + expected: '-abc\n+123', + }, + { + description: + 'should display one-line diff of file without trailing newlines', + before: 'abc', + after: '123', + expected: outdent` + -abc + \\ No newline at end of file + +123 + \\ No newline at end of file`, + }, + { + description: 'should display multi-line diff', + before: outdent` + a + b + c + `, + after: outdent` + 1 + 2 + 3 + `, + expected: outdent` + -a + -b + -c + +1 + +2 + +3`, + }, + { + description: 'should display multi-line diff without trailing newline', + before: outdent` + a + b + c`, + after: outdent` + 1 + 2 + 3`, + expected: outdent` + -a + -b + -c + \\ No newline at end of file + +1 + +2 + +3 + \\ No newline at end of file`, + }, + { + description: 'should display multi-line diff with added trailing newline', + before: outdent` + a + b + c`, + after: outdent` + 1 + 2 + 3 + `, + expected: outdent` + -a + -b + -c + \\ No newline at end of file + +1 + +2 + +3`, + }, + { + description: 'should display multi-line diff with removed trailing newline', + before: outdent` + a + b + c + `, + after: outdent` + 1 + 2 + 3`, + expected: outdent` + -a + -b + -c + +1 + +2 + +3 + \\ No newline at end of file`, + }, + { + description: 'should display multi-line diff with removed middle newline', + before: outdent` + a + b + + c + `, + after: outdent` + 1 + 2 + 3 + `, + expected: outdent` + -a + -b + - + -c + +1 + +2 + +3`, + }, + { + description: 'should display multi-line diff with added middle newline', + before: outdent` + a + b + c + `, + after: outdent` + 1 + 2 + + 3 + `, + expected: outdent` + -a + -b + -c + +1 + +2 + + + +3`, + }, + { + description: 'should display diff of added newline in middle of file', + before: outdent` + a + b + c + `, + after: outdent` + a + b + + c + `, + expected: outdent` + ${outdent} + a + b + + + c`, + }, + { + description: 'should display diff of added newline at end of file', + before: outdent` + a + b + c`, + after: outdent` + a + b + c + `, + expected: outdent` + ${outdent} + a + b + -c + \\ No newline at end of file + +c`, + }, + { + description: 'should display diff of removed newline at end of file', + before: outdent` + a + b + c + `, + after: outdent` + a + b + c`, + expected: outdent` + ${outdent} + a + b + -c + +c + \\ No newline at end of file`, + }, + { + description: 'should display one line of context before and after change', + before: outdent` + a + b + c + `, + after: outdent` + a + c + `, + expected: outdent` + ${outdent} + a + -b + c`, + }, + { + description: 'should display two lines of context before and after change', + before: outdent` + a + b + c + d + e + f + g + `, + after: outdent` + a + b + c + e + f + g + `, + expected: outdent` + ${outdent} + b + c + -d + e + f`, + }, + { + description: 'should not repeat context of changes one line apart', + before: outdent` + a + b + c + `, + after: outdent` + b + `, + expected: outdent` + -a + b + -c`, + }, + { + description: 'should not repeat context of changes two lines apart', + before: outdent` + a + b + c + d + `, + after: outdent` + b + c + `, + expected: outdent` + -a + b + c + -d`, + }, + { + description: 'should not repeat context of changes three lines apart', + before: outdent` + a + b + c + d + e + `, + after: outdent` + b + c + d + `, + expected: outdent` + -a + b + c + d + -e`, + }, +]; + +describe('generateDiff', () => { + for (const { description, before, after, expected } of testCases) { + it(`${description}`, () => { + const diff = generateDiff(before, after); + expect(diff).toStrictEqual(expected); + }); + } +}); 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..37c4eeb --- /dev/null +++ b/src/validateChangelog.js @@ -0,0 +1,111 @@ +const { parseChangelog } = require('./parseChangelog'); + +/** + * @typedef {import('./constants.js').Version} Version + */ + +/** + * Indicates that the changelog is invalid. + */ +class InvalidChangelogError extends Error {} + +/** + * Indicates that unreleased changes are still present in the changelog. + */ +class UnreleasedChangesError extends InvalidChangelogError { + constructor() { + super('Unreleased changes present in the changelog'); + } +} + +/** + * Indicates that the release header for the current version is missing. + */ +class MissingCurrentVersionError extends InvalidChangelogError { + /** + * @param {Version} currentVersion - The current version + */ + constructor(currentVersion) { + super(`Current version missing from changelog: '${currentVersion}'`); + } +} + +/** + * Represents a formatting error in a changelog. + */ +class ChangelogFormattingError extends InvalidChangelogError { + /** + * @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. + * @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 + * version. + * @throws {UnreleasedChangesError} Will throw if `isReleaseCandidate` is + * `true` and the changelog contains unreleased changes. + * @throws {ChangelogFormattingError} Will throw if there is a formatting error. + */ +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 MissingCurrentVersionError(currentVersion); + } + + const hasUnreleasedChanges = changelog.getUnreleasedChanges().length !== 0; + if (isReleaseCandidate && hasUnreleasedChanges) { + throw new UnreleasedChangesError(); + } + + const validChangelog = changelog.toString(); + if (validChangelog !== changelogContent) { + throw new ChangelogFormattingError({ + validChangelog, + invalidChangelog: changelogContent, + }); + } +} + +module.exports = { + ChangelogFormattingError, + InvalidChangelogError, + MissingCurrentVersionError, + UnreleasedChangesError, + validateChangelog, +}; diff --git a/src/validateChangelog.test.js b/src/validateChangelog.test.js new file mode 100644 index 0000000..9f827c0 --- /dev/null +++ b/src/validateChangelog.test.js @@ -0,0 +1,447 @@ +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 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 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 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 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 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'`); + }); + + describe('is not a release candidate', () => { + 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('is a release candidate', () => { + 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..808d95d 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" @@ -3374,6 +3379,11 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +outdent@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/outdent/-/outdent-0.8.0.tgz#2ebc3e77bf49912543f1008100ff8e7f44428eb0" + integrity sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A== + p-each-series@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48"