generated from MetaMask/metamask-module-template
-
-
Notifications
You must be signed in to change notification settings - Fork 10
/
changelog.ts
461 lines (418 loc) · 14.4 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
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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
import semver from 'semver';
import {
ChangeCategory,
orderedChangeCategories,
unreleased,
Version,
} from './constants';
const changelogTitle = '# Changelog';
const changelogDescription = `All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).`;
type ReleaseMetadata = {
/**
* The version of the current release.
*/
version: Version;
/**
* An ISO-8601 formatted date, representing the
* release date.
*/
date?: string;
/**
* The status of the release (e.g. 'WITHDRAWN', 'DEPRECATED')
*/
status?: string;
};
/**
* Release changes, organized by category.
*/
type ReleaseChanges = Partial<Record<ChangeCategory, string[]>>;
/**
* Changelog changes, organized by release and by category.
*/
type ChangelogChanges = Record<Version, ReleaseChanges> & {
[unreleased]: ReleaseChanges;
};
// Stringification helpers
/**
* Stringify a changelog category section.
*
* @param category - The title of the changelog category.
* @param changes - The changes included in this category.
* @returns The stringified category section.
*/
function stringifyCategory(category: ChangeCategory, changes: string[]) {
const categoryHeader = `### ${category}`;
if (changes.length === 0) {
return categoryHeader;
}
const changeDescriptions = changes
.map((description) => `- ${description}`)
.join('\n');
return `${categoryHeader}\n${changeDescriptions}`;
}
/**
* Stringify a changelog release section.
*
* @param version - The release version.
* @param categories - The categories of changes included in this release.
* @param options - Additional release options.
* @param options.date - The date of the release.
* @param options.status - The status of the release (e.g., "DEPRECATED").
* @returns The stringified release section.
*/
function stringifyRelease(
version: Version | typeof unreleased,
categories: ReleaseChanges,
{ date, status }: Partial<ReleaseMetadata> = {},
) {
const releaseHeader = `## [${version}]${date ? ` - ${date}` : ''}${
status ? ` [${status}]` : ''
}`;
const categorizedChanges = orderedChangeCategories
.filter((category) => categories[category])
.map((category) => {
const changes = categories[category] as string[];
return stringifyCategory(category, changes);
})
.join('\n\n');
if (categorizedChanges === '') {
return releaseHeader;
}
return `${releaseHeader}\n${categorizedChanges}`;
}
/**
* Stringify a set of changelog release sections.
*
* @param releases - The releases to stringify.
* @param changes - The set of changes to include, organized by release.
* @returns The stringified set of release sections.
*/
function stringifyReleases(
releases: ReleaseMetadata[],
changes: ChangelogChanges,
) {
const stringifiedUnreleased = stringifyRelease(
unreleased,
changes[unreleased],
);
const stringifiedReleases = releases.map(({ version, date, status }) => {
const categories = changes[version];
return stringifyRelease(version, categories, { date, status });
});
return [stringifiedUnreleased, ...stringifiedReleases].join('\n\n');
}
/**
* Return the given URL with a trailing slash. It is returned unaltered if it
* already has a trailing slash.
*
* @param url - The URL string.
* @returns The URL string with a trailing slash.
*/
function withTrailingSlash(url: string) {
return url.endsWith('/') ? url : `${url}/`;
}
/**
* Get the GitHub URL for comparing two git commits.
*
* @param repoUrl - The URL for the GitHub repository.
* @param firstRef - A reference (e.g., commit hash, tag, etc.) to the first commit to compare.
* @param secondRef - A reference (e.g., commit hash, tag, etc.) to the second commit to compare.
* @returns The comparison URL for the two given commits.
*/
function getCompareUrl(repoUrl: string, firstRef: string, secondRef: string) {
return `${withTrailingSlash(repoUrl)}compare/${firstRef}...${secondRef}`;
}
/**
* Get a GitHub tag URL.
*
* @param repoUrl - The URL for the GitHub repository.
* @param tag - The tag name.
* @returns The URL for the given tag.
*/
function getTagUrl(repoUrl: string, tag: string) {
return `${withTrailingSlash(repoUrl)}releases/tag/${tag}`;
}
/**
* Get a stringified list of link definitions for the given set of releases. The first release is
* linked to the corresponding tag, and each subsequent release is linked to a comparison with the
* previous release.
*
* @param repoUrl - The URL for the GitHub repository.
* @param tagPrefix - The prefix used in tags before the version number.
* @param releases - The releases to generate link definitions for.
* @returns The stringified release link definitions.
*/
function stringifyLinkReferenceDefinitions(
repoUrl: string,
tagPrefix: string,
releases: ReleaseMetadata[],
) {
// A list of release versions in descending SemVer order
const descendingSemverVersions = releases
.map(({ version }) => version)
.sort((a: Version, b: Version) => {
return semver.gt(a, b) ? -1 : 1;
});
const latestSemverVersion = descendingSemverVersions[0];
// A list of release versions in chronological order
const chronologicalVersions = releases.map(({ version }) => version);
const hasReleases = chronologicalVersions.length > 0;
// The "Unreleased" section represents all changes made since the *highest*
// release, not the most recent release. This is to accomodate patch releases
// of older versions that don't represent the latest set of changes.
//
// For example, if a library has a v2.0.0 but the v1.0.0 release needed a
// security update, the v1.0.1 release would then be the most recent, but the
// range of unreleased changes would remain `v2.0.0...HEAD`.
//
// If there have not been any releases yet, the repo URL is used directly as
// the link definition.
const unreleasedLinkReferenceDefinition = `[${unreleased}]: ${
hasReleases
? getCompareUrl(repoUrl, `${tagPrefix}${latestSemverVersion}`, 'HEAD')
: withTrailingSlash(repoUrl)
}`;
// The "previous" release that should be used for comparison is not always
// the most recent release chronologically. The _highest_ version that is
// lower than the current release is used as the previous release, so that
// patch releases on older releases can be accomodated.
const releaseLinkReferenceDefinitions = releases
.map(({ version }) => {
let diffUrl;
if (version === chronologicalVersions[chronologicalVersions.length - 1]) {
diffUrl = getTagUrl(repoUrl, `${tagPrefix}${version}`);
} else {
const versionIndex = chronologicalVersions.indexOf(version);
const previousVersion = chronologicalVersions
.slice(versionIndex)
.find((releaseVersion: Version) => {
return semver.gt(version, releaseVersion);
});
diffUrl = previousVersion
? getCompareUrl(
repoUrl,
`${tagPrefix}${previousVersion}`,
`${tagPrefix}${version}`,
)
: getTagUrl(repoUrl, `${tagPrefix}${version}`);
}
return `[${version}]: ${diffUrl}`;
})
.join('\n');
return `${unreleasedLinkReferenceDefinition}\n${releaseLinkReferenceDefinitions}${
releases.length > 0 ? '\n' : ''
}`;
}
type AddReleaseOptions = {
addToStart?: boolean;
date?: string;
status?: string;
version: Version;
};
type AddChangeOptions = {
addToStart?: boolean;
category: ChangeCategory;
description: string;
version?: Version;
};
/**
* A changelog that complies with the
* ["Keep a Changelog" v1.1.0 guidelines](https://keepachangelog.com/en/1.0.0/).
*
* This changelog starts out completely empty, and allows new releases and
* changes to be added such that the changelog remains compliant at all times.
* This can be used to help validate the contents of a changelog, normalize
* formatting, update a changelog, or build one from scratch.
*/
export default class Changelog {
private _releases: ReleaseMetadata[];
private _changes: ChangelogChanges;
private _repoUrl: string;
private _tagPrefix: string;
/**
* Construct an empty changelog.
*
* @param options - Changelog options.
* @param options.repoUrl - The GitHub repository URL for the current project.
* @param options.tagPrefix - The prefix used in tags before the version number.
*/
constructor({
repoUrl,
tagPrefix = 'v',
}: {
repoUrl: string;
tagPrefix?: string;
}) {
this._releases = [];
this._changes = { [unreleased]: {} };
this._repoUrl = repoUrl;
this._tagPrefix = tagPrefix;
}
/**
* Add a release to the changelog.
*
* @param options - Release options.
* @param options.addToStart - Determines whether the change is added to the
* top or bottom of the list of changes in this category. This defaults to
* `true` because changes should be in reverse-chronological order. This
* should be set to `false` when parsing a changelog top-to-bottom.
* @param options.date - An ISO-8601 formatted date, representing the release
* date.
* @param options.status - The status of the release (e.g., 'WITHDRAWN',
* 'DEPRECATED').
* @param options.version - The version of the current release, which should
* be a [SemVer](https://semver.org/spec/v2.0.0.html)-compatible version.
*/
addRelease({ addToStart = true, date, status, version }: AddReleaseOptions) {
if (!version) {
throw new Error('Version required');
} else if (semver.valid(version) === null) {
throw new Error(`Not a valid semver version: '${version}'`);
} else if (this._changes[version]) {
throw new Error(`Release already exists: '${version}'`);
}
this._changes[version] = {};
const newRelease = { version, date, status };
if (addToStart) {
this._releases.unshift(newRelease);
} else {
this._releases.push(newRelease);
}
}
/**
* Add a change to the changelog.
*
* @param options - Change options.
* @param options.addToStart - Determines whether the change is added to the
* top or bottom of the list of changes in this category. This defaults to
* `true` because changes should be in reverse-chronological order. This
* should be set to `false` when parsing a changelog top-to-bottom.
* @param options.category - The category of the change.
* @param options.description - The description of the change.
* @param options.version - The version this change was released in. If this
* is not given, the change is assumed to be unreleased.
*/
addChange({
addToStart = true,
category,
description,
version,
}: AddChangeOptions) {
if (!category) {
throw new Error('Category required');
} else if (!orderedChangeCategories.includes(category)) {
throw new Error(`Unrecognized category: '${category}'`);
} else if (!description) {
throw new Error('Description required');
} else if (version !== undefined && !this._changes[version]) {
throw new Error(`Specified release version does not exist: '${version}'`);
}
const release = version
? this._changes[version]
: this._changes[unreleased];
if (!release[category]) {
release[category] = [];
}
if (addToStart) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
release[category]!.unshift(description);
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
release[category]!.push(description);
}
}
/**
* Migrate all unreleased changes to a release section.
*
* Changes are migrated in their existing categories, and placed above any
* pre-existing changes in that category.
*
* @param version - The release version to migrate unreleased changes to.
*/
migrateUnreleasedChangesToRelease(version: Version) {
const releaseChanges = this._changes[version];
if (!releaseChanges) {
throw new Error(`Specified release version does not exist: '${version}'`);
}
const unreleasedChanges = this._changes[unreleased];
for (const category of Object.keys(unreleasedChanges) as ChangeCategory[]) {
if (releaseChanges[category]) {
releaseChanges[category] = [
...(unreleasedChanges[category] as string[]),
...(releaseChanges[category] as string[]),
];
} else {
releaseChanges[category] = unreleasedChanges[category];
}
}
this._changes[unreleased] = {};
}
/**
* Gets the metadata for all releases.
*
* @returns The metadata for each release.
*/
getReleases() {
return this._releases;
}
/**
* Gets the release of the given version.
*
* @param version - The version of the release to retrieve.
* @returns The specified release, or undefined if no such release exists.
*/
getRelease(version: Version) {
return this.getReleases().find(
({ version: _version }) => _version === version,
);
}
/**
* Gets the stringified release of the given version.
* Throws an error if no such release exists.
*
* @param version - The version of the release to stringify.
* @returns The stringified release, as it appears in the changelog.
*/
getStringifiedRelease(version: Version) {
const release = this.getRelease(version);
if (!release) {
throw new Error(`Specified release version does not exist: '${version}'`);
}
const releaseChanges = this.getReleaseChanges(version);
return stringifyRelease(version, releaseChanges, release);
}
/**
* Gets the changes in the given release, organized by category.
*
* @param version - The version of the release being retrieved.
* @returns The changes included in the given released.
*/
getReleaseChanges(version: Version) {
return this._changes[version];
}
/**
* Gets all changes that have not yet been released.
*
* @returns The changes that have not yet been released.
*/
getUnreleasedChanges() {
return this._changes[unreleased];
}
/**
* The stringified changelog, formatted according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
*
* @returns The stringified changelog.
*/
toString() {
return `${changelogTitle}
${changelogDescription}
${stringifyReleases(this._releases, this._changes)}
${stringifyLinkReferenceDefinitions(
this._repoUrl,
this._tagPrefix,
this._releases,
)}`;
}
}