Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add validate command #28

Merged
merged 6 commits into from
Apr 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.`;
rekmarks marked this conversation as resolved.
Show resolved Hide resolved

// 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`,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left rc out of the "commonCommandOptions" group because it was helpful to have a separate description for this flag for each command, since it implies something a bit different in each case.

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) {
rekmarks marked this conversation as resolved.
Show resolved Hide resolved
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