2020-03-10 13:29:44 -04:00
|
|
|
/**
|
|
|
|
* @license
|
2020-05-19 15:08:49 -04:00
|
|
|
* Copyright Google LLC All Rights Reserved.
|
2020-03-10 13:29:44 -04:00
|
|
|
*
|
|
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
|
|
* found in the LICENSE file at https://angular.io/license
|
|
|
|
*/
|
2021-03-19 15:11:21 -04:00
|
|
|
|
2020-05-20 17:22:29 -04:00
|
|
|
import {error} from '../utils/console';
|
|
|
|
|
2020-08-12 12:36:59 -04:00
|
|
|
import {COMMIT_TYPES, getCommitMessageConfig, ScopeRequirement} from './config';
|
2021-03-19 15:11:21 -04:00
|
|
|
import {Commit, parseCommitMessage} from './parse';
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-03-20 15:24:12 -04:00
|
|
|
/** Options for commit message validation. */
|
|
|
|
export interface ValidateCommitMessageOptions {
|
|
|
|
disallowSquash?: boolean;
|
|
|
|
nonFixupCommitHeaders?: string[];
|
|
|
|
}
|
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
/** The result of a commit message validation check. */
|
|
|
|
export interface ValidateCommitMessageResult {
|
|
|
|
valid: boolean;
|
|
|
|
errors: string[];
|
2021-03-19 15:11:21 -04:00
|
|
|
commit: Commit;
|
2020-09-03 17:54:31 -04:00
|
|
|
}
|
|
|
|
|
2020-08-12 12:40:37 -04:00
|
|
|
/** Regex matching a URL for an entire commit body line. */
|
2020-07-07 09:58:11 -04:00
|
|
|
const COMMIT_BODY_URL_LINE_RE = /^https?:\/\/.*$/;
|
2021-03-19 05:48:35 -04:00
|
|
|
/**
|
|
|
|
* Regex matching a breaking change.
|
|
|
|
*
|
|
|
|
* - Starts with BREAKING CHANGE
|
|
|
|
* - Followed by a colon
|
|
|
|
* - Followed by a single space or two consecutive new lines
|
|
|
|
*
|
|
|
|
* NB: Anything after `BREAKING CHANGE` is optional to facilitate the validation.
|
|
|
|
*/
|
|
|
|
const COMMIT_BODY_BREAKING_CHANGE_RE = /^BREAKING CHANGE(:( |\n{2}))?/m;
|
2020-03-10 13:29:44 -04:00
|
|
|
|
|
|
|
/** Validate a commit message against using the local repo's config. */
|
|
|
|
export function validateCommitMessage(
|
2021-03-19 15:11:21 -04:00
|
|
|
commitMsg: string|Commit,
|
2020-11-17 14:20:41 -05:00
|
|
|
options: ValidateCommitMessageOptions = {}): ValidateCommitMessageResult {
|
2020-05-08 17:51:29 -04:00
|
|
|
const config = getCommitMessageConfig().commitMessage;
|
2020-11-17 14:20:41 -05:00
|
|
|
const commit = typeof commitMsg === 'string' ? parseCommitMessage(commitMsg) : commitMsg;
|
2020-09-03 17:54:31 -04:00
|
|
|
const errors: string[] = [];
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
/** Perform the validation checks against the parsed commit. */
|
|
|
|
function validateCommitAndCollectErrors() {
|
|
|
|
////////////////////////////////////
|
|
|
|
// Checking revert, squash, fixup //
|
|
|
|
////////////////////////////////////
|
|
|
|
|
|
|
|
// All revert commits are considered valid.
|
|
|
|
if (commit.isRevert) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// All squashes are considered valid, as the commit will be squashed into another in
|
|
|
|
// the git history anyway, unless the options provided to not allow squash commits.
|
|
|
|
if (commit.isSquash) {
|
|
|
|
if (options.disallowSquash) {
|
|
|
|
errors.push('The commit must be manually squashed into the target commit');
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
// Fixups commits are considered valid, unless nonFixupCommitHeaders are provided to check
|
|
|
|
// against. If `nonFixupCommitHeaders` is not empty, we check whether there is a corresponding
|
|
|
|
// non-fixup commit (i.e. a commit whose header is identical to this commit's header after
|
|
|
|
// stripping the `fixup! ` prefix), otherwise we assume this verification will happen in another
|
|
|
|
// check.
|
|
|
|
if (commit.isFixup) {
|
|
|
|
if (options.nonFixupCommitHeaders && !options.nonFixupCommitHeaders.includes(commit.header)) {
|
|
|
|
errors.push(
|
|
|
|
'Unable to find match for fixup commit among prior commits: ' +
|
|
|
|
(options.nonFixupCommitHeaders.map(x => `\n ${x}`).join('') || '-'));
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
////////////////////////////
|
|
|
|
// Checking commit header //
|
|
|
|
////////////////////////////
|
|
|
|
if (commit.header.length > config.maxLineLength) {
|
|
|
|
errors.push(`The commit message header is longer than ${config.maxLineLength} characters`);
|
2020-04-06 16:18:59 -04:00
|
|
|
return false;
|
|
|
|
}
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
if (!commit.type) {
|
|
|
|
errors.push(`The commit message header does not match the expected format.`);
|
2020-03-10 13:29:44 -04:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
if (COMMIT_TYPES[commit.type] === undefined) {
|
|
|
|
errors.push(`'${commit.type}' is not an allowed type.\n => TYPES: ${
|
|
|
|
Object.keys(COMMIT_TYPES).join(', ')}`);
|
|
|
|
return false;
|
|
|
|
}
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
/** The scope requirement level for the provided type of the commit message. */
|
|
|
|
const scopeRequirementForType = COMMIT_TYPES[commit.type].scope;
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
if (scopeRequirementForType === ScopeRequirement.Forbidden && commit.scope) {
|
|
|
|
errors.push(`Scopes are forbidden for commits with type '${commit.type}', but a scope of '${
|
|
|
|
commit.scope}' was provided.`);
|
|
|
|
return false;
|
|
|
|
}
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
if (scopeRequirementForType === ScopeRequirement.Required && !commit.scope) {
|
|
|
|
errors.push(
|
|
|
|
`Scopes are required for commits with type '${commit.type}', but no scope was provided.`);
|
|
|
|
return false;
|
|
|
|
}
|
2020-08-12 12:36:59 -04:00
|
|
|
|
2021-04-02 09:59:52 -04:00
|
|
|
const fullScope = commit.npmScope ? `${commit.npmScope}/${commit.scope}` : commit.scope;
|
|
|
|
if (fullScope && !config.scopes.includes(fullScope)) {
|
2020-09-03 17:54:31 -04:00
|
|
|
errors.push(
|
2021-04-02 09:59:52 -04:00
|
|
|
`'${fullScope}' is not an allowed scope.\n => SCOPES: ${config.scopes.join(', ')}`);
|
2020-09-03 17:54:31 -04:00
|
|
|
return false;
|
|
|
|
}
|
2020-08-12 12:36:59 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
// Commits with the type of `release` do not require a commit body.
|
|
|
|
if (commit.type === 'release') {
|
|
|
|
return true;
|
|
|
|
}
|
2020-08-12 12:36:59 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
//////////////////////////
|
|
|
|
// Checking commit body //
|
|
|
|
//////////////////////////
|
2020-08-12 12:36:59 -04:00
|
|
|
|
2021-03-29 14:10:43 -04:00
|
|
|
// Due to an issue in which conventional-commits-parser considers all parts of a commit after
|
|
|
|
// a `#` reference to be the footer, we check the length of all of the commit content after the
|
|
|
|
// header. In the future, we expect to be able to check only the body once the parser properly
|
|
|
|
// handles this case.
|
|
|
|
const allNonHeaderContent = `${commit.body.trim()}\n${commit.footer.trim()}`;
|
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
if (!config.minBodyLengthTypeExcludes?.includes(commit.type) &&
|
2021-03-29 14:10:43 -04:00
|
|
|
allNonHeaderContent.length < config.minBodyLength) {
|
2020-09-03 17:54:31 -04:00
|
|
|
errors.push(`The commit message body does not meet the minimum length of ${
|
|
|
|
config.minBodyLength} characters`);
|
|
|
|
return false;
|
|
|
|
}
|
2020-08-12 12:36:59 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
const bodyByLine = commit.body.split('\n');
|
2021-03-19 15:11:21 -04:00
|
|
|
const lineExceedsMaxLength = bodyByLine.some((line: string) => {
|
2020-09-03 17:54:31 -04:00
|
|
|
// Check if any line exceeds the max line length limit. The limit is ignored for
|
|
|
|
// lines that just contain an URL (as these usually cannot be wrapped or shortened).
|
|
|
|
return line.length > config.maxLineLength && !COMMIT_BODY_URL_LINE_RE.test(line);
|
|
|
|
});
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
if (lineExceedsMaxLength) {
|
2021-03-19 05:48:35 -04:00
|
|
|
errors.push(`The commit message body contains lines greater than ${
|
|
|
|
config.maxLineLength} characters.`);
|
2020-09-03 17:54:31 -04:00
|
|
|
return false;
|
|
|
|
}
|
2020-03-10 13:29:44 -04:00
|
|
|
|
2021-03-19 05:48:35 -04:00
|
|
|
// Breaking change
|
|
|
|
// Check if the commit message contains a valid break change description.
|
|
|
|
// https://github.com/angular/angular/blob/88fbc066775ab1a2f6a8c75f933375b46d8fa9a4/CONTRIBUTING.md#commit-message-footer
|
2021-03-19 15:11:21 -04:00
|
|
|
const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.fullText);
|
2021-03-19 05:48:35 -04:00
|
|
|
if (hasBreakingChange !== null) {
|
|
|
|
const [, breakingChangeDescription] = hasBreakingChange;
|
|
|
|
if (!breakingChangeDescription) {
|
|
|
|
// Not followed by :, space or two consecutive new lines,
|
|
|
|
errors.push(`The commit message body contains an invalid breaking change description.`);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-14 12:34:30 -04:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
return {valid: validateCommitAndCollectErrors(), errors, commit};
|
|
|
|
}
|
2020-03-10 13:29:44 -04:00
|
|
|
|
|
|
|
|
2020-09-03 17:54:31 -04:00
|
|
|
/** Print the error messages from the commit message validation to the console. */
|
|
|
|
export function printValidationErrors(errors: string[], print = error) {
|
|
|
|
print.group(`Error${errors.length === 1 ? '' : 's'}:`);
|
|
|
|
errors.forEach(line => print(line));
|
|
|
|
print.groupEnd();
|
|
|
|
print();
|
|
|
|
print('The expected format for a commit is: ');
|
|
|
|
print('<type>(<scope>): <summary>');
|
|
|
|
print();
|
|
|
|
print('<body>');
|
|
|
|
print();
|
2021-03-19 05:48:35 -04:00
|
|
|
print(`BREAKING CHANGE: <breaking change summary>`);
|
|
|
|
print();
|
|
|
|
print(`<breaking change description>`);
|
|
|
|
print();
|
|
|
|
print();
|
2020-03-10 13:29:44 -04:00
|
|
|
}
|