Skip to content

Commit

Permalink
Add validate command (#28)
Browse files Browse the repository at this point in the history
* 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

* Add additional validation error types

All invalid changelog cases are now described by the
`InvalidChangelogError` error type, which has sub-classes to describe
each possible scenario. This allows distinguishing invalid changelog
errors from all other unexpected errors.

The CLI has been updated to take advantage of this; we now print a
simpler error message if the changelog is invalid, rather than a stack
trace.

* Improve `generateDiff`

The `generateDiff` function now has a comprehensive test suite. It has
been updated to ensure that the resulting diff displays a notice about
whether or not the file has a trailing newline.

I started this journey by trying to ensure we were trimming each line
properly. I found that `diff.diffLines` always returned change sets
that ended in a newline, except when returning the very last change to
a file that didn't terminate in a newline. So in order to ensure we
were correctly showing all changes, I needed to add special handling
for end-of-file changes. We could display this differently than I've
chosen to here, but I figured showing what `git` shows was a good
start.

The `generateDiff` function now also ensures the correct context is
shown in between each change.

The dependency `outdent` was added to help with writing inline test
fixtures. This saves us from having to come up with a name for each
fixture and cross-reference them in each test.

When adding that dependency, an ESLint error brought to my attention
that we were accidentally including test files in our `files` property
in `package.json`. It was updated to exclude test files, which resolved
the ESLint error.
  • Loading branch information
Gudahtt authored Apr 30, 2021
1 parent 1fee870 commit b1fbbbe
Show file tree
Hide file tree
Showing 9 changed files with 1,146 additions and 32 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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"node": ">=12.0.0"
},
"files": [
"src/"
"src/*.js",
"!src/*.test.js"
],
"repository": {
"type": "git",
Expand All @@ -30,6 +31,7 @@
},
"dependencies": {
"cross-spawn": "^7.0.3",
"diff": "^5.0.0",
"semver": "^7.3.5",
"yargs": "^16.2.0"
},
Expand All @@ -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"
}
}
147 changes: 116 additions & 31 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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) => {
Expand Down
91 changes: 91 additions & 0 deletions src/generateDiff.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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 };
Loading

0 comments on commit b1fbbbe

Please sign in to comment.