Skip to content

Commit

Permalink
Add validate command
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Gudahtt committed Apr 20, 2021
1 parent def1d77 commit 62821ed
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 17 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"cross-spawn": "^7.0.3",
"diff": "^5.0.0",
"semver": "^7.3.5",
"yargs": "^16.2.0"
},
Expand Down
117 changes: 100 additions & 17 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
/* eslint-disable node/no-process-exit */

const fs = require('fs').promises;
const diff = require('diff');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const { updateChangelog } = require('./updateChangelog');
const {
validateChangelog,
ChangelogFormattingError,
} = require('./validateChangelog');
const { unreleased } = require('./constants');

const updateEpilog = `New commits will be added to the "${unreleased}" section (or \
Expand All @@ -16,11 +21,89 @@ 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;

const changelogFilename = 'CHANGELOG.md';

async function readChangelog() {
return await fs.readFile(changelogFilename, {
encoding: 'utf8',
});
}

async function saveChangelog(newChangelogContent) {
await fs.writeFile(changelogFilename, newChangelogContent);
}

async function update({ isReleaseCandidate }) {
const changelogContent = await readChangelog();

const newChangelogContent = await updateChangelog({
changelogContent,
currentVersion: npmPackageVersion,
repoUrl: npmPackageRepositoryUrl,
isReleaseCandidate,
});

await saveChangelog(newChangelogContent);
console.log('CHANGELOG updated');
}

async function validate({ isReleaseCandidate }) {
const changelogContent = await readChangelog();

try {
validateChangelog({
changelogContent,
currentVersion: npmPackageVersion,
repoUrl: npmPackageRepositoryUrl,
isReleaseCandidate,
});
} catch (error) {
if (error instanceof ChangelogFormattingError) {
const { validChangelog, invalidChangelog } = error.data;
const changes = diff.diffLines(validChangelog, invalidChangelog);
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));
}
}
}

console.error(
`Changelog not well-formatted.\nDiff:\n${diffLines.join('\n')}`,
);
process.exit(1);
}
throw error;
}
}

async function main() {
const { argv } = yargs(hideBin(process.argv))
.command(
Expand All @@ -35,6 +118,18 @@ async function main() {
})
.epilog(updateEpilog),
)
.command(
'validate',
'Validate the changelog, ensuring that it is well-formatted.\nUsage: $0 validate [options]',
(_yargs) =>
_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')
Expand All @@ -52,23 +147,11 @@ async function main() {
);
}

const isReleaseCandidate = argv.rc;

const changelogFilename = 'CHANGELOG.md';
const changelogContent = await fs.readFile(changelogFilename, {
encoding: 'utf8',
});

const newChangelogContent = await updateChangelog({
changelogContent,
currentVersion: npmPackageVersion,
repoUrl: npmPackageRepositoryUrl,
isReleaseCandidate,
});

await fs.writeFile(changelogFilename, newChangelogContent);

console.log('CHANGELOG updated');
if (argv._ && argv._[0] === 'update') {
update({ isReleaseCandidate: argv.rc });
} else if (argv._ && argv._[0] === 'validate') {
validate({ isReleaseCandidate: argv.rc });
}
}

main().catch((error) => {
Expand Down
6 changes: 6 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
const { updateChangelog } = require('./updateChangelog');
const {
ChangelogFormattingError,
validateChangelog,
} = require('./validateChangelog');

module.exports = {
ChangelogFormattingError,
updateChangelog,
validateChangelog,
};
74 changes: 74 additions & 0 deletions src/validateChangelog.js
Original file line number Diff line number Diff line change
@@ -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 };
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 62821ed

Please sign in to comment.