generated from MetaMask/metamask-module-template
-
-
Notifications
You must be signed in to change notification settings - Fork 10
/
update-changelog.ts
292 lines (268 loc) · 9.03 KB
/
update-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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import { strict as assert } from 'assert';
import execa from 'execa';
import { parseChangelog } from './parse-changelog';
import { ChangeCategory, Version } from './constants';
import type Changelog from './changelog';
/**
* Get the most recent tag for a project.
*
* @param options - Options.
* @param options.tagPrefixes - A list of tag prefixes to look for, where the first is the intended
* prefix and each subsequent prefix is a fallback in case the previous tag prefixes are not found.
* @returns The most recent tag.
*/
async function getMostRecentTag({
tagPrefixes,
}: {
tagPrefixes: [string, ...string[]];
}) {
let mostRecentTagCommitHash: string | null = null;
for (const tagPrefix of tagPrefixes) {
const revListArgs = [
'rev-list',
`--tags=${tagPrefix}*`,
'--max-count=1',
'--date-order',
];
const results = await runCommand('git', revListArgs);
if (results.length) {
mostRecentTagCommitHash = results[0];
break;
}
}
if (mostRecentTagCommitHash === null) {
return null;
}
const [mostRecentTag] = await runCommand('git', [
'describe',
'--tags',
mostRecentTagCommitHash,
]);
return mostRecentTag;
}
/**
* Get commit details for each given commit hash.
*
* @param commitHashes - The list of commit hashes.
* @returns Commit details for each commit, including description and PR number (if present).
*/
async function getCommits(commitHashes: string[]) {
const commits: { prNumber?: string; description: string }[] = [];
for (const commitHash of commitHashes) {
const [subject] = await runCommand('git', [
'show',
'-s',
'--format=%s',
commitHash,
]);
assert.ok(
Boolean(subject),
`"git show" returned empty subject for commit "${commitHash}".`,
);
let matchResults = subject.match(/\(#(\d+)\)/u);
let prNumber: string | undefined;
let description = subject;
if (matchResults) {
// Squash & Merge: the commit subject is parsed as `<description> (#<PR ID>)`
prNumber = matchResults[1];
description = subject.match(/^(.+)\s\(#\d+\)/u)?.[1] || '';
} else {
// Merge: the PR ID is parsed from the git subject (which is of the form `Merge pull request
// #<PR ID> from <branch>`, and the description is assumed to be the first line of the body.
// If no body is found, the description is set to the commit subject
matchResults = subject.match(/#(\d+)\sfrom/u);
if (matchResults) {
prNumber = matchResults[1];
const [firstLineOfBody] = await runCommand('git', [
'show',
'-s',
'--format=%b',
commitHash,
]);
description = firstLineOfBody || subject;
}
}
// Otherwise:
// Normal commits: The commit subject is the description, and the PR ID is omitted.
commits.push({ prNumber, description });
}
return commits;
}
/**
* Get all change descriptions from a changelog.
*
* @param changelog - The changelog.
* @returns All commit descriptions included in the given changelog.
*/
function getAllChangeDescriptions(changelog: Changelog) {
const releases = changelog.getReleases();
const changeDescriptions = Object.values(
changelog.getUnreleasedChanges(),
).flat();
for (const release of releases) {
changeDescriptions.push(
...Object.values(changelog.getReleaseChanges(release.version)).flat(),
);
}
return changeDescriptions;
}
/**
* Get all pull request numbers included in the given changelog.
*
* @param changelog - The changelog.
* @returns All pull request numbers included in the given changelog.
*/
function getAllLoggedPrNumbers(changelog: Changelog) {
const changeDescriptions = getAllChangeDescriptions(changelog);
const prNumbersWithChangelogEntries = [];
for (const description of changeDescriptions) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const matchResults = description!.matchAll(/\[#(\d+)\]/gu);
const prNumbers = Array.from(matchResults, (result) => result[1]);
prNumbersWithChangelogEntries.push(...prNumbers);
}
return prNumbersWithChangelogEntries;
}
/**
* Get all commit hashes included in the given commit range.
*
* @param commitRange - The commit range.
* @param rootDirectory - The project root directory.
* @returns A list of commit hashes for the given range.
*/
async function getCommitHashesInRange(
commitRange: string,
rootDirectory?: string,
) {
const revListArgs = ['rev-list', commitRange];
if (rootDirectory) {
revListArgs.push(rootDirectory);
}
return await runCommand('git', revListArgs);
}
export type UpdateChangelogOptions = {
changelogContent: string;
currentVersion?: Version;
repoUrl: string;
isReleaseCandidate: boolean;
projectRootDirectory?: string;
tagPrefixes?: [string, ...string[]];
};
/**
* Update a changelog with any commits made since the last release. Commits for
* PRs that are already included in the changelog are omitted.
*
* @param options - Update options.
* @param options.changelogContent - The current changelog.
* @param options.currentVersion - The current version. Required if
* `isReleaseCandidate` is set, but optional otherwise.
* @param options.repoUrl - The GitHub repository URL for the current project.
* @param options.isReleaseCandidate - Denotes whether the current project.
* is in the midst of release preparation or not. If this is set, any new
* changes are listed under the current release header. Otherwise, they are
* listed under the 'Unreleased' section.
* @param options.projectRootDirectory - The root project directory, used to
* filter results from various git commands. This path is assumed to be either
* absolute, or relative to the current directory. Defaults to the root of the
* current git repository.
* @param options.tagPrefixes - A list of tag prefixes to look for, where the first is the intended
* prefix and each subsequent prefix is a fallback in case the previous tag prefixes are not found.
* @returns The updated changelog text.
*/
export async function updateChangelog({
changelogContent,
currentVersion,
repoUrl,
isReleaseCandidate,
projectRootDirectory,
tagPrefixes = ['v'],
}: UpdateChangelogOptions) {
if (isReleaseCandidate && !currentVersion) {
throw new Error(
`A version must be specified if 'isReleaseCandidate' is set.`,
);
}
const changelog = parseChangelog({
changelogContent,
repoUrl,
tagPrefix: tagPrefixes[0],
});
// Ensure we have all tags on remote
await runCommand('git', ['fetch', '--tags']);
const mostRecentTag = await getMostRecentTag({
tagPrefixes,
});
if (
isReleaseCandidate &&
mostRecentTag === `${tagPrefixes[0]}${currentVersion}`
) {
throw new Error(
`Current version already has tag, which is unexpected for a release candidate.`,
);
}
const commitRange =
mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`;
const commitsHashesSinceLastRelease = await getCommitHashesInRange(
commitRange,
projectRootDirectory,
);
const commits = await getCommits(commitsHashesSinceLastRelease);
const loggedPrNumbers = getAllLoggedPrNumbers(changelog);
const newCommits = commits.filter(
({ prNumber }) =>
prNumber === undefined || !loggedPrNumbers.includes(prNumber),
);
const hasUnreleasedChanges =
Object.keys(changelog.getUnreleasedChanges()).length !== 0;
if (
newCommits.length === 0 &&
(!isReleaseCandidate || hasUnreleasedChanges)
) {
return undefined;
}
// Ensure release header exists, if necessary
if (
isReleaseCandidate &&
!changelog
.getReleases()
.find((release) => release.version === currentVersion)
) {
// Typecast: currentVersion will be defined here due to type guard at the
// top of this function.
changelog.addRelease({ version: currentVersion as Version });
}
if (isReleaseCandidate && hasUnreleasedChanges) {
// Typecast: currentVersion will be defined here due to type guard at the
// top of this function.
changelog.migrateUnreleasedChangesToRelease(currentVersion as Version);
}
const newChangeEntries = newCommits.map(({ prNumber, description }) => {
if (prNumber) {
const suffix = `([#${prNumber}](${repoUrl}/pull/${prNumber}))`;
return `${description} ${suffix}`;
}
return description;
});
for (const description of newChangeEntries.reverse()) {
changelog.addChange({
version: isReleaseCandidate ? currentVersion : undefined,
category: ChangeCategory.Uncategorized,
description,
});
}
return changelog.toString();
}
/**
* Executes a shell command in a child process and returns what it wrote to
* stdout, or rejects if the process exited with an error.
*
* @param command - The command to run, e.g. "git".
* @param args - The arguments to the command.
* @returns An array of the non-empty lines returned by the command.
*/
async function runCommand(command: string, args: string[]): Promise<string[]> {
return (await execa(command, [...args])).stdout
.trim()
.split('\n')
.filter((line) => line !== '');
}