Joey Perrott 0516fbb180 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
2021-03-23 13:10:47 -07:00

156 lines
5.8 KiB
TypeScript

/**
* @license
* Copyright Google LLC 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 {Commit as ParsedCommit, Options, sync as parse} from 'conventional-commits-parser';
import {exec} from '../utils/shelljs';
/** 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;
/** 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 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 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(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[] = [];
// 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);
}
});
return {
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): Commit[] {
/** A random number used as a split point in the git log result. */
const randomValueSeparator = `${Math.random()}`;
/**
* Custom git log format that provides the commit header and body, separated as expected with the
* custom separator as the trailing value.
*/
const gitLogFormat = `%s%n%n%b${randomValueSeparator}`;
// Retrieve the commits in the provided range.
const result = exec(`git log --reverse --format=${gitLogFormat} ${range}`);
if (result.code) {
throw new Error(`Failed to get all commits in the range:\n ${result.stderr}`);
}
return result
// Separate the commits from a single string into individual commits.
.split(randomValueSeparator)
// Remove extra space before and after each commit message.
.map(l => l.trim())
// Remove any superfluous lines which remain from the split.
.filter(line => !!line)
// Parse each commit message.
.map(commit => parseCommitMessage(commit));
}