diff --git a/dev-infra/commit-message/BUILD.bazel b/dev-infra/commit-message/BUILD.bazel index ed4abcc30a..7ced3089f7 100644 --- a/dev-infra/commit-message/BUILD.bazel +++ b/dev-infra/commit-message/BUILD.bazel @@ -11,10 +11,12 @@ ts_library( visibility = ["//dev-infra:__subpackages__"], deps = [ "//dev-infra/utils", + "@npm//@types/conventional-commits-parser", "@npm//@types/inquirer", "@npm//@types/node", "@npm//@types/shelljs", "@npm//@types/yargs", + "@npm//conventional-commits-parser", "@npm//inquirer", "@npm//shelljs", "@npm//yargs", diff --git a/dev-infra/commit-message/parse.spec.ts b/dev-infra/commit-message/parse.spec.ts index 22a79934a9..b1ec355a81 100644 --- a/dev-infra/commit-message/parse.spec.ts +++ b/dev-infra/commit-message/parse.spec.ts @@ -6,27 +6,40 @@ * found in the LICENSE file at https://angular.io/license */ -import {parseCommitMessage, ParsedCommitMessage} from './parse'; +import {parseCommitMessage} from './parse'; const commitValues = { prefix: '', type: 'fix', + npmScope: '', scope: 'changed-area', 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 = {}) { - const {prefix, type, scope, summary, body} = {...commitValues, ...params}; - return `${prefix}${type}${scope ? '(' + scope + ')' : ''}: ${summary}\n\n${body}`; +function buildCommitMessage(params: Partial = {}) { + const {prefix, npmScope, type, scope, summary, body, footer} = {...commitValues, ...params}; + const scopeSlug = npmScope ? `${npmScope}/${scope}` : scope; + return `${prefix}${type}${scopeSlug ? '(' + scopeSlug + ')' : ''}: ${summary}\n\n${body}\n\n${ + footer}`; } describe('commit message parsing:', () => { - it('parses the scope', () => { - const message = buildCommitMessage(); - expect(parseCommitMessage(message).scope).toBe(commitValues.scope); + describe('parses the scope', () => { + it('when only a scope is defined', () => { + const message = buildCommitMessage(); + 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', () => { @@ -45,12 +58,6 @@ describe('commit message parsing:', () => { 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', () => { const message = buildCommitMessage(); expect(parseCommitMessage(message).subject).toBe(commitValues.summary); @@ -100,6 +107,71 @@ describe('commit message parsing:', () => { expect(parsedMessage.body) .toBe( '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); + }); }); }); diff --git a/dev-infra/commit-message/parse.ts b/dev-infra/commit-message/parse.ts index bbd95502e7..365b7492b0 100644 --- a/dev-infra/commit-message/parse.ts +++ b/dev-infra/commit-message/parse.ts @@ -6,80 +6,129 @@ * 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'; -/** 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; + /** The full body of the commit, not including the footer. */ 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; + /** The scope of the commit message. */ scope: string; + /** The npm scope of the commit message. */ + npmScope: string; + /** The subject of the commit message. */ 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; + /** Whether the commit is a squash commit. */ isSquash: boolean; + /** Whether the commit is a revert commit. */ 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. */ 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. */ const SQUASH_PREFIX_RE = /^squash! /i; /** Regex determining if a commit is a revert. */ const REVERT_PREFIX_RE = /^revert:? /i; -/** Regex determining the scope of a commit if provided. */ -const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/; -/** Regex determining the entire header line of the commit. */ -const COMMIT_HEADER_RE = /^(.*)/i; -/** Regex determining the body of the commit. */ -const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/; +/** + * Regex pattern for parsing the header line of a commit. + * + * Several groups are being matched to be used in the parsed commit object, being mapped to the + * `headerCorrespondence` object. + * + * 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. */ -export function parseCommitMessage(commitMsg: string): ParsedCommitMessage { - // Ignore comments (i.e. lines starting with `#`). Comments are automatically removed by git and - // should not be considered part of the final commit message. - commitMsg = commitMsg.split('\n').filter(line => !line.startsWith('#')).join('\n'); +export function parseCommitMessage(fullText: string): Commit { + /** The commit message text with the fixup and squash markers stripped out. */ + const strippedCommitMsg = fullText.replace(FIXUP_PREFIX_RE, '') + .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 = ''; - let body = ''; - let bodyWithoutLinking = ''; - let type = ''; - let scope = ''; - let subject = ''; + // Extract the commit message notes by marked types into their respective lists. + commit.notes.forEach((note: ParsedCommit.Note) => { + if (note.title === NoteSections.BREAKING_CHANGE) { + return breakingChanges.push(note); + } + 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 { - header, - body, - bodyWithoutLinking, - type, - scope, - subject, - isFixup: FIXUP_PREFIX_RE.test(commitMsg), - isSquash: SQUASH_PREFIX_RE.test(commitMsg), - isRevert: REVERT_PREFIX_RE.test(commitMsg), + fullText, + breakingChanges, + deprecations, + body: commit.body || '', + footer: commit.footer || '', + header: commit.header || '', + references: commit.references, + scope: commit.scope || '', + 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. */ -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. */ const randomValueSeparator = `${Math.random()}`; /** diff --git a/dev-infra/commit-message/restore-commit-message/cli.ts b/dev-infra/commit-message/restore-commit-message/cli.ts index 6f4a2dacdc..09e0c8354d 100644 --- a/dev-infra/commit-message/restore-commit-message/cli.ts +++ b/dev-infra/commit-message/restore-commit-message/cli.ts @@ -8,7 +8,7 @@ import {Arguments, Argv, CommandModule} from 'yargs'; -import {CommitMsgSource} from '../commit-message-source'; +import {CommitMsgSource} from './commit-message-source'; import {restoreCommitMessage} from './restore-commit-message'; diff --git a/dev-infra/commit-message/commit-message-draft.ts b/dev-infra/commit-message/restore-commit-message/commit-message-draft.ts similarity index 100% rename from dev-infra/commit-message/commit-message-draft.ts rename to dev-infra/commit-message/restore-commit-message/commit-message-draft.ts diff --git a/dev-infra/commit-message/commit-message-source.ts b/dev-infra/commit-message/restore-commit-message/commit-message-source.ts similarity index 100% rename from dev-infra/commit-message/commit-message-source.ts rename to dev-infra/commit-message/restore-commit-message/commit-message-source.ts diff --git a/dev-infra/commit-message/restore-commit-message/restore-commit-message.ts b/dev-infra/commit-message/restore-commit-message/restore-commit-message.ts index 8008728d0b..d2551732be 100644 --- a/dev-infra/commit-message/restore-commit-message/restore-commit-message.ts +++ b/dev-infra/commit-message/restore-commit-message/restore-commit-message.ts @@ -10,8 +10,8 @@ import {writeFileSync} from 'fs'; import {debug, log} from '../../utils/console'; -import {loadCommitMessageDraft} from '../commit-message-draft'; -import {CommitMsgSource} from '../commit-message-source'; +import {loadCommitMessageDraft} from './commit-message-draft'; +import {CommitMsgSource} from './commit-message-source'; /** * Restore the commit message draft to the git to be used as the default commit message. diff --git a/dev-infra/commit-message/validate-file/validate-file.ts b/dev-infra/commit-message/validate-file/validate-file.ts index 459fa52fa9..965b0c6a5c 100644 --- a/dev-infra/commit-message/validate-file/validate-file.ts +++ b/dev-infra/commit-message/validate-file/validate-file.ts @@ -11,7 +11,7 @@ import {resolve} from 'path'; import {getRepoBaseDir} from '../../utils/config'; 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'; /** Validate commit message at the provided file path. */ diff --git a/dev-infra/commit-message/validate-range/validate-range.ts b/dev-infra/commit-message/validate-range/validate-range.ts index 56158e74de..226f5bd6d0 100644 --- a/dev-infra/commit-message/validate-range/validate-range.ts +++ b/dev-infra/commit-message/validate-range/validate-range.ts @@ -6,15 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ import {error, info} from '../../utils/console'; - -import {parseCommitMessagesForRange, ParsedCommitMessage} from '../parse'; +import {Commit, parseCommitMessagesForRange} from '../parse'; import {printValidationErrors, validateCommitMessage, ValidateCommitMessageOptions} from '../validate'; // 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). -const extractCommitHeader = (commit: ParsedCommitMessage) => commit.header; +const extractCommitHeader = (commit: Commit) => commit.header; /** Validate all commits in a provided git commit range. */ export function validateCommitRange(range: string) { diff --git a/dev-infra/commit-message/validate.ts b/dev-infra/commit-message/validate.ts index 3fa93c23fb..0aa30c5aa1 100644 --- a/dev-infra/commit-message/validate.ts +++ b/dev-infra/commit-message/validate.ts @@ -5,10 +5,11 @@ * 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 {error} from '../utils/console'; import {COMMIT_TYPES, getCommitMessageConfig, ScopeRequirement} from './config'; -import {parseCommitMessage, ParsedCommitMessage} from './parse'; +import {Commit, parseCommitMessage} from './parse'; /** Options for commit message validation. */ export interface ValidateCommitMessageOptions { @@ -20,7 +21,7 @@ export interface ValidateCommitMessageOptions { export interface ValidateCommitMessageResult { valid: boolean; errors: string[]; - commit: ParsedCommitMessage; + commit: Commit; } /** 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. */ export function validateCommitMessage( - commitMsg: string|ParsedCommitMessage, + commitMsg: string|Commit, options: ValidateCommitMessageOptions = {}): ValidateCommitMessageResult { const config = getCommitMessageConfig().commitMessage; const commit = typeof commitMsg === 'string' ? parseCommitMessage(commitMsg) : commitMsg; @@ -46,8 +47,6 @@ export function validateCommitMessage( /** Perform the validation checks against the parsed commit. */ function validateCommitAndCollectErrors() { - // TODO(josephperrott): Remove early return calls when commit message errors are found - //////////////////////////////////// // Checking revert, squash, fixup // //////////////////////////////////// @@ -133,14 +132,14 @@ export function validateCommitMessage( ////////////////////////// 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 ${ config.minBodyLength} characters`); return false; } 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 // 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); @@ -155,7 +154,7 @@ export function validateCommitMessage( // 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 - const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.body); + const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.fullText); if (hasBreakingChange !== null) { const [, breakingChangeDescription] = hasBreakingChange; if (!breakingChangeDescription) { diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js index 050e57cf01..20b77696bf 100755 --- a/dev-infra/ng-dev.js +++ b/dev-infra/ng-dev.js @@ -19,6 +19,7 @@ var fetch = _interopDefault(require('node-fetch')); var semver = require('semver'); var multimatch = require('multimatch'); var yaml = require('yaml'); +var conventionalCommitsParser = require('conventional-commits-parser'); var cliProgress = require('cli-progress'); var os = require('os'); 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 * 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. */ 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. */ const SQUASH_PREFIX_RE = /^squash! /i; /** Regex determining if a commit is a revert. */ const REVERT_PREFIX_RE = /^revert:? /i; -/** Regex determining the scope of a commit if provided. */ -const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/; -/** Regex determining the entire header line of the commit. */ -const COMMIT_HEADER_RE = /^(.*)/i; -/** Regex determining the body of the commit. */ -const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/; +/** + * Regex pattern for parsing the header line of a commit. + * + * Several groups are being matched to be used in the parsed commit object, being mapped to the + * `headerCorrespondence` object. + * + * 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. */ -function parseCommitMessage(commitMsg) { - // Ignore comments (i.e. lines starting with `#`). Comments are automatically removed by git and - // should not be considered part of the final commit message. - commitMsg = commitMsg.split('\n').filter(line => !line.startsWith('#')).join('\n'); - 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]; - } +function parseCommitMessage(fullText) { + /** The commit message text with the fixup and squash markers stripped out. */ + const strippedCommitMsg = fullText.replace(FIXUP_PREFIX_RE, '') + .replace(SQUASH_PREFIX_RE, '') + .replace(REVERT_PREFIX_RE, ''); + /** The initially parsed commit. */ + const commit = conventionalCommitsParser.sync(strippedCommitMsg, parseOptions); + /** A list of breaking change notes from the commit. */ + const breakingChanges = []; + /** A list of deprecation notes from the commit. */ + const deprecations = []; + // Extract the commit message notes by marked types into their respective lists. + commit.notes.forEach((note) => { + if (note.title === NoteSections.BREAKING_CHANGE) { + return breakingChanges.push(note); + } + if (note.title === NoteSections.DEPRECATED) { + return deprecations.push(note); + } + }); 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), + fullText, + breakingChanges, + deprecations, + body: commit.body || '', + footer: commit.footer || '', + header: commit.header || '', + references: commit.references, + scope: commit.scope || '', + 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. */ @@ -1786,11 +1815,10 @@ function validateCommitMessage(commitMsg, options = {}) { const errors = []; /** Perform the validation checks against the parsed commit. */ function validateCommitAndCollectErrors() { - // TODO(josephperrott): Remove early return calls when commit message errors are found - var _a; //////////////////////////////////// // Checking revert, squash, fixup // //////////////////////////////////// + var _a; // All revert commits are considered valid. if (commit.isRevert) { return true; @@ -1854,12 +1882,12 @@ function validateCommitMessage(commitMsg, options = {}) { // Checking commit body // ////////////////////////// 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`); return false; } 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 // 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); @@ -1871,7 +1899,7 @@ function validateCommitMessage(commitMsg, options = {}) { // 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 - const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.body); + const hasBreakingChange = COMMIT_BODY_BREAKING_CHANGE_RE.exec(commit.fullText); if (hasBreakingChange !== null) { const [, breakingChangeDescription] = hasBreakingChange; if (!breakingChangeDescription) { diff --git a/dev-infra/pr/rebase/BUILD.bazel b/dev-infra/pr/rebase/BUILD.bazel index 6b420d8f6f..13089d1cbf 100644 --- a/dev-infra/pr/rebase/BUILD.bazel +++ b/dev-infra/pr/rebase/BUILD.bazel @@ -11,9 +11,11 @@ ts_library( deps = [ "//dev-infra/commit-message", "//dev-infra/utils", + "@npm//@types/conventional-commits-parser", "@npm//@types/inquirer", "@npm//@types/node", "@npm//@types/yargs", + "@npm//conventional-commits-parser", "@npm//inquirer", "@npm//typed-graphqlify", "@npm//yargs", diff --git a/dev-infra/pr/rebase/index.ts b/dev-infra/pr/rebase/index.ts index 48aecbbc37..7fbe88517d 100644 --- a/dev-infra/pr/rebase/index.ts +++ b/dev-infra/pr/rebase/index.ts @@ -7,8 +7,8 @@ */ 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 {error, info, promptConfirm} from '../../utils/console'; import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls'; @@ -95,8 +95,7 @@ export async function rebasePr( const commits = parseCommitMessagesForRange(`${commonAncestorSha}..HEAD`); - let squashFixups = - commits.filter((commit: ParsedCommitMessage) => commit.isFixup).length === 0 ? + let squashFixups = commits.filter((commit: Commit) => commit.isFixup).length === 0 ? false : await promptConfirm( `PR #${prNumber} contains fixup commits, would you like to squash them during rebase?`, diff --git a/dev-infra/tmpl-package.json b/dev-infra/tmpl-package.json index f54f2820dc..5a283b205a 100644 --- a/dev-infra/tmpl-package.json +++ b/dev-infra/tmpl-package.json @@ -16,6 +16,7 @@ "brotli": "", "chalk": "", "cli-progress": "", + "conventional-commits-parser": "", "glob": "", "inquirer": "", "minimatch": "", diff --git a/package.json b/package.json index 5c1720f524..f247c1de53 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@babel/cli": "^7.8.4", "@babel/core": "^7.8.6", "@babel/generator": "^7.8.6", + "@babel/parser": "^7.0.0", "@babel/preset-env": "^7.10.2", "@babel/template": "^7.8.6", "@babel/traverse": "^7.8.6", @@ -61,7 +62,6 @@ "@microsoft/api-extractor": "7.7.11", "@octokit/rest": "16.28.7", "@octokit/types": "^5.0.1", - "tmp": "0.0.33", "@schematics/angular": "11.0.0-rc.1", "@types/angular": "^1.6.47", "@types/babel__core": "^7.1.6", @@ -73,14 +73,13 @@ "@types/chai": "^4.1.2", "@types/convert-source-map": "^1.5.1", "@types/diff": "^3.5.1", + "@types/events": "3.0.0", "@types/fs-extra": "4.0.2", "@types/hammerjs": "2.0.35", "@types/inquirer": "^7.3.0", "@types/jasmine": "3.5.10", "@types/jasmine-ajax": "^3.3.1", "@types/jasminewd2": "^2.0.8", - "@babel/parser": "^7.0.0", - "@types/events": "3.0.0", "@types/minimist": "^1.2.0", "@types/multimatch": "^4.0.0", "@types/node": "^12.11.1", @@ -151,6 +150,7 @@ "sourcemap-codec": "^1.4.8", "systemjs": "0.18.10", "terser": "^4.4.0", + "tmp": "0.0.33", "tsickle": "0.38.1", "tslib": "^2.1.0", "tslint": "6.1.3", @@ -167,6 +167,7 @@ "@bazel/ibazel": "^0.12.3", "@octokit/graphql": "^4.3.1", "@types/cli-progress": "^3.4.2", + "@types/conventional-commits-parser": "^3.0.1", "@types/minimist": "^1.2.0", "@yarnpkg/lockfile": "^1.1.0", "browserstacktunnel-wrapper": "^2.0.4", @@ -177,6 +178,7 @@ "cldrjs": "0.5.0", "cli-progress": "^3.7.0", "conventional-changelog": "^2.0.3", + "conventional-commits-parser": "^3.2.1", "entities": "1.1.1", "firebase-tools": "^7.11.0", "firefox-profile": "1.0.3", diff --git a/yarn.lock b/yarn.lock index 39af85d71c..b6ca40aff3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2447,6 +2447,13 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" 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": version "1.5.1" 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" 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: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"