Skip to content

Commit 4fe5758

Browse files
devversionjelbourn
authored andcommitted
build: group changelog entries by packages (#17017)
Resolves COMP-225
1 parent b24fdb6 commit 4fe5758

File tree

5 files changed

+230
-29
lines changed

5 files changed

+230
-29
lines changed

CONTRIBUTING.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,37 @@ we use the git commit messages to **generate the Angular Material change log**.
166166

167167
### Commit Message Format
168168
Each commit message consists of a **header**, a **body** and a **footer**. The header has a special
169-
format that includes a **type**, a **scope** and a **subject**:
169+
format that includes a **type**, a **package**, a **scope** and a **subject**:
170170

171171
```
172-
<type>(<scope>): <subject>
172+
<type>(<package>/<scope>): <subject>
173173
<BLANK LINE>
174174
<body>
175175
<BLANK LINE>
176176
<footer>
177177
```
178178

179-
The **header** is mandatory and the **scope** of the header is optional.
179+
The **header** is mandatory. For changes which are shown in the changelog (`fix`, `feat`,
180+
`perf` and `revert`), the **package** and **scope** fields are mandatory.
181+
182+
The `package` and `scope` fields can be omitted if the change does not affect a specific
183+
package and is not displayed in the changelog (e.g. build changes or refactorings).
180184

181185
Any line of the commit message cannot be longer 100 characters! This allows the message to be easier
182186
to read on GitHub as well as in various git tools.
183187

188+
Example:
189+
190+
```
191+
fix(material/button): unable to disable button through binding
192+
193+
Fixes a bug in the Angular Material `button` component where buttons
194+
cannot be disabled through an binding. This is because the `disabled`
195+
input did not set the `.mat-button-disabled` class on the host element.
196+
197+
Fixes #1234
198+
```
199+
184200
### Revert
185201
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of
186202
the reverted commit. In the body it should say: `This reverts commit <hash>.`, where the hash is
@@ -201,6 +217,10 @@ Must be one of the following:
201217
(example scopes: gulp, broccoli, npm)
202218
* **chore**: Other changes that don't modify `src` or `test` files
203219
220+
### Package
221+
The commit message should specify which package is affected by the change. For example:
222+
`material`, `cdk-experimental`, etc.
223+
204224
### Scope
205225
The scope could be anything specifying place of the commit change. For example
206226
`datepicker`, `dialog`, etc.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"stage-release": "ts-node --project tools/release/ tools/release/stage-release.ts",
3030
"publish-release": "ts-node --project tools/release/ tools/release/publish-release.ts",
3131
"check-release-output": "ts-node --project tools/release tools/release/check-release-output.ts",
32+
"changelog": "ts-node --project tools/release tools/release/changelog.ts",
3233
"preinstall": "node ./tools/npm/check-npm.js",
3334
"format:ts": "git-clang-format HEAD $(git diff HEAD --name-only | grep -v \"\\.d\\.ts\")",
3435
"format:bazel": "yarn -s bazel:buildifier --lint=fix --mode=fix",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{{#if scope}} **{{scope}}:**
2+
{{~/if}} {{#if subject}}
3+
{{~subject}}
4+
{{~else}}
5+
{{~header}}
6+
{{~/if}}
7+
8+
{{~!-- commit link --}} {{#if @root.linkReferences~}}
9+
([{{hash}}](
10+
{{~#if @root.repository}}
11+
{{~#if @root.host}}
12+
{{~@root.host}}/
13+
{{~/if}}
14+
{{~#if @root.owner}}
15+
{{~@root.owner}}/
16+
{{~/if}}
17+
{{~@root.repository}}
18+
{{~else}}
19+
{{~@root.repoUrl}}
20+
{{~/if}}/
21+
{{~@root.commit}}/{{hash}}))
22+
{{~else}}
23+
{{~hash}}
24+
{{~/if}}
25+
26+
{{~!-- commit references --}}
27+
{{~#if references~}}
28+
, closes
29+
{{~#each references}} {{#if @root.linkReferences~}}
30+
[
31+
{{~#if this.owner}}
32+
{{~this.owner}}/
33+
{{~/if}}
34+
{{~this.repository}}#{{this.issue}}](
35+
{{~#if @root.repository}}
36+
{{~#if @root.host}}
37+
{{~@root.host}}/
38+
{{~/if}}
39+
{{~#if this.repository}}
40+
{{~#if this.owner}}
41+
{{~this.owner}}/
42+
{{~/if}}
43+
{{~this.repository}}
44+
{{~else}}
45+
{{~#if @root.owner}}
46+
{{~@root.owner}}/
47+
{{~/if}}
48+
{{~@root.repository}}
49+
{{~/if}}
50+
{{~else}}
51+
{{~@root.repoUrl}}
52+
{{~/if}}/
53+
{{~@root.issue}}/{{this.issue}})
54+
{{~else}}
55+
{{~#if this.owner}}
56+
{{~this.owner}}/
57+
{{~/if}}
58+
{{~this.repository}}#{{this.issue}}
59+
{{~/if}}{{/each}}
60+
{{~/if}}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{{> header}}
2+
3+
{{#each packageGroups}}
4+
### {{title}}
5+
6+
{{#if breakingChanges.length}}
7+
_Breaking changes:_
8+
9+
{{#each breakingChanges}}
10+
* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}}
11+
{{/each}}
12+
13+
{{/if}}
14+
| | |
15+
| ---------- | --------------------- |
16+
{{#each commits}}
17+
| {{type}} | {{> commit root=@root}} |
18+
{{/each}}
19+
20+
{{/each}}
21+

tools/release/changelog.ts

Lines changed: 125 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,33 @@ import {createReadStream, createWriteStream, readFileSync} from 'fs';
33
import {prompt} from 'inquirer';
44
import {join} from 'path';
55
import {Readable} from 'stream';
6+
import {releasePackages} from './release-output/release-packages';
67

78
// These imports lack type definitions.
89
const conventionalChangelog = require('conventional-changelog');
10+
const changelogCompare = require('conventional-changelog-writer/lib/util');
911
const merge2 = require('merge2');
1012

13+
/** Interface that describes a package in the changelog. */
14+
interface ChangelogPackage {
15+
commits: any[];
16+
breakingChanges: any[];
17+
}
18+
19+
/** Hardcoded order of packages shown in the changelog. */
20+
const changelogPackageOrder = [
21+
'cdk',
22+
'material',
23+
'google-maps',
24+
'youtube-player',
25+
'material-moment-adapter',
26+
'cdk-experimental',
27+
'material-experimental',
28+
];
29+
30+
/** List of packages which are excluded in the changelog. */
31+
const excludedChangelogPackages = ['google-maps'];
32+
1133
/** Prompts for a changelog release name and prepends the new changelog. */
1234
export async function promptAndGenerateChangelog(changelogPath: string) {
1335
const releaseName = await promptChangelogReleaseName();
@@ -21,11 +43,16 @@ export async function promptAndGenerateChangelog(changelogPath: string) {
2143
*/
2244
export async function prependChangelogFromLatestTag(changelogPath: string, releaseName: string) {
2345
const outputStream: Readable = conventionalChangelog(
24-
/* core options */ {preset: 'angular'},
25-
/* context options */ {title: releaseName},
26-
/* raw-commits options */ null,
27-
/* commit parser options */ null,
28-
/* writer options */ createChangelogWriterOptions(changelogPath));
46+
/* core options */ {preset: 'angular'},
47+
/* context options */ {title: releaseName},
48+
/* raw-commits options */ null,
49+
/* commit parser options */ {
50+
// Expansion of the convention-changelog-angular preset to extract the package
51+
// name from the commit message.
52+
headerPattern: /^(\w*)(?:\((?:([^/]+)\/)?(.*)\))?: (.*)$/,
53+
headerCorrespondence: ['type', 'package', 'scope', 'subject'],
54+
},
55+
/* writer options */ createChangelogWriterOptions(changelogPath));
2956

3057
// Stream for reading the existing changelog. This is necessary because we want to
3158
// actually prepend the new changelog to the existing one.
@@ -41,19 +68,20 @@ export async function prependChangelogFromLatestTag(changelogPath: string, relea
4168
// read and write from the same source which causes the content to be thrown off.
4269
previousChangelogStream.on('end', () => {
4370
mergedCompleteChangelog.pipe(createWriteStream(changelogPath))
44-
.once('error', (error: any) => reject(error))
45-
.once('finish', () => resolve());
71+
.once('error', (error: any) => reject(error))
72+
.once('finish', () => resolve());
4673
});
4774
});
4875
}
4976

5077
/** Prompts the terminal for a changelog release name. */
5178
export async function promptChangelogReleaseName(): Promise<string> {
5279
return (await prompt<{releaseName: string}>({
53-
type: 'text',
54-
name: 'releaseName',
55-
message: 'What should be the name of the release?'
56-
})).releaseName;
80+
type: 'text',
81+
name: 'releaseName',
82+
message: 'What should be the name of the release?'
83+
}))
84+
.releaseName;
5785
}
5886

5987
/**
@@ -68,45 +96,116 @@ export async function promptChangelogReleaseName(): Promise<string> {
6896
*/
6997
function createChangelogWriterOptions(changelogPath: string) {
7098
const existingChangelogContent = readFileSync(changelogPath, 'utf8');
99+
const commitSortFunction = changelogCompare.functionify(['type', 'scope', 'subject']);
71100

72101
return {
102+
// Overwrite the changelog templates so that we can render the commits grouped
103+
// by package names. Templates are based on the original templates of the
104+
// angular preset: "conventional-changelog-angular/templates".
105+
mainTemplate: readFileSync(join(__dirname, 'changelog-root-template.hbs'), 'utf8'),
106+
commitPartial: readFileSync(join(__dirname, 'changelog-commit-template.hbs'), 'utf8'),
107+
73108
// Specify a writer option that can be used to modify the content of a new changelog section.
74109
// See: conventional-changelog/tree/master/packages/conventional-changelog-writer
75110
finalizeContext: (context: any) => {
76-
context.commitGroups = context.commitGroups.filter((group: any) => {
77-
group.commits = group.commits.filter((commit: any) => {
78-
79-
// Commits that change things for "cdk-experimental" or "material-experimental" will also
80-
// show up in the changelog by default. We don't want to show these in the changelog.
81-
if (commit.scope && commit.scope.includes('experimental')) {
82-
console.log(yellow(` ↺ Skipping experimental: "${bold(commit.header)}"`));
83-
return false;
84-
}
111+
const packageGroups: {[packageName: string]: ChangelogPackage} = {};
85112

113+
context.commitGroups.forEach((group: any) => {
114+
group.commits.forEach((commit: any) => {
86115
// Filter out duplicate commits. Note that we cannot compare the SHA because the commits
87116
// will have a different SHA if they are being cherry-picked into a different branch.
88117
if (existingChangelogContent.includes(commit.subject)) {
89118
console.log(yellow(` ↺ Skipping duplicate: "${bold(commit.header)}"`));
90119
return false;
91120
}
92-
return true;
121+
122+
// Commits which just specify a scope that refers to a package but do not follow
123+
// the commit format that is parsed by the conventional-changelog-parser, can be
124+
// still resolved to their package from the scope. This handles the case where
125+
// a commit targets the whole package and does not specify a specific scope.
126+
// e.g. "refactor(material-experimental): support strictness flags".
127+
if (!commit.package && commit.scope) {
128+
const matchingPackage = releasePackages.find(pkgName => pkgName === commit.scope);
129+
if (matchingPackage) {
130+
commit.scope = null;
131+
commit.package = matchingPackage;
132+
}
133+
}
134+
135+
// TODO(devversion): once we formalize the commit message format and
136+
// require specifying the "material" package explicitly, we can remove
137+
// the fallback to the "material" package.
138+
const packageName = commit.package || 'material';
139+
const type = getTypeOfCommitGroupDescription(group.title);
140+
141+
if (!packageGroups[packageName]) {
142+
packageGroups[packageName] = {commits: [], breakingChanges: []};
143+
}
144+
const packageGroup = packageGroups[packageName];
145+
146+
packageGroup.breakingChanges.push(...commit.notes);
147+
packageGroup.commits.push({...commit, type});
93148
});
149+
});
94150

95-
// Filter out commit groups which don't have any commits. Commit groups will become
96-
// empty if we filter out all duplicated commits.
97-
return group.commits.length;
151+
const sortedPackageGroupNames =
152+
Object.keys(packageGroups)
153+
.filter(pkgName => !excludedChangelogPackages.includes(pkgName))
154+
.sort(preferredOrderComparator);
155+
156+
context.packageGroups = sortedPackageGroupNames.map(pkgName => {
157+
const packageGroup = packageGroups[pkgName];
158+
return {
159+
title: pkgName,
160+
commits: packageGroup.commits.sort(commitSortFunction),
161+
breakingChanges: packageGroup.breakingChanges,
162+
};
98163
});
99164

100165
return context;
101166
}
102167
};
103168
}
104169

170+
/**
171+
* Comparator function that sorts a given array of strings based on the
172+
* hardcoded changelog package order. Entries which are not hardcoded are
173+
* sorted in alphabetical order after the hardcoded entries.
174+
*/
175+
function preferredOrderComparator(a: string, b: string): number {
176+
const aIndex = changelogPackageOrder.indexOf(a);
177+
const bIndex = changelogPackageOrder.indexOf(b);
178+
// If a package name could not be found in the hardcoded order, it should be
179+
// sorted after the hardcoded entries in alphabetical order.
180+
if (aIndex === -1) {
181+
return bIndex === -1 ? a.localeCompare(b) : 1;
182+
} else if (bIndex === -1) {
183+
return -1;
184+
}
185+
return aIndex - bIndex;
186+
}
187+
188+
/** Gets the type of a commit group description. */
189+
function getTypeOfCommitGroupDescription(description: string): string {
190+
if (description === 'Features') {
191+
return 'feature';
192+
} else if (description === 'Bug Fixes') {
193+
return 'bug fix';
194+
} else if (description === 'Performance Improvements') {
195+
return 'performance';
196+
} else if (description === 'Reverts') {
197+
return 'revert';
198+
} else if (description === 'Documentation') {
199+
return 'docs';
200+
} else if (description === 'Code Refactoring') {
201+
return 'refactor';
202+
}
203+
return description.toLowerCase();
204+
}
205+
105206
/** Entry-point for generating the changelog when called through the CLI. */
106207
if (require.main === module) {
107208
promptAndGenerateChangelog(join(__dirname, '../../CHANGELOG.md')).then(() => {
108209
console.log(green(' ✓ Successfully updated the changelog.'));
109210
});
110211
}
111-
112-

0 commit comments

Comments
 (0)