Skip to content

Commit 4136117

Browse files
jake-perkinsmcmire
andauthored
Auto categorize CLI flag (#212)
* feat: new cli flag for auto categorizing conventional commits in the changelog --------- Co-authored-by: Elliot Winkler <elliot.winkler@gmail.com>
1 parent ef3e86e commit 4136117

File tree

6 files changed

+263
-120
lines changed

6 files changed

+263
-120
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ or
2424

2525
`npm run auto-changelog update`
2626

27+
#### Use Conventional Commits prefixes to auto-categorize changes
28+
29+
`yarn run auto-changelog update --autoCategorize`
30+
2731
#### Update the current release section of the changelog
2832

2933
`yarn run auto-changelog update --rc`

src/cli.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ type UpdateOptions = {
9191
projectRootDirectory?: string;
9292
tagPrefix: string;
9393
formatter: Formatter;
94+
autoCategorize: boolean;
9495
/**
9596
* The package rename properties, used in case of package is renamed
9697
*/
@@ -109,6 +110,7 @@ type UpdateOptions = {
109110
* @param options.tagPrefix - The prefix used in tags before the version number.
110111
* @param options.formatter - A custom Markdown formatter to use.
111112
* @param options.packageRename - The package rename properties.
113+
* @param options.autoCategorize - Whether to categorize commits automatically based on their messages.
112114
* An optional, which is required only in case of package renamed.
113115
*/
114116
async function update({
@@ -120,6 +122,7 @@ async function update({
120122
tagPrefix,
121123
formatter,
122124
packageRename,
125+
autoCategorize,
123126
}: UpdateOptions) {
124127
const changelogContent = await readChangelog(changelogPath);
125128

@@ -132,6 +135,7 @@ async function update({
132135
tagPrefixes: [tagPrefix],
133136
formatter,
134137
packageRename,
138+
autoCategorize,
135139
});
136140

137141
if (newChangelogContent) {
@@ -278,6 +282,12 @@ function configureCommonCommandOptions(_yargs: Argv) {
278282
description: 'A version of the package before being renamed.',
279283
type: 'string',
280284
})
285+
.option('autoCategorize', {
286+
default: false,
287+
description:
288+
'Automatically categorize commits based on Conventional Commits prefixes.',
289+
type: 'boolean',
290+
})
281291
.option('tagPrefixBeforePackageRename', {
282292
description: 'A tag prefix of the package before being renamed.',
283293
type: 'string',
@@ -304,6 +314,11 @@ async function main() {
304314
'The current version of the project that the changelog belongs to.',
305315
type: 'string',
306316
})
317+
.option('autoCategorize', {
318+
default: false,
319+
description:
320+
'Automatically categorize commits based on their messages.',
321+
})
307322
.option('prettier', {
308323
default: true,
309324
description: `Expect the changelog to be formatted with Prettier.`,
@@ -358,6 +373,7 @@ async function main() {
358373
prettier: usePrettier,
359374
versionBeforePackageRename,
360375
tagPrefixBeforePackageRename,
376+
autoCategorize,
361377
} = argv;
362378
let { currentVersion } = argv;
363379

@@ -486,6 +502,7 @@ async function main() {
486502
tagPrefix,
487503
formatter,
488504
packageRename,
505+
autoCategorize,
489506
});
490507
} else if (command === 'validate') {
491508
let packageRename: PackageRename | undefined;

src/constants.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
33
*/
44
export type Version = string;
55

6+
/**
7+
* A [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) type.
8+
*/
9+
export enum ConventionalCommitType {
10+
/**
11+
* fix: a commit of the type fix patches a bug in your codebase
12+
*/
13+
Fix = 'fix',
14+
/**
15+
* a commit of the type feat introduces a new feature to the codebase
16+
*/
17+
Feat = 'feat',
18+
}
619
/**
720
* Change categories.
821
*

src/get-new-changes.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { strict as assert } from 'assert';
2+
import execa from 'execa';
3+
4+
export type AddNewCommitsOptions = {
5+
mostRecentTag: string | null;
6+
repoUrl: string;
7+
loggedPrNumbers: string[];
8+
projectRootDirectory?: string;
9+
};
10+
11+
/**
12+
* Get all commit hashes included in the given commit range.
13+
*
14+
* @param commitRange - The commit range.
15+
* @param rootDirectory - The project root directory.
16+
* @returns A list of commit hashes for the given range.
17+
*/
18+
async function getCommitHashesInRange(
19+
commitRange: string,
20+
rootDirectory?: string,
21+
) {
22+
const revListArgs = ['rev-list', commitRange];
23+
if (rootDirectory) {
24+
revListArgs.push(rootDirectory);
25+
}
26+
return await runCommand('git', revListArgs);
27+
}
28+
29+
/**
30+
* Get commit details for each given commit hash.
31+
*
32+
* @param commitHashes - The list of commit hashes.
33+
* @returns Commit details for each commit, including description and PR number (if present).
34+
*/
35+
async function getCommits(commitHashes: string[]) {
36+
const commits: { prNumber?: string; description: string }[] = [];
37+
for (const commitHash of commitHashes) {
38+
const [subject] = await runCommand('git', [
39+
'show',
40+
'-s',
41+
'--format=%s',
42+
commitHash,
43+
]);
44+
assert.ok(
45+
Boolean(subject),
46+
`"git show" returned empty subject for commit "${commitHash}".`,
47+
);
48+
49+
let matchResults = subject.match(/\(#(\d+)\)/u);
50+
let prNumber: string | undefined;
51+
let description = subject;
52+
53+
if (matchResults) {
54+
// Squash & Merge: the commit subject is parsed as `<description> (#<PR ID>)`
55+
prNumber = matchResults[1];
56+
description = subject.match(/^(.+)\s\(#\d+\)/u)?.[1] ?? '';
57+
} else {
58+
// Merge: the PR ID is parsed from the git subject (which is of the form `Merge pull request
59+
// #<PR ID> from <branch>`, and the description is assumed to be the first line of the body.
60+
// If no body is found, the description is set to the commit subject
61+
matchResults = subject.match(/#(\d+)\sfrom/u);
62+
if (matchResults) {
63+
prNumber = matchResults[1];
64+
const [firstLineOfBody] = await runCommand('git', [
65+
'show',
66+
'-s',
67+
'--format=%b',
68+
commitHash,
69+
]);
70+
description = firstLineOfBody || subject;
71+
}
72+
}
73+
// Otherwise:
74+
// Normal commits: The commit subject is the description, and the PR ID is omitted.
75+
76+
commits.push({ prNumber, description });
77+
}
78+
return commits;
79+
}
80+
81+
/**
82+
* Get the list of new change entries to add to a changelog.
83+
*
84+
* @param options - Options.
85+
* @param options.mostRecentTag - The most recent tag.
86+
* @param options.repoUrl - The GitHub repository URL for the current project.
87+
* @param options.loggedPrNumbers - A list of all pull request numbers included in the relevant parsed changelog.
88+
* @param options.projectRootDirectory - The root project directory, used to
89+
* filter results from various git commands. This path is assumed to be either
90+
* absolute, or relative to the current directory. Defaults to the root of the
91+
* current git repository.
92+
* @returns A list of new change entries to add to the changelog, based on commits made since the last release.
93+
*/
94+
export async function getNewChangeEntries({
95+
mostRecentTag,
96+
repoUrl,
97+
loggedPrNumbers,
98+
projectRootDirectory,
99+
}: AddNewCommitsOptions) {
100+
const commitRange =
101+
mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`;
102+
const commitsHashesSinceLastRelease = await getCommitHashesInRange(
103+
commitRange,
104+
projectRootDirectory,
105+
);
106+
const commits = await getCommits(commitsHashesSinceLastRelease);
107+
108+
const newCommits = commits.filter(
109+
({ prNumber }) => !prNumber || !loggedPrNumbers.includes(prNumber),
110+
);
111+
112+
return newCommits.map(({ prNumber, description }) => {
113+
if (prNumber) {
114+
const suffix = `([#${prNumber}](${repoUrl}/pull/${prNumber}))`;
115+
return `${description} ${suffix}`;
116+
}
117+
return description;
118+
});
119+
}
120+
121+
/**
122+
* Executes a shell command in a child process and returns what it wrote to
123+
* stdout, or rejects if the process exited with an error.
124+
*
125+
* @param command - The command to run, e.g. "git".
126+
* @param args - The arguments to the command.
127+
* @returns An array of the non-empty lines returned by the command.
128+
*/
129+
async function runCommand(command: string, args: string[]): Promise<string[]> {
130+
return (await execa(command, [...args])).stdout
131+
.trim()
132+
.split('\n')
133+
.filter((line) => line !== '');
134+
}

src/update-changelog.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as ChangeLogUtils from './get-new-changes';
2+
import * as ChangeLogManager from './update-changelog';
3+
4+
const emptyChangelog = `# Changelog
5+
All notable changes to this project will be documented in this file.
6+
7+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
8+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9+
10+
## [Unreleased]
11+
12+
[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/
13+
`;
14+
15+
describe('updateChangelog', () => {
16+
it('should contain conventional support mappings categorization when autoCategorize is true', async () => {
17+
// Set up the spy and mock the implementation if needed
18+
jest
19+
.spyOn(ChangeLogUtils, 'getNewChangeEntries')
20+
.mockResolvedValue([
21+
'fix: Fixed a critical bug',
22+
'feat: Added new feature [PR#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/123)',
23+
]);
24+
25+
const result = await ChangeLogManager.updateChangelog({
26+
changelogContent: emptyChangelog,
27+
currentVersion: '1.0.0',
28+
repoUrl:
29+
'https://github.com/ExampleUsernameOrOrganization/ExampleRepository',
30+
isReleaseCandidate: true,
31+
autoCategorize: true,
32+
});
33+
34+
expect(result).toContain('### Fixed');
35+
expect(result).toContain('### Added');
36+
expect(result).not.toContain('### Uncategorized');
37+
});
38+
39+
it('should not contain conventional support mappings categorization when autoCategorize is false', async () => {
40+
// Set up the spy and mock the implementation if needed
41+
jest
42+
.spyOn(ChangeLogUtils, 'getNewChangeEntries')
43+
.mockResolvedValue([
44+
'fix: Fixed a critical bug',
45+
'feat: Added new feature [PR#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/123)',
46+
]);
47+
48+
const result = await ChangeLogManager.updateChangelog({
49+
changelogContent: emptyChangelog,
50+
currentVersion: '1.0.0',
51+
repoUrl:
52+
'https://github.com/ExampleUsernameOrOrganization/ExampleRepository',
53+
isReleaseCandidate: true,
54+
autoCategorize: false,
55+
});
56+
57+
expect(result).toContain('### Uncategorized');
58+
expect(result).not.toContain('### Fixed');
59+
expect(result).not.toContain('### Added');
60+
});
61+
});

0 commit comments

Comments
 (0)