feat(dev-infra): create commit-message validation script/tooling (#36117)

PR Close #36117
This commit is contained in:
Joey Perrott
2020-03-10 10:29:44 -07:00
committed by Misko Hevery
parent 2afc7e982e
commit 14b2db1d43
9 changed files with 503 additions and 6 deletions

View File

@ -0,0 +1,39 @@
load("//tools:defaults.bzl", "jasmine_node_test")
load("@npm_bazel_typescript//:index.bzl", "ts_library")
ts_library(
name = "commit-message",
srcs = [
"config.ts",
"validate.ts",
],
module_name = "@angular/dev-infra-private/commit-message",
visibility = ["//dev-infra:__subpackages__"],
deps = [
"//dev-infra/utils:config",
"@npm//@types/node",
"@npm//tslib",
],
)
ts_library(
name = "validate-test",
testonly = True,
srcs = ["validate.spec.ts"],
deps = [
":commit-message",
"//dev-infra/utils:config",
"@npm//@types/events",
"@npm//@types/jasmine",
"@npm//@types/node",
],
)
jasmine_node_test(
name = "test",
bootstrap = ["//tools/testing:node_no_angular_es5"],
deps = [
":commit-message",
":validate-test",
],
)

View File

@ -0,0 +1,13 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* 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
*/
export interface CommitMessageConfig {
maxLineLength: number;
minBodyLength: number;
types: string[];
scopes: string[];
}

View File

@ -0,0 +1,248 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* 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
*/
// Imports
import * as utilConfig from '../utils/config';
import {validateCommitMessage} from './validate';
// Constants
const config = {
'commitMessage': {
'maxLineLength': 120,
'minBodyLength': 0,
'types': [
'feat',
'fix',
'refactor',
'release',
'style',
],
'scopes': [
'common',
'compiler',
'core',
'packaging',
]
}
};
const TYPES = config.commitMessage.types.join(', ');
const SCOPES = config.commitMessage.scopes.join(', ');
const INVALID = false;
const VALID = true;
// TODO(josephperrott): Clean up tests to test script rather than for
// specific commit messages we want to use.
describe('validate-commit-message.js', () => {
let lastError: string = '';
beforeEach(() => {
lastError = '';
spyOn(console, 'error').and.callFake((msg: string) => lastError = msg);
spyOn(utilConfig, 'getAngularDevConfig').and.returnValue(config);
});
describe('validateMessage()', () => {
it('should be valid', () => {
expect(validateCommitMessage('feat(packaging): something')).toBe(VALID);
expect(lastError).toBe('');
expect(validateCommitMessage('release(packaging): something')).toBe(VALID);
expect(lastError).toBe('');
expect(validateCommitMessage('fixup! release(packaging): something')).toBe(VALID);
expect(lastError).toBe('');
expect(validateCommitMessage('squash! release(packaging): something')).toBe(VALID);
expect(lastError).toBe('');
expect(validateCommitMessage('Revert: "release(packaging): something"')).toBe(VALID);
expect(lastError).toBe('');
});
it('should validate max length', () => {
const msg =
'fix(compiler): something super mega extra giga tera long, maybe even longer and longer and longer and longer and longer and longer...';
expect(validateCommitMessage(msg)).toBe(INVALID);
expect(lastError).toContain(
`The commit message header is longer than ${config.commitMessage.maxLineLength} characters`);
});
it('should validate "<type>(<scope>): <subject>" format', () => {
const msg = 'not correct format';
expect(validateCommitMessage(msg)).toBe(INVALID);
expect(lastError).toContain(`The commit message header does not match the expected format.`);
});
it('should fail when type is invalid', () => {
const msg = 'weird(core): something';
expect(validateCommitMessage(msg)).toBe(INVALID);
expect(lastError).toContain(`'weird' is not an allowed type.\n => TYPES: ${TYPES}`);
});
it('should fail when scope is invalid', () => {
const errorMessageFor = (scope: string, header: string) =>
`'${scope}' is not an allowed scope.\n => SCOPES: ${SCOPES}`;
expect(validateCommitMessage('fix(Compiler): something')).toBe(INVALID);
expect(lastError).toContain(errorMessageFor('Compiler', 'fix(Compiler): something'));
expect(validateCommitMessage('feat(bah): something')).toBe(INVALID);
expect(lastError).toContain(errorMessageFor('bah', 'feat(bah): something'));
expect(validateCommitMessage('style(webworker): something')).toBe(INVALID);
expect(lastError).toContain(errorMessageFor('webworker', 'style(webworker): something'));
expect(validateCommitMessage('refactor(security): something')).toBe(INVALID);
expect(lastError).toContain(errorMessageFor('security', 'refactor(security): something'));
expect(validateCommitMessage('refactor(docs): something')).toBe(INVALID);
expect(lastError).toContain(errorMessageFor('docs', 'refactor(docs): something'));
expect(validateCommitMessage('release(angular): something')).toBe(INVALID);
expect(lastError).toContain(errorMessageFor('angular', 'release(angular): something'));
});
it('should allow empty scope', () => {
expect(validateCommitMessage('fix: blablabla')).toBe(VALID);
expect(lastError).toBe('');
});
// We do not want to allow WIP. It is OK to fail the PR build in this case to show that there is
// work still to be done (i.e. fixing the commit message).
it('should not allow "WIP: ..." syntax', () => {
const msg = 'WIP: fix: something';
expect(validateCommitMessage(msg)).toBe(INVALID);
expect(lastError).toContain(`'WIP' is not an allowed type.\n => TYPES: ${TYPES}`);
});
describe('(revert)', () => {
it('should allow valid "revert: ..." syntaxes', () => {
expect(validateCommitMessage('revert: anything')).toBe(VALID);
expect(lastError).toBe('');
expect(validateCommitMessage('Revert: "anything"')).toBe(VALID);
expect(lastError).toBe('');
expect(validateCommitMessage('revert anything')).toBe(VALID);
expect(lastError).toBe('');
expect(validateCommitMessage('rEvErT anything')).toBe(VALID);
expect(lastError).toBe('');
});
it('should not allow "revert(scope): ..." syntax', () => {
const msg = 'revert(compiler): reduce generated code payload size by 65%';
expect(validateCommitMessage(msg)).toBe(INVALID);
expect(lastError).toContain(`'revert' is not an allowed type.\n => TYPES: ${TYPES}`);
});
// https://github.com/angular/angular/issues/23479
it('should allow typical Angular messages generated by git', () => {
const msg =
'Revert "fix(compiler): Pretty print object instead of [Object object] (#22689)" (#23442)';
expect(validateCommitMessage(msg)).toBe(VALID);
expect(lastError).toBe('');
});
});
describe('(squash)', () => {
it('should strip the `squash! ` prefix and validate the rest', () => {
const errorMessage = `The commit message header does not match the expected format.`;
// Valid messages.
expect(validateCommitMessage('squash! feat(core): add feature')).toBe(VALID);
expect(validateCommitMessage('squash! fix: a bug', false)).toBe(VALID);
// Invalid messages.
expect(validateCommitMessage('squash! fix a typo', false)).toBe(INVALID);
expect(lastError).toContain('squash! fix a typo');
expect(lastError).toContain(errorMessage);
expect(validateCommitMessage('squash! squash! fix: a bug')).toBe(INVALID);
expect(lastError).toContain('squash! squash! fix: a bug');
expect(lastError).toContain(errorMessage);
});
describe('with `disallowSquash`', () => {
it('should fail', () => {
expect(validateCommitMessage('fix(core): something', true)).toBe(VALID);
expect(validateCommitMessage('squash! fix(core): something', true)).toBe(INVALID);
expect(lastError).toContain(
'The commit must be manually squashed into the target commit');
});
});
});
describe('(fixup)', () => {
describe('without `nonFixupCommitHeaders`', () => {
it('should strip the `fixup! ` prefix and validate the rest', () => {
const errorMessage = `The commit message header does not match the expected format.`;
// Valid messages.
expect(validateCommitMessage('fixup! feat(core): add feature')).toBe(VALID);
expect(validateCommitMessage('fixup! fix: a bug')).toBe(VALID);
// Invalid messages.
expect(validateCommitMessage('fixup! fix a typo')).toBe(INVALID);
expect(lastError).toContain('fixup! fix a typo');
expect(lastError).toContain(errorMessage);
expect(validateCommitMessage('fixup! fixup! fix: a bug')).toBe(INVALID);
expect(lastError).toContain('fixup! fixup! fix: a bug');
expect(lastError).toContain(errorMessage);
});
});
describe('with `nonFixupCommitHeaders`', () => {
it('should check that the fixup commit matches a non-fixup one', () => {
const msg = 'fixup! foo';
expect(validateCommitMessage(msg, false, ['foo', 'bar', 'baz'])).toBe(VALID);
expect(validateCommitMessage(msg, false, ['bar', 'baz', 'foo'])).toBe(VALID);
expect(validateCommitMessage(msg, false, ['baz', 'foo', 'bar'])).toBe(VALID);
expect(validateCommitMessage(msg, false, ['qux', 'quux', 'quuux'])).toBe(INVALID);
expect(lastError).toContain(
'Unable to find match for fixup commit among prior commits: \n' +
' qux\n' +
' quux\n' +
' quuux');
});
it('should fail if `nonFixupCommitHeaders` is empty', () => {
expect(validateCommitMessage('refactor(core): make reactive', false, [])).toBe(VALID);
expect(validateCommitMessage('fixup! foo', false, [])).toBe(INVALID);
expect(lastError).toContain(
`Unable to find match for fixup commit among prior commits: -`);
});
});
});
});
});

View File

@ -0,0 +1,134 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* 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
*/
import {getAngularDevConfig} from '../utils/config';
import {CommitMessageConfig} from './config';
const FIXUP_PREFIX_RE = /^fixup! /i;
const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig;
const SQUASH_PREFIX_RE = /^squash! /i;
const REVERT_PREFIX_RE = /^revert:? /i;
const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/;
const COMMIT_HEADER_RE = /^(.*)/i;
const COMMIT_BODY_RE = /^.*\n\n(.*)/i;
/** Parse a full commit message into its composite parts. */
export function parseCommitMessage(commitMsg: string) {
let header = '';
let body = '';
let bodyWithoutLinking = '';
let type = '';
let scope = '';
let subject = '';
if (COMMIT_HEADER_RE.test(commitMsg)) {
header = COMMIT_HEADER_RE.exec(commitMsg) ![1]
.replace(FIXUP_PREFIX_RE, '')
.replace(SQUASH_PREFIX_RE, '');
}
if (COMMIT_BODY_RE.test(commitMsg)) {
body = COMMIT_BODY_RE.exec(commitMsg) ![1];
bodyWithoutLinking = body.replace(GITHUB_LINKING_RE, '');
}
if (TYPE_SCOPE_RE.test(header)) {
const parsedCommitHeader = TYPE_SCOPE_RE.exec(header) !;
type = parsedCommitHeader[1];
scope = parsedCommitHeader[2];
subject = parsedCommitHeader[3];
}
return {
header,
body,
bodyWithoutLinking,
type,
scope,
subject,
isFixup: FIXUP_PREFIX_RE.test(commitMsg),
isSquash: SQUASH_PREFIX_RE.test(commitMsg),
isRevert: REVERT_PREFIX_RE.test(commitMsg),
};
}
/** Validate a commit message against using the local repo's config. */
export function validateCommitMessage(
commitMsg: string, disallowSquash: boolean = false, nonFixupCommitHeaders?: string[]) {
function error(errorMessage: string) {
console.error(
`INVALID COMMIT MSG: \n` +
`${'─'.repeat(40)}\n` +
`${commitMsg}\n` +
`${'─'.repeat(40)}\n` +
`ERROR: \n` +
` ${errorMessage}` +
`\n\n` +
`The expected format for a commit is: \n` +
`<type>(<scope>): <subject>\n\n<body>`);
}
const config = getAngularDevConfig<'commitMessage', CommitMessageConfig>().commitMessage;
const commit = parseCommitMessage(commitMsg);
if (commit.isRevert) {
return true;
}
if (commit.isSquash && disallowSquash) {
error('The commit must be manually squashed into the target commit');
return false;
}
// If it is a fixup commit and `nonFixupCommitHeaders` is not empty, we only care to 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).
if (commit.isFixup && nonFixupCommitHeaders) {
if (!nonFixupCommitHeaders.includes(commit.header)) {
error(
'Unable to find match for fixup commit among prior commits: ' +
(nonFixupCommitHeaders.map(x => `\n ${x}`).join('') || '-'));
return false;
}
return true;
}
if (commit.header.length > config.maxLineLength) {
error(`The commit message header is longer than ${config.maxLineLength} characters`);
return false;
}
if (!commit.type) {
error(`The commit message header does not match the expected format.`);
return false;
}
if (!config.types.includes(commit.type)) {
error(`'${commit.type}' is not an allowed type.\n => TYPES: ${config.types.join(', ')}`);
return false;
}
if (commit.scope && !config.scopes.includes(commit.scope)) {
error(`'${commit.scope}' is not an allowed scope.\n => SCOPES: ${config.scopes.join(', ')}`);
return false;
}
if (commit.bodyWithoutLinking.trim().length < config.minBodyLength) {
error(
`The commit message body does not meet the minimum length of ${config.minBodyLength} characters`);
return false;
}
const bodyByLine = commit.body.split('\n');
if (bodyByLine.some(line => line.length > config.maxLineLength)) {
error(
`The commit messsage body contains lines greater than ${config.maxLineLength} characters`);
return false;
}
return true;
}