Skip to content

Commit b7c5c0e

Browse files
authored
Add way to verify each change has associated PRs (#222)
This commit adds a new option to the `validate` command, `--pr-links`, which will cause an error to be thrown if: - a changelog entry does not have one or more links to pull requests after it - a changelog entry does have PR links present, but they do not point to the project's repo - a changelog entry does have PR links present, but they are not positioned at the very end of the line The `ensureValidPrLinksPresent` option has also been added to `validateChangelog`. If this option is provided, then `parseChangelog` is instructed to look for and extract pull request numbers from changelog entries. The list of numbers will then be checked for in the validation step. It is also used to reconstruct pull request links when the changelog is stringified. Note that because this commit changes what `parseChangelog` returns, this is a breaking change.
1 parent 0b03d75 commit b7c5c0e

File tree

9 files changed

+938
-59
lines changed

9 files changed

+938
-59
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ or
9090

9191
`npm run auto-changelog validate --tag-prefix-before-package-rename "polling-controller@" --version-before-package-name 0.2.3 --tag-prefix "@metamask/polling-controller@"`
9292

93+
#### Validate that each changelog entry has one or more associated pull requests
94+
95+
`yarn run auto-changelog validate --pr-links`
96+
97+
or
98+
99+
`npm run auto-changelog validate --pr-links`
100+
93101
## API Usage
94102

95103
Each supported command is a separate named export.
@@ -116,7 +124,7 @@ await fs.writeFile('CHANGELOG.md', updatedChangelog);
116124

117125
### `validateChangelog`
118126

119-
This command validates the changelog
127+
This command validates the changelog.
120128

121129
```javascript
122130
import { promises as fs } from 'fs';
@@ -132,6 +140,7 @@ try {
132140
repoUrl:
133141
'https://github.com/ExampleUsernameOrOrganization/ExampleRepository',
134142
isReleaseCandidate: false,
143+
ensureValidPrLinksPresent: true,
135144
});
136145
// changelog is valid!
137146
} catch (error) {

src/changelog.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import _outdent from 'outdent';
2+
13
import Changelog from './changelog';
4+
import { ChangeCategory } from './constants';
5+
6+
const outdent = _outdent({ trimTrailingNewline: false });
27

38
const emptyChangelog = `# Changelog
49
All notable changes to this project will be documented in this file.
@@ -28,4 +33,43 @@ describe('Changelog', () => {
2833

2934
expect(await changelog.toString()).toStrictEqual(emptyChangelog);
3035
});
36+
37+
it('should recreate pull request links for change entries based on the repo URL', async () => {
38+
const changelog = new Changelog({
39+
repoUrl: 'https://github.com/MetaMask/fake-repo',
40+
});
41+
changelog.addRelease({ version: '1.0.0' });
42+
changelog.addChange({
43+
version: '1.0.0',
44+
category: ChangeCategory.Changed,
45+
description: 'This is a cool change\n - This is a sub-bullet',
46+
prNumbers: ['100', '200'],
47+
});
48+
changelog.addChange({
49+
version: '1.0.0',
50+
category: ChangeCategory.Changed,
51+
description: 'This is a very cool change\nAnd another line',
52+
prNumbers: ['300'],
53+
});
54+
55+
expect(await changelog.toString()).toStrictEqual(outdent`
56+
# Changelog
57+
All notable changes to this project will be documented in this file.
58+
59+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
60+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
61+
62+
## [Unreleased]
63+
64+
## [1.0.0]
65+
### Changed
66+
- This is a very cool change ([#300](https://github.com/MetaMask/fake-repo/pull/300))
67+
And another line
68+
- This is a cool change ([#100](https://github.com/MetaMask/fake-repo/pull/100), [#200](https://github.com/MetaMask/fake-repo/pull/200))
69+
- This is a sub-bullet
70+
71+
[Unreleased]: https://github.com/MetaMask/fake-repo/compare/v1.0.0...HEAD
72+
[1.0.0]: https://github.com/MetaMask/fake-repo/releases/tag/v1.0.0
73+
`);
74+
});
3175
});

src/changelog.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,25 @@ type ReleaseMetadata = {
7272
status?: string;
7373
};
7474

75+
/**
76+
* A single change in the changelog.
77+
*/
78+
export type Change = {
79+
/**
80+
* The description of the change.
81+
*/
82+
description: string;
83+
84+
/**
85+
* Pull requests within the repo that are associated with this change.
86+
*/
87+
prNumbers: string[];
88+
};
89+
7590
/**
7691
* Release changes, organized by category.
7792
*/
78-
type ReleaseChanges = Partial<Record<ChangeCategory, string[]>>;
93+
type ReleaseChanges = Partial<Record<ChangeCategory, Change[]>>;
7994

8095
/**
8196
* Changelog changes, organized by release and by category.
@@ -91,15 +106,30 @@ type ChangelogChanges = Record<Version, ReleaseChanges> & {
91106
*
92107
* @param category - The title of the changelog category.
93108
* @param changes - The changes included in this category.
109+
* @param repoUrl - The URL of the repository.
94110
* @returns The stringified category section.
95111
*/
96-
function stringifyCategory(category: ChangeCategory, changes: string[]) {
112+
function stringifyCategory(
113+
category: ChangeCategory,
114+
changes: Change[],
115+
repoUrl: string,
116+
) {
97117
const categoryHeader = `### ${category}`;
98118
if (changes.length === 0) {
99119
return categoryHeader;
100120
}
101121
const changeDescriptions = changes
102-
.map((description) => `- ${description}`)
122+
.map(({ description, prNumbers }) => {
123+
const [firstLine, ...otherLines] = description.split('\n');
124+
const stringifiedPrLinks = prNumbers
125+
.map((prNumber) => `[#${prNumber}](${repoUrl}/pull/${prNumber})`)
126+
.join(', ');
127+
const parenthesizedPrLinks =
128+
stringifiedPrLinks.length > 0 ? ` (${stringifiedPrLinks})` : '';
129+
return [`- ${firstLine}${parenthesizedPrLinks}`, ...otherLines].join(
130+
'\n',
131+
);
132+
})
103133
.join('\n');
104134
return `${categoryHeader}\n${changeDescriptions}`;
105135
}
@@ -109,6 +139,7 @@ function stringifyCategory(category: ChangeCategory, changes: string[]) {
109139
*
110140
* @param version - The release version.
111141
* @param categories - The categories of changes included in this release.
142+
* @param repoUrl - The URL of the repository.
112143
* @param options - Additional release options.
113144
* @param options.date - The date of the release.
114145
* @param options.status - The status of the release (e.g., "DEPRECATED").
@@ -117,6 +148,7 @@ function stringifyCategory(category: ChangeCategory, changes: string[]) {
117148
function stringifyRelease(
118149
version: Version | typeof unreleased,
119150
categories: ReleaseChanges,
151+
repoUrl: string,
120152
{ date, status }: Partial<ReleaseMetadata> = {},
121153
) {
122154
const releaseHeader = `## [${version}]${date ? ` - ${date}` : ''}${
@@ -126,7 +158,7 @@ function stringifyRelease(
126158
.filter((category) => categories[category])
127159
.map((category) => {
128160
const changes = categories[category] ?? [];
129-
return stringifyCategory(category, changes);
161+
return stringifyCategory(category, changes, repoUrl);
130162
})
131163
.join('\n\n');
132164
if (categorizedChanges === '') {
@@ -140,19 +172,22 @@ function stringifyRelease(
140172
*
141173
* @param releases - The releases to stringify.
142174
* @param changes - The set of changes to include, organized by release.
175+
* @param repoUrl - The URL of the repository.
143176
* @returns The stringified set of release sections.
144177
*/
145178
function stringifyReleases(
146179
releases: ReleaseMetadata[],
147180
changes: ChangelogChanges,
181+
repoUrl: string,
148182
) {
149183
const stringifiedUnreleased = stringifyRelease(
150184
unreleased,
151185
changes[unreleased],
186+
repoUrl,
152187
);
153188
const stringifiedReleases = releases.map(({ version, date, status }) => {
154189
const categories = changes[version];
155-
return stringifyRelease(version, categories, { date, status });
190+
return stringifyRelease(version, categories, repoUrl, { date, status });
156191
});
157192

158193
return [stringifiedUnreleased, ...stringifiedReleases].join('\n\n');
@@ -364,6 +399,7 @@ type AddChangeOptions = {
364399
category: ChangeCategory;
365400
description: string;
366401
version?: Version;
402+
prNumbers?: string[];
367403
};
368404

369405
/**
@@ -462,12 +498,15 @@ export default class Changelog {
462498
* @param options.description - The description of the change.
463499
* @param options.version - The version this change was released in. If this
464500
* is not given, the change is assumed to be unreleased.
501+
* @param options.prNumbers - The pull request numbers associated with the
502+
* change.
465503
*/
466504
addChange({
467505
addToStart = true,
468506
category,
469507
description,
470508
version,
509+
prNumbers = [],
471510
}: AddChangeOptions) {
472511
if (!category) {
473512
throw new Error('Category required');
@@ -482,18 +521,14 @@ export default class Changelog {
482521
const release = version
483522
? this.#changes[version]
484523
: this.#changes[unreleased];
524+
const releaseCategory = release[category] ?? [];
485525

486-
if (!release[category]) {
487-
release[category] = [];
488-
}
526+
releaseCategory[addToStart ? 'unshift' : 'push']({
527+
description,
528+
prNumbers,
529+
});
489530

490-
if (addToStart) {
491-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
492-
release[category]!.unshift(description);
493-
} else {
494-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
495-
release[category]!.push(description);
496-
}
531+
release[category] = releaseCategory;
497532
}
498533

499534
/**
@@ -559,7 +594,7 @@ export default class Changelog {
559594
throw new Error(`Specified release version does not exist: '${version}'`);
560595
}
561596
const releaseChanges = this.getReleaseChanges(version);
562-
return stringifyRelease(version, releaseChanges, release);
597+
return stringifyRelease(version, releaseChanges, this.#repoUrl, release);
563598
}
564599

565600
/**
@@ -590,7 +625,7 @@ export default class Changelog {
590625
const changelog = `${changelogTitle}
591626
${changelogDescription}
592627
593-
${stringifyReleases(this.#releases, this.#changes)}
628+
${stringifyReleases(this.#releases, this.#changes, this.#repoUrl)}
594629
595630
${stringifyLinkReferenceDefinitions(
596631
this.#repoUrl,

src/cli.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
#!/usr/bin/env node
2-
31
import { promises as fs, constants as fsConstants } from 'fs';
42
import path from 'path';
53
import semver from 'semver';
@@ -158,6 +156,11 @@ type ValidateOptions = {
158156
* The package rename properties, used in case of package is renamed
159157
*/
160158
packageRename?: PackageRename;
159+
/**
160+
* Whether to validate that each changelog entry has one or more links to
161+
* associated pull requests within the repository (true) or not (false).
162+
*/
163+
ensureValidPrLinksPresent: boolean;
161164
};
162165

163166
/**
@@ -172,7 +175,9 @@ type ValidateOptions = {
172175
* @param options.fix - Whether to attempt to fix the changelog or not.
173176
* @param options.formatter - A custom Markdown formatter to use.
174177
* @param options.packageRename - The package rename properties.
175-
* An optional, which is required only in case of package renamed.
178+
* @param options.ensureValidPrLinksPresent - Whether to validate that each
179+
* changelog entry has one or more links to associated pull requests within the
180+
* repository (true) or not (false).
176181
*/
177182
async function validate({
178183
changelogPath,
@@ -183,6 +188,7 @@ async function validate({
183188
fix,
184189
formatter,
185190
packageRename,
191+
ensureValidPrLinksPresent,
186192
}: ValidateOptions) {
187193
const changelogContent = await readChangelog(changelogPath);
188194

@@ -195,6 +201,7 @@ async function validate({
195201
tagPrefix,
196202
formatter,
197203
packageRename,
204+
ensureValidPrLinksPresent,
198205
});
199206
return undefined;
200207
} catch (error) {
@@ -351,6 +358,12 @@ async function main() {
351358
description: `Expect the changelog to be formatted with Prettier.`,
352359
type: 'boolean',
353360
})
361+
.option('prLinks', {
362+
default: false,
363+
description:
364+
'Verify that each changelog entry has one or more links to associated pull requests within the repository',
365+
type: 'boolean',
366+
})
354367
.epilog(validateEpilog),
355368
)
356369
.command('init', 'Initialize a new empty changelog', (_yargs) => {
@@ -374,6 +387,7 @@ async function main() {
374387
versionBeforePackageRename,
375388
tagPrefixBeforePackageRename,
376389
autoCategorize,
390+
prLinks,
377391
} = argv;
378392
let { currentVersion } = argv;
379393

@@ -521,6 +535,7 @@ async function main() {
521535
fix,
522536
formatter,
523537
packageRename,
538+
ensureValidPrLinksPresent: prLinks,
524539
});
525540
} else if (command === 'init') {
526541
await init({

0 commit comments

Comments
 (0)