Skip to content

Commit 0516fbb

Browse files
committed
refactor(dev-infra): use conventional-commits-parser for commit parsing (angular#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 angular#41286
1 parent eba1289 commit 0516fbb

File tree

16 files changed

+304
-131
lines changed

16 files changed

+304
-131
lines changed

dev-infra/commit-message/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ ts_library(
1111
visibility = ["//dev-infra:__subpackages__"],
1212
deps = [
1313
"//dev-infra/utils",
14+
"@npm//@types/conventional-commits-parser",
1415
"@npm//@types/inquirer",
1516
"@npm//@types/node",
1617
"@npm//@types/shelljs",
1718
"@npm//@types/yargs",
19+
"@npm//conventional-commits-parser",
1820
"@npm//inquirer",
1921
"@npm//shelljs",
2022
"@npm//yargs",

dev-infra/commit-message/parse.spec.ts

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,40 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {parseCommitMessage, ParsedCommitMessage} from './parse';
9+
import {parseCommitMessage} from './parse';
1010

1111

1212
const commitValues = {
1313
prefix: '',
1414
type: 'fix',
15+
npmScope: '',
1516
scope: 'changed-area',
1617
summary: 'This is a short summary of the change',
17-
body: 'This is a longer description of the change Closes #1',
18+
body: 'This is a longer description of the change',
19+
footer: 'Closes #1',
1820
};
1921

20-
function buildCommitMessage(params = {}) {
21-
const {prefix, type, scope, summary, body} = {...commitValues, ...params};
22-
return `${prefix}${type}${scope ? '(' + scope + ')' : ''}: ${summary}\n\n${body}`;
22+
function buildCommitMessage(params: Partial<typeof commitValues> = {}) {
23+
const {prefix, npmScope, type, scope, summary, body, footer} = {...commitValues, ...params};
24+
const scopeSlug = npmScope ? `${npmScope}/${scope}` : scope;
25+
return `${prefix}${type}${scopeSlug ? '(' + scopeSlug + ')' : ''}: ${summary}\n\n${body}\n\n${
26+
footer}`;
2327
}
2428

2529

2630
describe('commit message parsing:', () => {
27-
it('parses the scope', () => {
28-
const message = buildCommitMessage();
29-
expect(parseCommitMessage(message).scope).toBe(commitValues.scope);
31+
describe('parses the scope', () => {
32+
it('when only a scope is defined', () => {
33+
const message = buildCommitMessage();
34+
expect(parseCommitMessage(message).scope).toBe(commitValues.scope);
35+
expect(parseCommitMessage(message).npmScope).toBe('');
36+
});
37+
38+
it('when an npmScope and scope are defined', () => {
39+
const message = buildCommitMessage({npmScope: 'myNpmPackage'});
40+
expect(parseCommitMessage(message).scope).toBe(commitValues.scope);
41+
expect(parseCommitMessage(message).npmScope).toBe('myNpmPackage');
42+
});
3043
});
3144

3245
it('parses the type', () => {
@@ -45,12 +58,6 @@ describe('commit message parsing:', () => {
4558
expect(parseCommitMessage(message).body).toBe(commitValues.body);
4659
});
4760

48-
it('parses the body without Github linking', () => {
49-
const body = 'This has linking\nCloses #1';
50-
const message = buildCommitMessage({body});
51-
expect(parseCommitMessage(message).bodyWithoutLinking).toBe('This has linking\n');
52-
});
53-
5461
it('parses the subject', () => {
5562
const message = buildCommitMessage();
5663
expect(parseCommitMessage(message).subject).toBe(commitValues.summary);
@@ -100,6 +107,71 @@ describe('commit message parsing:', () => {
100107
expect(parsedMessage.body)
101108
.toBe(
102109
'This is line 1 of the actual body.\n' +
103-
'This is line 2 of the actual body (and it also contains a # but it not a comment).\n');
110+
'This is line 2 of the actual body (and it also contains a # but it not a comment).');
111+
});
112+
113+
describe('parses breaking change notes', () => {
114+
const summary = 'This breaks things';
115+
const description = 'This is how it breaks things.';
116+
117+
it('when only a summary is provided', () => {
118+
const message = buildCommitMessage({
119+
footer: `BREAKING CHANGE: ${summary}`,
120+
});
121+
const parsedMessage = parseCommitMessage(message);
122+
expect(parsedMessage.breakingChanges[0].text).toBe(summary);
123+
expect(parsedMessage.breakingChanges.length).toBe(1);
124+
});
125+
126+
it('when only a description is provided', () => {
127+
const message = buildCommitMessage({
128+
footer: `BREAKING CHANGE:\n\n${description}`,
129+
});
130+
const parsedMessage = parseCommitMessage(message);
131+
expect(parsedMessage.breakingChanges[0].text).toBe(description);
132+
expect(parsedMessage.breakingChanges.length).toBe(1);
133+
});
134+
135+
it('when a summary and description are provied', () => {
136+
const message = buildCommitMessage({
137+
footer: `BREAKING CHANGE: ${summary}\n\n${description}`,
138+
});
139+
const parsedMessage = parseCommitMessage(message);
140+
expect(parsedMessage.breakingChanges[0].text).toBe(`${summary}\n\n${description}`);
141+
expect(parsedMessage.breakingChanges.length).toBe(1);
142+
});
143+
});
144+
145+
describe('parses deprecation notes', () => {
146+
const summary = 'This will break things later';
147+
const description = 'This is a long winded explanation of why it \nwill break things later.';
148+
149+
150+
it('when only a summary is provided', () => {
151+
const message = buildCommitMessage({
152+
footer: `DEPRECATED: ${summary}`,
153+
});
154+
const parsedMessage = parseCommitMessage(message);
155+
expect(parsedMessage.deprecations[0].text).toBe(summary);
156+
expect(parsedMessage.deprecations.length).toBe(1);
157+
});
158+
159+
it('when only a description is provided', () => {
160+
const message = buildCommitMessage({
161+
footer: `DEPRECATED:\n\n${description}`,
162+
});
163+
const parsedMessage = parseCommitMessage(message);
164+
expect(parsedMessage.deprecations[0].text).toBe(description);
165+
expect(parsedMessage.deprecations.length).toBe(1);
166+
});
167+
168+
it('when a summary and description are provied', () => {
169+
const message = buildCommitMessage({
170+
footer: `DEPRECATED: ${summary}\n\n${description}`,
171+
});
172+
const parsedMessage = parseCommitMessage(message);
173+
expect(parsedMessage.deprecations[0].text).toBe(`${summary}\n\n${description}`);
174+
expect(parsedMessage.deprecations.length).toBe(1);
175+
});
104176
});
105177
});

dev-infra/commit-message/parse.ts

Lines changed: 96 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,80 +6,129 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {Commit as ParsedCommit, Options, sync as parse} from 'conventional-commits-parser';
10+
911
import {exec} from '../utils/shelljs';
1012

11-
/** A parsed commit message. */
12-
export interface ParsedCommitMessage {
13+
14+
/** A parsed commit, containing the information needed to validate the commit. */
15+
export interface Commit {
16+
/** The full raw text of the commit. */
17+
fullText: string;
18+
/** The header line of the commit, will be used in the changelog entries. */
1319
header: string;
20+
/** The full body of the commit, not including the footer. */
1421
body: string;
15-
bodyWithoutLinking: string;
22+
/** The footer of the commit, containing issue references and note sections. */
23+
footer: string;
24+
/** A list of the references to other issues made throughout the commit message. */
25+
references: ParsedCommit.Reference[];
26+
/** The type of the commit message. */
1627
type: string;
28+
/** The scope of the commit message. */
1729
scope: string;
30+
/** The npm scope of the commit message. */
31+
npmScope: string;
32+
/** The subject of the commit message. */
1833
subject: string;
34+
/** A list of breaking change notes in the commit message. */
35+
breakingChanges: ParsedCommit.Note[];
36+
/** A list of deprecation notes in the commit message. */
37+
deprecations: ParsedCommit.Note[];
38+
/** Whether the commit is a fixup commit. */
1939
isFixup: boolean;
40+
/** Whether the commit is a squash commit. */
2041
isSquash: boolean;
42+
/** Whether the commit is a revert commit. */
2143
isRevert: boolean;
2244
}
2345

46+
/** Markers used to denote the start of a note section in a commit. */
47+
enum NoteSections {
48+
BREAKING_CHANGE = 'BREAKING CHANGE',
49+
DEPRECATED = 'DEPRECATED',
50+
}
2451
/** Regex determining if a commit is a fixup. */
2552
const FIXUP_PREFIX_RE = /^fixup! /i;
26-
/** Regex finding all github keyword links. */
27-
const GITHUB_LINKING_RE = /((closed?s?)|(fix(es)?(ed)?)|(resolved?s?))\s\#(\d+)/ig;
2853
/** Regex determining if a commit is a squash. */
2954
const SQUASH_PREFIX_RE = /^squash! /i;
3055
/** Regex determining if a commit is a revert. */
3156
const REVERT_PREFIX_RE = /^revert:? /i;
32-
/** Regex determining the scope of a commit if provided. */
33-
const TYPE_SCOPE_RE = /^(\w+)(?:\(([^)]+)\))?\:\s(.+)$/;
34-
/** Regex determining the entire header line of the commit. */
35-
const COMMIT_HEADER_RE = /^(.*)/i;
36-
/** Regex determining the body of the commit. */
37-
const COMMIT_BODY_RE = /^.*\n\n([\s\S]*)$/;
57+
/**
58+
* Regex pattern for parsing the header line of a commit.
59+
*
60+
* Several groups are being matched to be used in the parsed commit object, being mapped to the
61+
* `headerCorrespondence` object.
62+
*
63+
* The pattern can be broken down into component parts:
64+
* - `(\w+)` - a capturing group discovering the type of the commit.
65+
* - `(?:\((?:([^/]+)\/)?([^)]+)\))?` - a pair of capturing groups to capture the scope and,
66+
* optionally the npmScope of the commit.
67+
* - `(.*)` - a capturing group discovering the subject of the commit.
68+
*/
69+
const headerPattern = /^(\w+)(?:\((?:([^/]+)\/)?([^)]+)\))?: (.*)$/;
70+
/**
71+
* The property names used for the values extracted from the header via the `headerPattern` regex.
72+
*/
73+
const headerCorrespondence = ['type', 'npmScope', 'scope', 'subject'];
74+
/**
75+
* Configuration options for the commit parser.
76+
*
77+
* NOTE: An extended type from `Options` must be used because the current
78+
* @types/conventional-commits-parser version does not include the `notesPattern` field.
79+
*/
80+
const parseOptions: Options&{notesPattern: (keywords: string) => RegExp} = {
81+
commentChar: '#',
82+
headerPattern,
83+
headerCorrespondence,
84+
noteKeywords: [NoteSections.BREAKING_CHANGE, NoteSections.DEPRECATED],
85+
notesPattern: (keywords: string) => new RegExp(`(${keywords})(?:: ?)(.*)`),
86+
};
3887

39-
/** Parse a full commit message into its composite parts. */
40-
export function parseCommitMessage(commitMsg: string): ParsedCommitMessage {
41-
// Ignore comments (i.e. lines starting with `#`). Comments are automatically removed by git and
42-
// should not be considered part of the final commit message.
43-
commitMsg = commitMsg.split('\n').filter(line => !line.startsWith('#')).join('\n');
4488

45-
let header = '';
46-
let body = '';
47-
let bodyWithoutLinking = '';
48-
let type = '';
49-
let scope = '';
50-
let subject = '';
89+
/** Parse a full commit message into its composite parts. */
90+
export function parseCommitMessage(fullText: string): Commit {
91+
/** The commit message text with the fixup and squash markers stripped out. */
92+
const strippedCommitMsg = fullText.replace(FIXUP_PREFIX_RE, '')
93+
.replace(SQUASH_PREFIX_RE, '')
94+
.replace(REVERT_PREFIX_RE, '');
95+
/** The initially parsed commit. */
96+
const commit = parse(strippedCommitMsg, parseOptions);
97+
/** A list of breaking change notes from the commit. */
98+
const breakingChanges: ParsedCommit.Note[] = [];
99+
/** A list of deprecation notes from the commit. */
100+
const deprecations: ParsedCommit.Note[] = [];
51101

52-
if (COMMIT_HEADER_RE.test(commitMsg)) {
53-
header = COMMIT_HEADER_RE.exec(commitMsg)![1]
54-
.replace(FIXUP_PREFIX_RE, '')
55-
.replace(SQUASH_PREFIX_RE, '');
56-
}
57-
if (COMMIT_BODY_RE.test(commitMsg)) {
58-
body = COMMIT_BODY_RE.exec(commitMsg)![1];
59-
bodyWithoutLinking = body.replace(GITHUB_LINKING_RE, '');
60-
}
102+
// Extract the commit message notes by marked types into their respective lists.
103+
commit.notes.forEach((note: ParsedCommit.Note) => {
104+
if (note.title === NoteSections.BREAKING_CHANGE) {
105+
return breakingChanges.push(note);
106+
}
107+
if (note.title === NoteSections.DEPRECATED) {
108+
return deprecations.push(note);
109+
}
110+
});
61111

62-
if (TYPE_SCOPE_RE.test(header)) {
63-
const parsedCommitHeader = TYPE_SCOPE_RE.exec(header)!;
64-
type = parsedCommitHeader[1];
65-
scope = parsedCommitHeader[2];
66-
subject = parsedCommitHeader[3];
67-
}
68112
return {
69-
header,
70-
body,
71-
bodyWithoutLinking,
72-
type,
73-
scope,
74-
subject,
75-
isFixup: FIXUP_PREFIX_RE.test(commitMsg),
76-
isSquash: SQUASH_PREFIX_RE.test(commitMsg),
77-
isRevert: REVERT_PREFIX_RE.test(commitMsg),
113+
fullText,
114+
breakingChanges,
115+
deprecations,
116+
body: commit.body || '',
117+
footer: commit.footer || '',
118+
header: commit.header || '',
119+
references: commit.references,
120+
scope: commit.scope || '',
121+
subject: commit.subject || '',
122+
type: commit.type || '',
123+
npmScope: commit.npmScope || '',
124+
isFixup: FIXUP_PREFIX_RE.test(fullText),
125+
isSquash: SQUASH_PREFIX_RE.test(fullText),
126+
isRevert: REVERT_PREFIX_RE.test(fullText),
78127
};
79128
}
80129

81130
/** Retrieve and parse each commit message in a provide range. */
82-
export function parseCommitMessagesForRange(range: string): ParsedCommitMessage[] {
131+
export function parseCommitMessagesForRange(range: string): Commit[] {
83132
/** A random number used as a split point in the git log result. */
84133
const randomValueSeparator = `${Math.random()}`;
85134
/**

dev-infra/commit-message/restore-commit-message/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {Arguments, Argv, CommandModule} from 'yargs';
1010

11-
import {CommitMsgSource} from '../commit-message-source';
11+
import {CommitMsgSource} from './commit-message-source';
1212

1313
import {restoreCommitMessage} from './restore-commit-message';
1414

dev-infra/commit-message/restore-commit-message/restore-commit-message.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {writeFileSync} from 'fs';
1010

1111
import {debug, log} from '../../utils/console';
1212

13-
import {loadCommitMessageDraft} from '../commit-message-draft';
14-
import {CommitMsgSource} from '../commit-message-source';
13+
import {loadCommitMessageDraft} from './commit-message-draft';
14+
import {CommitMsgSource} from './commit-message-source';
1515

1616
/**
1717
* Restore the commit message draft to the git to be used as the default commit message.

dev-infra/commit-message/validate-file/validate-file.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {resolve} from 'path';
1111
import {getRepoBaseDir} from '../../utils/config';
1212
import {error, green, info, log, red, yellow} from '../../utils/console';
1313

14-
import {deleteCommitMessageDraft, saveCommitMessageDraft} from '../commit-message-draft';
14+
import {deleteCommitMessageDraft, saveCommitMessageDraft} from '../restore-commit-message/commit-message-draft';
1515
import {printValidationErrors, validateCommitMessage} from '../validate';
1616

1717
/** Validate commit message at the provided file path. */

dev-infra/commit-message/validate-range/validate-range.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import {error, info} from '../../utils/console';
9-
10-
import {parseCommitMessagesForRange, ParsedCommitMessage} from '../parse';
9+
import {Commit, parseCommitMessagesForRange} from '../parse';
1110
import {printValidationErrors, validateCommitMessage, ValidateCommitMessageOptions} from '../validate';
1211

1312
// Whether the provided commit is a fixup commit.
14-
const isNonFixup = (commit: ParsedCommitMessage) => !commit.isFixup;
13+
const isNonFixup = (commit: Commit) => !commit.isFixup;
1514

1615
// Extracts commit header (first line of commit message).
17-
const extractCommitHeader = (commit: ParsedCommitMessage) => commit.header;
16+
const extractCommitHeader = (commit: Commit) => commit.header;
1817

1918
/** Validate all commits in a provided git commit range. */
2019
export function validateCommitRange(range: string) {

0 commit comments

Comments
 (0)