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.

Tests have been added to comprehensively test the changelog validation.

Closes #11
  • Loading branch information
Gudahtt committed Apr 21, 2021
1 parent 07147af commit cc77665
Show file tree
Hide file tree
Showing 8 changed files with 688 additions and 20 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ or

## CLI Usage

### Update

To update the 'Unreleased' section of the changelog:

`npx @metamask/auto-changelog update`
Expand All @@ -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.
Expand All @@ -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.
Expand Down
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
100 changes: 80 additions & 20 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 \
Expand All @@ -16,30 +21,94 @@ 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, isReleaseCandidate }) {
const changelogContent = await readChangelog(changelogFilename);

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

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

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

try {
validateChangelog({
changelogContent,
currentVersion: npmPackageVersion,
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',
});
}

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',
})
.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')
Expand Down Expand Up @@ -72,20 +141,11 @@ async function main() {
process.exit(1);
}

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') {
await update({ changelogFilename, isReleaseCandidate });
} else if (argv._ && argv._[0] === 'validate') {
await validate({ changelogFilename, isReleaseCandidate });
}
}

main().catch((error) => {
Expand Down
41 changes: 41 additions & 0 deletions src/generateDiff.js
Original file line number Diff line number Diff line change
@@ -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 };
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 };
Loading

0 comments on commit cc77665

Please sign in to comment.