-
Notifications
You must be signed in to change notification settings - Fork 6.7k
/
changelog.ts
211 lines (187 loc) · 8.9 KB
/
changelog.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import chalk from 'chalk';
import {createReadStream, createWriteStream, readFileSync} from 'fs';
import {prompt} from 'inquirer';
import {join} from 'path';
import {Readable} from 'stream';
// These imports lack type definitions.
const conventionalChangelog = require('conventional-changelog');
const changelogCompare = require('conventional-changelog-writer/lib/util');
const merge2 = require('merge2');
/** Interface that describes a package in the changelog. */
interface ChangelogPackage {
commits: any[];
breakingChanges: any[];
}
/** Hardcoded order of packages shown in the changelog. */
const orderedChangelogPackages = [
'cdk',
'material',
'google-maps',
'youtube-player',
'material-moment-adapter',
'cdk-experimental',
'material-experimental',
];
/** List of packages which are excluded in the changelog. */
const excludedChangelogPackages = [];
/** Prompts for a changelog release name and prepends the new changelog. */
export async function promptAndGenerateChangelog(changelogPath: string) {
const releaseName = await promptChangelogReleaseName();
await prependChangelogFromLatestTag(changelogPath, releaseName);
}
/**
* Writes the changelog from the latest Semver tag to the current HEAD.
* @param changelogPath Path to the changelog file.
* @param releaseName Name of the release that should show up in the changelog.
*/
export async function prependChangelogFromLatestTag(changelogPath: string, releaseName: string) {
const outputStream: Readable = conventionalChangelog(
/* core options */ {preset: 'angular'},
/* context options */ {title: releaseName},
/* raw-commits options */ null,
/* commit parser options */ {
// Expansion of the convention-changelog-angular preset to extract the package
// name from the commit message.
headerPattern: /^(\w*)(?:\((?:([^/]+)\/)?(.*)\))?: (.*)$/,
headerCorrespondence: ['type', 'package', 'scope', 'subject'],
},
/* writer options */ createChangelogWriterOptions(changelogPath));
// Stream for reading the existing changelog. This is necessary because we want to
// actually prepend the new changelog to the existing one.
const previousChangelogStream = createReadStream(changelogPath);
return new Promise((resolve, reject) => {
// Sequentially merge the changelog output and the previous changelog stream, so that
// the new changelog section comes before the existing versions. Afterwards, pipe into the
// changelog file, so that the changes are reflected on file system.
const mergedCompleteChangelog = merge2(outputStream, previousChangelogStream);
// Wait for the previous changelog to be completely read because otherwise we would
// read and write from the same source which causes the content to be thrown off.
previousChangelogStream.on('end', () => {
mergedCompleteChangelog.pipe(createWriteStream(changelogPath))
.once('error', (error: any) => reject(error))
.once('finish', () => resolve());
});
});
}
/** Prompts the terminal for a changelog release name. */
export async function promptChangelogReleaseName(): Promise<string> {
return (await prompt<{releaseName: string}>({
type: 'text',
name: 'releaseName',
message: 'What should be the name of the release?'
}))
.releaseName;
}
/**
* Creates changelog writer options which ensure that commits which are duplicated, or for
* experimental packages do not showing up multiple times. Commits can show up multiple times
* if a changelog has been generated on a publish branch and has been cherry-picked into "master".
* In that case, the changelog will already contain cherry-picked commits from master which might
* be added to future changelog's on "master" again. This is because usually patch and minor
* releases are tagged from the publish branches and therefore conventional-changelog tries to
* build the changelog from last major version to master's HEAD when a new major version is being
* published from the "master" branch.
*/
function createChangelogWriterOptions(changelogPath: string) {
const existingChangelogContent = readFileSync(changelogPath, 'utf8');
const commitSortFunction = changelogCompare.functionify(['type', 'scope', 'subject']);
const allPackages = [...orderedChangelogPackages, ...excludedChangelogPackages];
return {
// Overwrite the changelog templates so that we can render the commits grouped
// by package names. Templates are based on the original templates of the
// angular preset: "conventional-changelog-angular/templates".
mainTemplate: readFileSync(join(__dirname, 'changelog-root-template.hbs'), 'utf8'),
commitPartial: readFileSync(join(__dirname, 'changelog-commit-template.hbs'), 'utf8'),
// Specify a writer option that can be used to modify the content of a new changelog section.
// See: conventional-changelog/tree/master/packages/conventional-changelog-writer
finalizeContext: (context: any) => {
const packageGroups: {[packageName: string]: ChangelogPackage} = {};
context.commitGroups.forEach((group: any) => {
group.commits.forEach((commit: any) => {
// Filter out duplicate commits. Note that we cannot compare the SHA because the commits
// will have a different SHA if they are being cherry-picked into a different branch.
if (existingChangelogContent.includes(commit.subject)) {
console.log(chalk.yellow(` ↺ Skipping duplicate: "${chalk.bold(commit.header)}"`));
return false;
}
// Commits which just specify a scope that refers to a package but do not follow
// the commit format that is parsed by the conventional-changelog-parser, can be
// still resolved to their package from the scope. This handles the case where
// a commit targets the whole package and does not specify a specific scope.
// e.g. "refactor(material-experimental): support strictness flags".
if (!commit.package && commit.scope) {
const matchingPackage = allPackages.find(pkgName => pkgName === commit.scope);
if (matchingPackage) {
commit.scope = null;
commit.package = matchingPackage;
}
}
// TODO(devversion): once we formalize the commit message format and
// require specifying the "material" package explicitly, we can remove
// the fallback to the "material" package.
const packageName = commit.package || 'material';
const type = getTypeOfCommitGroupDescription(group.title);
if (!packageGroups[packageName]) {
packageGroups[packageName] = {commits: [], breakingChanges: []};
}
const packageGroup = packageGroups[packageName];
packageGroup.breakingChanges.push(...commit.notes);
packageGroup.commits.push({...commit, type});
});
});
const sortedPackageGroupNames =
Object.keys(packageGroups)
.filter(pkgName => !excludedChangelogPackages.includes(pkgName))
.sort(preferredOrderComparator);
context.packageGroups = sortedPackageGroupNames.map(pkgName => {
const packageGroup = packageGroups[pkgName];
return {
title: pkgName,
commits: packageGroup.commits.sort(commitSortFunction),
breakingChanges: packageGroup.breakingChanges,
};
});
return context;
}
};
}
/**
* Comparator function that sorts a given array of strings based on the
* hardcoded changelog package order. Entries which are not hardcoded are
* sorted in alphabetical order after the hardcoded entries.
*/
function preferredOrderComparator(a: string, b: string): number {
const aIndex = orderedChangelogPackages.indexOf(a);
const bIndex = orderedChangelogPackages.indexOf(b);
// If a package name could not be found in the hardcoded order, it should be
// sorted after the hardcoded entries in alphabetical order.
if (aIndex === -1) {
return bIndex === -1 ? a.localeCompare(b) : 1;
} else if (bIndex === -1) {
return -1;
}
return aIndex - bIndex;
}
/** Gets the type of a commit group description. */
function getTypeOfCommitGroupDescription(description: string): string {
if (description === 'Features') {
return 'feature';
} else if (description === 'Bug Fixes') {
return 'bug fix';
} else if (description === 'Performance Improvements') {
return 'performance';
} else if (description === 'Reverts') {
return 'revert';
} else if (description === 'Documentation') {
return 'docs';
} else if (description === 'Code Refactoring') {
return 'refactor';
}
return description.toLowerCase();
}
/** Entry-point for generating the changelog when called through the CLI. */
if (require.main === module) {
promptAndGenerateChangelog(join(__dirname, '../../CHANGELOG.md')).then(() => {
console.log(chalk.green(' ✓ Successfully updated the changelog.'));
});
}