refactor(dev-infra): use conventional-commits-parser for commit parsing (#41286)

Use conventional-commits-parser for parsing commits for validation, this is being done
in anticipation of relying on this parser for release note creation.  Unifying how commits
are parsed will provide the most consistency in our tooling.

PR Close #41286
This commit is contained in:
Joey Perrott 2021-03-19 12:11:21 -07:00
parent eba1289ec9
commit 0516fbb180
16 changed files with 304 additions and 131 deletions

View File

@ -11,10 +11,12 @@ ts_library(
visibility = ["//dev-infra:__subpackages__"], visibility = ["//dev-infra:__subpackages__"],
deps = [ deps = [
"//dev-infra/utils", "//dev-infra/utils",
"@npm//@types/conventional-commits-parser",
"@npm//@types/inquirer", "@npm//@types/inquirer",
"@npm//@types/node", "@npm//@types/node",
"@npm//@types/shelljs", "@npm//@types/shelljs",
"@npm//@types/yargs", "@npm//@types/yargs",
"@npm//conventional-commits-parser",
"@npm//inquirer", "@npm//inquirer",
"@npm//shelljs", "@npm//shelljs",
"@npm//yargs", "@npm//yargs",

View File

@ -6,27 +6,40 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {parseCommitMessage, ParsedCommitMessage} from './parse'; import {parseCommitMessage} from './parse';
const commitValues = { const commitValues = {
prefix: '', prefix: '',
type: 'fix', type: 'fix',
npmScope: '',
scope: 'changed-area', scope: 'changed-area',
summary: 'This is a short summary of the change', summary: 'This is a short summary of the change',
body: 'This is a longer description of the change Closes #1', body: 'This is a longer description of the change',
footer: 'Closes #1',
}; };
function buildCommitMessage(params = {}) { function buildCommitMessage(params: Partial<typeof commitValues> = {}) {
const {prefix, type, scope, summary, body} = {...commitValues, ...params}; const {prefix, npmScope, type, scope, summary, body, footer} = {...commitValues, ...params};
return `${prefix}${type}${scope ? '(' + scope + ')' : ''}: ${summary}\n\n${body}`; const scopeSlug = npmScope ? `${npmScope}/${scope}` : scope;
return `${prefix}${type}${scopeSlug ? '(' + scopeSlug + ')' : ''}: ${summary}\n\n${body}\n\n${
footer}`;
} }
describe('commit message parsing:', () => { describe('commit message parsing:', () => {
it('parses the scope', () => { describe('parses the scope', () => {
it('when only a scope is defined', () => {
const message = buildCommitMessage(); const message = buildCommitMessage();
expect(parseCommitMessage(message).scope).toBe(commitValues.scope); expect(parseCommitMessage(message).scope).toBe(commitValues.scope);
expect(parseCommitMessage(message).npmScope).toBe('');
});
it('when an npmScope and scope are defined', () => {
const message = buildCommitMessage({npmScope: 'myNpmPackage'});
expect(parseCommitMessage(message).scope).toBe(commitValues.scope);
expect(parseCommitMessage(message).npmScope).toBe('myNpmPackage');
});
}); });
it('parses the type', () => { it('parses the type', () => {
@ -45,12 +58,6 @@ describe('commit message parsing:', () => {
expect(parseCommitMessage(message).body).toBe(commitValues.body); expect(parseCommitMessage(message).body).toBe(commitValues.body);
}); });
it('parses the body without Github linking', () => {
const body = 'This has linking\nCloses #1';
const message = buildCommitMessage({body});
expect(parseCommitMessage(message).bodyWithoutLinking).toBe('This has linking\n');
});
it('parses the subject', () => { it('parses the subject', () => {
const message = buildCommitMessage(); const message = buildCommitMessage();
expect(parseCommitMessage(message).subject).toBe(commitValues.summary); expect(parseCommitMessage(message).subject).toBe(commitValues.summary);
@ -100,6 +107,71 @@ describe('commit message parsing:', () => {
expect(parsedMessage.body) expect(parsedMessage.body)
.toBe( .toBe(
'This is line 1 of the actual body.\n' + 'This is line 1 of the actual body.\n' +
'This is line 2 of the actual body (and it also contains a # but it not a comment).\n'); 'This is line 2 of the actual body (and it also contains a # but it not a comment).');
});
describe('parses breaking change notes', () => {
const summary = 'This breaks things';
const description = 'This is how it breaks things.';
it('when only a summary is provided', () => {
const message = buildCommitMessage({
footer: `BREAKING CHANGE: ${summary}`,
});
const parsedMessage = parseCommitMessage(message);
expect(parsedMessage.breakingChanges[0].text).toBe(summary);
expect(parsedMessage.breakingChanges.length).toBe(1);
});
it('when only a description is provided', () => {
const message = buildCommitMessage({
footer: `BREAKING CHANGE:\n\n${description}`,
});
const parsedMessage = parseCommitMessage(message);
expect(parsedMessage.breakingChanges[0].text).toBe(description);
expect(parsedMessage.breakingChanges.length).toBe(1);
});
it('when a summary and description are provied', () => {
const message = buildCommitMessage({
footer: `BREAKING CHANGE: ${summary}\n\n${description}`,
});
const parsedMessage = parseCommitMessage(message);
expect(parsedMessage.breakingChanges[0].text).toBe(`${summary}\n\n${description}`);
expect(parsedMessage.breakingChanges.length).toBe(1);
});
});
describe('parses deprecation notes', () => {
const summary = 'This will break things later';
const description = 'This is a long winded explanation of why it \nwill break things later.';
it('when only a summary is provided', () => {
const message = buildCommitMessage({
footer: `DEPRECATED: ${summary}`,
});
const parsedMessage = parseCommitMessage(message);
expect(parsedMessage.deprecations[0].text).toBe(summary);
expect(parsedMessage.deprecations.length).toBe(1);
});
it('when only a description is provided', () => {
const message = buildCommitMessage({
footer: `DEPRECATED:\n\n${description}`,
});
const parsedMessage = parseCommitMessage(message);
expect(parsedMessage.deprecations[0].text).toBe(description);
expect(parsedMessage.deprecations.length).toBe(1);
});
it('when a summary and description are provied', () => {
const message = buildCommitMessage({
footer: `DEPRECATED: ${summary}\n\n${description}`,
});
const parsedMessage = parseCommitMessage(message);
expect(parsedMessage.deprecations[0].text).toBe(`${summary}\n\n${description}`);
expect(parsedMessage.deprecations.length).toBe(1);
});
}); });
}); });

View File

@ -6,80 +6,129 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {Commit as ParsedCommit, Options, sync as parse} from 'conventional-commits-parser';
import {exec} from '../utils/shelljs'; import {exec} from '../utils/shelljs';
/** A parsed commit message. */
export interface ParsedCommitMessage { /** A parsed commit, containing the information needed to validate the commit. */
export interface Commit {
/** The full raw text of the commit. */
fullText: string;
/** The header line of the commit, will be used in the changelog entries. */
header: string; header: string;
/** The full body of the commit, not including the footer. */
body: string; body: string;
bodyWithoutLinking: string; /** The footer of the commit, containing issue references and note sections. */
footer: string;
/** A list of the references to other issues made throughout the commit message. */
references: ParsedCommit.Reference[];
/** The type of the commit message. */
type: string; type: string;
/** The scope of the commit message. */
scope: string; scope: string;
/** The npm scope of the commit message. */
npmScope: string;
/** The subject of the commit message. */
subject: string; subject: string;
/** A list of breaking change notes in the commit message. */
breakingChanges: ParsedCommit.Note[];
/** A list of deprecation notes in the commit message. */
deprecations: ParsedCommit.Note[];
/** Whether the commit is a fixup commit. */
isFixup: boolean; isFixup: boolean;
/** Whether the commit is a squash commit. */
isSquash: boolean; isSquash: boolean;
/** Whether the commit is a revert commit. */
isRevert: boolean; isRevert: boolean;
} }
/** Markers used to denote the start of a note section in a commit. */
enum NoteSections {
BREAKING_CHANGE = 'BREAKING CHANGE',
DEPRECATED = 'DEPRECATED',
}
/** Regex determining if a commit is a fixup. */ /** Regex determining if a commit is a fixup. */
const FIXUP_PREFIX_RE = /^fixup! /i; const FIXUP_PREFIX_RE = /^fixup! /i;
/** Regex finding all github keyword links. */
const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig;
/** Regex determining if a commit is a squash. */ /** Regex determining if a commit is a squash. */
const SQUASH_PREFIX_RE = /^squash! /i; const SQUASH_PREFIX_RE = /^squash! /i;
/** Regex determining if a commit is a revert. */ /** Regex determining if a commit is a revert. */
const REVERT_PREFIX_RE = /^revert:? /i; const REVERT_PREFIX_RE = /^revert:? /i;
/** Regex determining the scope of a commit if provided. */ /**
const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/; * Regex pattern for parsing the header line of a commit.
/** Regex determining the entire header line of the commit. */ *
const COMMIT_HEADER_RE = /^(.*)/i; * Several groups are being matched to be used in the parsed commit object, being mapped to the
/** Regex determining the body of the commit. */ * `headerCorrespondence` object.
const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/; *
* The pattern can be broken down into component parts:
* - `(\w+)` - a capturing group discovering the type of the commit.
* - `(?:\((?:([^/]+)\/)?([^)]+)\))?` - a pair of capturing groups to capture the scope and,
* optionally the npmScope of the commit.
* - `(.*)` - a capturing group discovering the subject of the commit.
*/
const headerPattern = /^(\w+)(?:\((?:([^/]+)\/)?([^)]+)\))?: (.*)$/;
/**
* The property names used for the values extracted from the header via the `headerPattern` regex.
*/
const headerCorrespondence = ['type', 'npmScope', 'scope', 'subject'];
/**
* Configuration options for the commit parser.
*
* NOTE: An extended type from `Options` must be used because the current
* @types/conventional-commits-parser version does not include the `notesPattern` field.
*/
const parseOptions: Options&{notesPattern: (keywords: string) => RegExp} = {
commentChar: '#',
headerPattern,
headerCorrespondence,
noteKeywords: [NoteSections.BREAKING_CHANGE, NoteSections.DEPRECATED],
notesPattern: (keywords: string) => new RegExp(`(${keywords})(?:: ?)(.*)`),
};
/** Parse a full commit message into its composite parts. */ /** Parse a full commit message into its composite parts. */
export function parseCommitMessage(commitMsg: string): ParsedCommitMessage { export function parseCommitMessage(fullText: string): Commit {
// Ignore comments (i.e. lines starting with `#`). Comments are automatically removed by git and /** The commit message text with the fixup and squash markers stripped out. */
// should not be considered part of the final commit message. const strippedCommitMsg = fullText.replace(FIXUP_PREFIX_RE, '')
commitMsg = commitMsg.split('\n').filter(line => !line.startsWith('#')).join('\n'); .replace(SQUASH_PREFIX_RE, '')
.replace(REVERT_PREFIX_RE, '');
/** The initially parsed commit. */
const commit = parse(strippedCommitMsg, parseOptions);
/** A list of breaking change notes from the commit. */
const breakingChanges: ParsedCommit.Note[] = [];
/** A list of deprecation notes from the commit. */
const deprecations: ParsedCommit.Note[] = [];
let header = ''; // Extract the commit message notes by marked types into their respective lists.
let body = ''; commit.notes.forEach((note: ParsedCommit.Note) => {
let bodyWithoutLinking = ''; if (note.title === NoteSections.BREAKING_CHANGE) {
let type = ''; return breakingChanges.push(note);
let scope = ''; }
let subject = ''; if (note.title === NoteSections.DEPRECATED) {
return deprecations.push(note);
}
});
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 { return {
header, fullText,
body, breakingChanges,
bodyWithoutLinking, deprecations,
type, body: commit.body || '',
scope, footer: commit.footer || '',
subject, header: commit.header || '',
isFixup: FIXUP_PREFIX_RE.test(commitMsg), references: commit.references,
isSquash: SQUASH_PREFIX_RE.test(commitMsg), scope: commit.scope || '',
isRevert: REVERT_PREFIX_RE.test(commitMsg), subject: commit.subject || '',
type: commit.type || '',
npmScope: commit.npmScope || '',
isFixup: FIXUP_PREFIX_RE.test(fullText),
isSquash: SQUASH_PREFIX_RE.test(fullText),
isRevert: REVERT_PREFIX_RE.test(fullText),
}; };
} }
/** Retrieve and parse each commit message in a provide range. */ /** Retrieve and parse each commit message in a provide range. */
export function parseCommitMessagesForRange(range: string): ParsedCommitMessage[] { export function parseCommitMessagesForRange(range: string): Commit[] {
/** A random number used as a split point in the git log result. */ /** A random number used as a split point in the git log result. */
const randomValueSeparator = `${Math.random()}`; const randomValueSeparator = `${Math.random()}`;
/** /**

View File

@ -8,7 +8,7 @@
import {Arguments, Argv, CommandModule} from 'yargs'; import {Arguments, Argv, CommandModule} from 'yargs';
import {CommitMsgSource} from '../commit-message-source'; import {CommitMsgSource} from './commit-message-source';
import {restoreCommitMessage} from './restore-commit-message'; import {restoreCommitMessage} from './restore-commit-message';

View File

@ -10,8 +10,8 @@ import {writeFileSync} from 'fs';
import {debug, log} from '../../utils/console'; import {debug, log} from '../../utils/console';
import {loadCommitMessageDraft} from '../commit-message-draft'; import {loadCommitMessageDraft} from './commit-message-draft';
import {CommitMsgSource} from '../commit-message-source'; import {CommitMsgSource} from './commit-message-source';
/** /**
* Restore the commit message draft to the git to be used as the default commit message. * Restore the commit message draft to the git to be used as the default commit message.

View File

@ -11,7 +11,7 @@ import {resolve} from 'path';
import {getRepoBaseDir} from '../../utils/config'; import {getRepoBaseDir} from '../../utils/config';
import {error, green, info, log, red, yellow} from '../../utils/console'; import {error, green, info, log, red, yellow} from '../../utils/console';
import {deleteCommitMessageDraft, saveCommitMessageDraft} from '../commit-message-draft'; import {deleteCommitMessageDraft, saveCommitMessageDraft} from '../restore-commit-message/commit-message-draft';
import {printValidationErrors, validateCommitMessage} from '../validate'; import {printValidationErrors, validateCommitMessage} from '../validate';
/** Validate commit message at the provided file path. */ /** Validate commit message at the provided file path. */

View File

@ -6,15 +6,14 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {error, info} from '../../utils/console'; import {error, info} from '../../utils/console';
import {Commit, parseCommitMessagesForRange} from '../parse';
import {parseCommitMessagesForRange, ParsedCommitMessage} from '../parse';
import {printValidationErrors, validateCommitMessage, ValidateCommitMessageOptions} from '../validate'; import {printValidationErrors, validateCommitMessage, ValidateCommitMessageOptions} from '../validate';
// Whether the provided commit is a fixup commit. // Whether the provided commit is a fixup commit.
const isNonFixup = (commit: ParsedCommitMessage) => !commit.isFixup; const isNonFixup = (commit: Commit) => !commit.isFixup;
// Extracts commit header (first line of commit message). // Extracts commit header (first line of commit message).
const extractCommitHeader = (commit: ParsedCommitMessage) => commit.header; const extractCommitHeader = (commit: Commit) => commit.header;
/** Validate all commits in a provided git commit range. */ /** Validate all commits in a provided git commit range. */
export function validateCommitRange(range: string) { export function validateCommitRange(range: string) {

View File

@ -5,10 +5,11 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
import {error} from '../utils/console'; import {error} from '../utils/console';
import {COMMIT_TYPES, getCommitMessageConfig, ScopeRequirement} from './config'; import {COMMIT_TYPES, getCommitMessageConfig, ScopeRequirement} from './config';
import {parseCommitMessage, ParsedCommitMessage} from './parse'; import {Commit, parseCommitMessage} from './parse';
/** Options for commit message validation. */ /** Options for commit message validation. */
export interface ValidateCommitMessageOptions { export interface ValidateCommitMessageOptions {
@ -20,7 +21,7 @@ export interface ValidateCommitMessageOptions {
export interface ValidateCommitMessageResult { export interface ValidateCommitMessageResult {
valid: boolean; valid: boolean;
errors: string[]; errors: string[];
commit: ParsedCommitMessage; commit: Commit;
} }
/** Regex matching a URL for an entire commit body line. */ /** Regex matching a URL for an entire commit body line. */
@ -38,7 +39,7 @@ const COMMIT_BODY_BREAKING_CHANGE_RE = /^BREAKING CHANGE(:( |\n{2}))?/m;
/** Validate a commit message against using the local repo's config. */ /** Validate a commit message against using the local repo's config. */
export function validateCommitMessage( export function validateCommitMessage(
commitMsg: string|ParsedCommitMessage, commitMsg: string|Commit,
options: ValidateCommitMessageOptions = {}): ValidateCommitMessageResult { options: ValidateCommitMessageOptions = {}): ValidateCommitMessageResult {
const config = getCommitMessageConfig().commitMessage; const config = getCommitMessageConfig().commitMessage;
const commit = typeof commitMsg === 'string' ? parseCommitMessage(commitMsg) : commitMsg; const commit = typeof commitMsg === 'string' ? parseCommitMessage(commitMsg) : commitMsg;
@ -46,8 +47,6 @@ export function validateCommitMessage(
/** Perform the validation checks against the parsed commit. */ /** Perform the validation checks against the parsed commit. */
function validateCommitAndCollectErrors() { function validateCommitAndCollectErrors() {
// TODO(josephperrott): Remove early return calls when commit message errors are found
//////////////////////////////////// ////////////////////////////////////
// Checking revert, squash, fixup // // Checking revert, squash, fixup //
//////////////////////////////////// ////////////////////////////////////
@ -133,14 +132,14 @@ export function validateCommitMessage(
////////////////////////// //////////////////////////
if (!config.minBodyLengthTypeExcludes?.includes(commit.type) && if (!config.minBodyLengthTypeExcludes?.includes(commit.type) &&
commit.bodyWithoutLinking.trim().length < config.minBodyLength) { commit.body.trim().length < config.minBodyLength) {
errors.push(`The commit message body does not meet the minimum length of ${ errors.push(`The commit message body does not meet the minimum length of ${
config.minBodyLength} characters`); config.minBodyLength} characters`);
return false; return false;
} }
const bodyByLine = commit.body.split('\n'); const bodyByLine = commit.body.split('\n');
const lineExceedsMaxLength = bodyByLine.some(line => { const lineExceedsMaxLength = bodyByLine.some((line: string) => {
// Check if any line exceeds the max line length limit. The limit is ignored for // 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). // 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); return line.length > config.maxLineLength && !COMMIT_BODY_URL_LINE_RE.test(line);
@ -155,7 +154,7 @@ export function validateCommitMessage(
// Breaking change // Breaking change
// Check if the commit message contains a valid break change description. // Check if the commit message contains a valid break change description.
// https://github.com/angular/angular/blob/88fbc066775ab1a2f6a8c75f933375b46d8fa9a4/CONTRIBUTING.md#commit-message-footer // https://github.com/angular/angular/blob/88fbc066775ab1a2f6a8c75f933375b46d8fa9a4/CONTRIBUTING.md#commit-message-footer
const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.body); const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.fullText);
if (hasBreakingChange !== null) { if (hasBreakingChange !== null) {
const [, breakingChangeDescription] = hasBreakingChange; const [, breakingChangeDescription] = hasBreakingChange;
if (!breakingChangeDescription) { if (!breakingChangeDescription) {

View File

@ -19,6 +19,7 @@ var fetch = _interopDefault(require('node-fetch'));
var semver = require('semver'); var semver = require('semver');
var multimatch = require('multimatch'); var multimatch = require('multimatch');
var yaml = require('yaml'); var yaml = require('yaml');
var conventionalCommitsParser = require('conventional-commits-parser');
var cliProgress = require('cli-progress'); var cliProgress = require('cli-progress');
var os = require('os'); var os = require('os');
var minimatch = require('minimatch'); var minimatch = require('minimatch');
@ -1683,56 +1684,84 @@ const COMMIT_TYPES = {
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
/** Markers used to denote the start of a note section in a commit. */
var NoteSections;
(function (NoteSections) {
NoteSections["BREAKING_CHANGE"] = "BREAKING CHANGE";
NoteSections["DEPRECATED"] = "DEPRECATED";
})(NoteSections || (NoteSections = {}));
/** Regex determining if a commit is a fixup. */ /** Regex determining if a commit is a fixup. */
const FIXUP_PREFIX_RE = /^fixup! /i; const FIXUP_PREFIX_RE = /^fixup! /i;
/** Regex finding all github keyword links. */
const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig;
/** Regex determining if a commit is a squash. */ /** Regex determining if a commit is a squash. */
const SQUASH_PREFIX_RE = /^squash! /i; const SQUASH_PREFIX_RE = /^squash! /i;
/** Regex determining if a commit is a revert. */ /** Regex determining if a commit is a revert. */
const REVERT_PREFIX_RE = /^revert:? /i; const REVERT_PREFIX_RE = /^revert:? /i;
/** Regex determining the scope of a commit if provided. */ /**
const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/; * Regex pattern for parsing the header line of a commit.
/** Regex determining the entire header line of the commit. */ *
const COMMIT_HEADER_RE = /^(.*)/i; * Several groups are being matched to be used in the parsed commit object, being mapped to the
/** Regex determining the body of the commit. */ * `headerCorrespondence` object.
const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/; *
* The pattern can be broken down into component parts:
* - `(\w+)` - a capturing group discovering the type of the commit.
* - `(?:\((?:([^/]+)\/)?([^)]+)\))?` - a pair of capturing groups to capture the scope and,
* optionally the npmScope of the commit.
* - `(.*)` - a capturing group discovering the subject of the commit.
*/
const headerPattern = /^(\w+)(?:\((?:([^/]+)\/)?([^)]+)\))?: (.*)$/;
/**
* The property names used for the values extracted from the header via the `headerPattern` regex.
*/
const headerCorrespondence = ['type', 'npmScope', 'scope', 'subject'];
/**
* Configuration options for the commit parser.
*
* NOTE: An extended type from `Options` must be used because the current
* @types/conventional-commits-parser version does not include the `notesPattern` field.
*/
const parseOptions = {
commentChar: '#',
headerPattern,
headerCorrespondence,
noteKeywords: [NoteSections.BREAKING_CHANGE, NoteSections.DEPRECATED],
notesPattern: (keywords) => new RegExp(`(${keywords})(?:: ?)(.*)`),
};
/** Parse a full commit message into its composite parts. */ /** Parse a full commit message into its composite parts. */
function parseCommitMessage(commitMsg) { function parseCommitMessage(fullText) {
// Ignore comments (i.e. lines starting with `#`). Comments are automatically removed by git and /** The commit message text with the fixup and squash markers stripped out. */
// should not be considered part of the final commit message. const strippedCommitMsg = fullText.replace(FIXUP_PREFIX_RE, '')
commitMsg = commitMsg.split('\n').filter(line => !line.startsWith('#')).join('\n'); .replace(SQUASH_PREFIX_RE, '')
let header = ''; .replace(REVERT_PREFIX_RE, '');
let body = ''; /** The initially parsed commit. */
let bodyWithoutLinking = ''; const commit = conventionalCommitsParser.sync(strippedCommitMsg, parseOptions);
let type = ''; /** A list of breaking change notes from the commit. */
let scope = ''; const breakingChanges = [];
let subject = ''; /** A list of deprecation notes from the commit. */
if (COMMIT_HEADER_RE.test(commitMsg)) { const deprecations = [];
header = COMMIT_HEADER_RE.exec(commitMsg)[1] // Extract the commit message notes by marked types into their respective lists.
.replace(FIXUP_PREFIX_RE, '') commit.notes.forEach((note) => {
.replace(SQUASH_PREFIX_RE, ''); if (note.title === NoteSections.BREAKING_CHANGE) {
return breakingChanges.push(note);
} }
if (COMMIT_BODY_RE.test(commitMsg)) { if (note.title === NoteSections.DEPRECATED) {
body = COMMIT_BODY_RE.exec(commitMsg)[1]; return deprecations.push(note);
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 { return {
header, fullText,
body, breakingChanges,
bodyWithoutLinking, deprecations,
type, body: commit.body || '',
scope, footer: commit.footer || '',
subject, header: commit.header || '',
isFixup: FIXUP_PREFIX_RE.test(commitMsg), references: commit.references,
isSquash: SQUASH_PREFIX_RE.test(commitMsg), scope: commit.scope || '',
isRevert: REVERT_PREFIX_RE.test(commitMsg), subject: commit.subject || '',
type: commit.type || '',
npmScope: commit.npmScope || '',
isFixup: FIXUP_PREFIX_RE.test(fullText),
isSquash: SQUASH_PREFIX_RE.test(fullText),
isRevert: REVERT_PREFIX_RE.test(fullText),
}; };
} }
/** Retrieve and parse each commit message in a provide range. */ /** Retrieve and parse each commit message in a provide range. */
@ -1786,11 +1815,10 @@ function validateCommitMessage(commitMsg, options = {}) {
const errors = []; const errors = [];
/** Perform the validation checks against the parsed commit. */ /** Perform the validation checks against the parsed commit. */
function validateCommitAndCollectErrors() { function validateCommitAndCollectErrors() {
// TODO(josephperrott): Remove early return calls when commit message errors are found
var _a;
//////////////////////////////////// ////////////////////////////////////
// Checking revert, squash, fixup // // Checking revert, squash, fixup //
//////////////////////////////////// ////////////////////////////////////
var _a;
// All revert commits are considered valid. // All revert commits are considered valid.
if (commit.isRevert) { if (commit.isRevert) {
return true; return true;
@ -1854,12 +1882,12 @@ function validateCommitMessage(commitMsg, options = {}) {
// Checking commit body // // Checking commit body //
////////////////////////// //////////////////////////
if (!((_a = config.minBodyLengthTypeExcludes) === null || _a === void 0 ? void 0 : _a.includes(commit.type)) && if (!((_a = config.minBodyLengthTypeExcludes) === null || _a === void 0 ? void 0 : _a.includes(commit.type)) &&
commit.bodyWithoutLinking.trim().length < config.minBodyLength) { commit.body.trim().length < config.minBodyLength) {
errors.push(`The commit message body does not meet the minimum length of ${config.minBodyLength} characters`); errors.push(`The commit message body does not meet the minimum length of ${config.minBodyLength} characters`);
return false; return false;
} }
const bodyByLine = commit.body.split('\n'); const bodyByLine = commit.body.split('\n');
const lineExceedsMaxLength = bodyByLine.some(line => { const lineExceedsMaxLength = bodyByLine.some((line) => {
// Check if any line exceeds the max line length limit. The limit is ignored for // 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). // 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); return line.length > config.maxLineLength && !COMMIT_BODY_URL_LINE_RE.test(line);
@ -1871,7 +1899,7 @@ function validateCommitMessage(commitMsg, options = {}) {
// Breaking change // Breaking change
// Check if the commit message contains a valid break change description. // Check if the commit message contains a valid break change description.
// https://github.com/angular/angular/blob/88fbc066775ab1a2f6a8c75f933375b46d8fa9a4/CONTRIBUTING.md#commit-message-footer // https://github.com/angular/angular/blob/88fbc066775ab1a2f6a8c75f933375b46d8fa9a4/CONTRIBUTING.md#commit-message-footer
const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.body); const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.fullText);
if (hasBreakingChange !== null) { if (hasBreakingChange !== null) {
const [, breakingChangeDescription] = hasBreakingChange; const [, breakingChangeDescription] = hasBreakingChange;
if (!breakingChangeDescription) { if (!breakingChangeDescription) {

View File

@ -11,9 +11,11 @@ ts_library(
deps = [ deps = [
"//dev-infra/commit-message", "//dev-infra/commit-message",
"//dev-infra/utils", "//dev-infra/utils",
"@npm//@types/conventional-commits-parser",
"@npm//@types/inquirer", "@npm//@types/inquirer",
"@npm//@types/node", "@npm//@types/node",
"@npm//@types/yargs", "@npm//@types/yargs",
"@npm//conventional-commits-parser",
"@npm//inquirer", "@npm//inquirer",
"@npm//typed-graphqlify", "@npm//typed-graphqlify",
"@npm//yargs", "@npm//yargs",

View File

@ -7,8 +7,8 @@
*/ */
import {types as graphQLTypes} from 'typed-graphqlify'; import {types as graphQLTypes} from 'typed-graphqlify';
import {parseCommitMessagesForRange, ParsedCommitMessage} from '../../commit-message/parse';
import {Commit, parseCommitMessagesForRange} from '../../commit-message/parse';
import {getConfig, NgDevConfig} from '../../utils/config'; import {getConfig, NgDevConfig} from '../../utils/config';
import {error, info, promptConfirm} from '../../utils/console'; import {error, info, promptConfirm} from '../../utils/console';
import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls'; import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls';
@ -95,8 +95,7 @@ export async function rebasePr(
const commits = parseCommitMessagesForRange(`${commonAncestorSha}..HEAD`); const commits = parseCommitMessagesForRange(`${commonAncestorSha}..HEAD`);
let squashFixups = let squashFixups = commits.filter((commit: Commit) => commit.isFixup).length === 0 ?
commits.filter((commit: ParsedCommitMessage) => commit.isFixup).length === 0 ?
false : false :
await promptConfirm( await promptConfirm(
`PR #${prNumber} contains fixup commits, would you like to squash them during rebase?`, `PR #${prNumber} contains fixup commits, would you like to squash them during rebase?`,

View File

@ -16,6 +16,7 @@
"brotli": "<from-root>", "brotli": "<from-root>",
"chalk": "<from-root>", "chalk": "<from-root>",
"cli-progress": "<from-root>", "cli-progress": "<from-root>",
"conventional-commits-parser": "<from-root>",
"glob": "<from-root>", "glob": "<from-root>",
"inquirer": "<from-root>", "inquirer": "<from-root>",
"minimatch": "<from-root>", "minimatch": "<from-root>",

View File

@ -48,6 +48,7 @@
"@babel/cli": "^7.8.4", "@babel/cli": "^7.8.4",
"@babel/core": "^7.8.6", "@babel/core": "^7.8.6",
"@babel/generator": "^7.8.6", "@babel/generator": "^7.8.6",
"@babel/parser": "^7.0.0",
"@babel/preset-env": "^7.10.2", "@babel/preset-env": "^7.10.2",
"@babel/template": "^7.8.6", "@babel/template": "^7.8.6",
"@babel/traverse": "^7.8.6", "@babel/traverse": "^7.8.6",
@ -61,7 +62,6 @@
"@microsoft/api-extractor": "7.7.11", "@microsoft/api-extractor": "7.7.11",
"@octokit/rest": "16.28.7", "@octokit/rest": "16.28.7",
"@octokit/types": "^5.0.1", "@octokit/types": "^5.0.1",
"tmp": "0.0.33",
"@schematics/angular": "11.0.0-rc.1", "@schematics/angular": "11.0.0-rc.1",
"@types/angular": "^1.6.47", "@types/angular": "^1.6.47",
"@types/babel__core": "^7.1.6", "@types/babel__core": "^7.1.6",
@ -73,14 +73,13 @@
"@types/chai": "^4.1.2", "@types/chai": "^4.1.2",
"@types/convert-source-map": "^1.5.1", "@types/convert-source-map": "^1.5.1",
"@types/diff": "^3.5.1", "@types/diff": "^3.5.1",
"@types/events": "3.0.0",
"@types/fs-extra": "4.0.2", "@types/fs-extra": "4.0.2",
"@types/hammerjs": "2.0.35", "@types/hammerjs": "2.0.35",
"@types/inquirer": "^7.3.0", "@types/inquirer": "^7.3.0",
"@types/jasmine": "3.5.10", "@types/jasmine": "3.5.10",
"@types/jasmine-ajax": "^3.3.1", "@types/jasmine-ajax": "^3.3.1",
"@types/jasminewd2": "^2.0.8", "@types/jasminewd2": "^2.0.8",
"@babel/parser": "^7.0.0",
"@types/events": "3.0.0",
"@types/minimist": "^1.2.0", "@types/minimist": "^1.2.0",
"@types/multimatch": "^4.0.0", "@types/multimatch": "^4.0.0",
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
@ -151,6 +150,7 @@
"sourcemap-codec": "^1.4.8", "sourcemap-codec": "^1.4.8",
"systemjs": "0.18.10", "systemjs": "0.18.10",
"terser": "^4.4.0", "terser": "^4.4.0",
"tmp": "0.0.33",
"tsickle": "0.38.1", "tsickle": "0.38.1",
"tslib": "^2.1.0", "tslib": "^2.1.0",
"tslint": "6.1.3", "tslint": "6.1.3",
@ -167,6 +167,7 @@
"@bazel/ibazel": "^0.12.3", "@bazel/ibazel": "^0.12.3",
"@octokit/graphql": "^4.3.1", "@octokit/graphql": "^4.3.1",
"@types/cli-progress": "^3.4.2", "@types/cli-progress": "^3.4.2",
"@types/conventional-commits-parser": "^3.0.1",
"@types/minimist": "^1.2.0", "@types/minimist": "^1.2.0",
"@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/lockfile": "^1.1.0",
"browserstacktunnel-wrapper": "^2.0.4", "browserstacktunnel-wrapper": "^2.0.4",
@ -177,6 +178,7 @@
"cldrjs": "0.5.0", "cldrjs": "0.5.0",
"cli-progress": "^3.7.0", "cli-progress": "^3.7.0",
"conventional-changelog": "^2.0.3", "conventional-changelog": "^2.0.3",
"conventional-commits-parser": "^3.2.1",
"entities": "1.1.1", "entities": "1.1.1",
"firebase-tools": "^7.11.0", "firebase-tools": "^7.11.0",
"firefox-profile": "1.0.3", "firefox-profile": "1.0.3",

View File

@ -2447,6 +2447,13 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/conventional-commits-parser@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/conventional-commits-parser/-/conventional-commits-parser-3.0.1.tgz#1a4796a0ae2bf33b41ea49f55e27ccbdb29cce5e"
integrity sha512-xkKomW6PqJS0rzFPPQSzKwbKIRqAGjYa1aWWkoT14YYodXyEpG4ok4H1US3olqGBxejz7EeBfT3fTJ3hUOiUkQ==
dependencies:
"@types/node" "*"
"@types/convert-source-map@^1.5.1": "@types/convert-source-map@^1.5.1":
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.1.tgz#d4d180dd6adc5cb68ad99bd56e03d637881f4616" resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.1.tgz#d4d180dd6adc5cb68ad99bd56e03d637881f4616"
@ -5313,6 +5320,19 @@ conventional-commits-parser@^3.2.0:
through2 "^4.0.0" through2 "^4.0.0"
trim-off-newlines "^1.0.0" trim-off-newlines "^1.0.0"
conventional-commits-parser@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-3.2.1.tgz#ba44f0b3b6588da2ee9fd8da508ebff50d116ce2"
integrity sha512-OG9kQtmMZBJD/32NEw5IhN5+HnBqVjy03eC+I71I0oQRFA5rOgA4OtPOYG7mz1GkCfCNxn3gKIX8EiHJYuf1cA==
dependencies:
JSONStream "^1.0.4"
is-text-path "^1.0.1"
lodash "^4.17.15"
meow "^8.0.0"
split2 "^3.0.0"
through2 "^4.0.0"
trim-off-newlines "^1.0.0"
convert-source-map@1.7.0, convert-source-map@^1.1.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1, convert-source-map@^1.6.0, convert-source-map@^1.7.0: convert-source-map@1.7.0, convert-source-map@^1.1.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"