angular-cn/dev-infra/commit-message/parse.ts

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));
}