Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ or

`npm run auto-changelog update`

#### Use Conventional Commits prefixes to auto-categorize changes

`yarn run auto-changelog update --autoCategorize`

#### Update the current release section of the changelog

`yarn run auto-changelog update --rc`
Expand Down
17 changes: 17 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ type UpdateOptions = {
projectRootDirectory?: string;
tagPrefix: string;
formatter: Formatter;
autoCategorize: boolean;
/**
* The package rename properties, used in case of package is renamed
*/
Expand All @@ -109,6 +110,7 @@ type UpdateOptions = {
* @param options.tagPrefix - The prefix used in tags before the version number.
* @param options.formatter - A custom Markdown formatter to use.
* @param options.packageRename - The package rename properties.
* @param options.autoCategorize - Whether to categorize commits automatically based on their messages.
* An optional, which is required only in case of package renamed.
*/
async function update({
Expand All @@ -120,6 +122,7 @@ async function update({
tagPrefix,
formatter,
packageRename,
autoCategorize,
}: UpdateOptions) {
const changelogContent = await readChangelog(changelogPath);

Expand All @@ -132,6 +135,7 @@ async function update({
tagPrefixes: [tagPrefix],
formatter,
packageRename,
autoCategorize,
});

if (newChangelogContent) {
Expand Down Expand Up @@ -278,6 +282,12 @@ function configureCommonCommandOptions(_yargs: Argv) {
description: 'A version of the package before being renamed.',
type: 'string',
})
.option('autoCategorize', {
default: false,
description:
'Automatically categorize commits based on Conventional Commits prefixes.',
type: 'boolean',
})
.option('tagPrefixBeforePackageRename', {
description: 'A tag prefix of the package before being renamed.',
type: 'string',
Expand All @@ -304,6 +314,11 @@ async function main() {
'The current version of the project that the changelog belongs to.',
type: 'string',
})
.option('autoCategorize', {
default: false,
description:
'Automatically categorize commits based on their messages.',
})
.option('prettier', {
default: true,
description: `Expect the changelog to be formatted with Prettier.`,
Expand Down Expand Up @@ -358,6 +373,7 @@ async function main() {
prettier: usePrettier,
versionBeforePackageRename,
tagPrefixBeforePackageRename,
autoCategorize,
} = argv;
let { currentVersion } = argv;

Expand Down Expand Up @@ -486,6 +502,7 @@ async function main() {
tagPrefix,
formatter,
packageRename,
autoCategorize,
});
} else if (command === 'validate') {
let packageRename: PackageRename | undefined;
Expand Down
13 changes: 13 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
*/
export type Version = string;

/**
* A [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) type.
*/
export enum ConventionalCommitType {
/**
* fix: a commit of the type fix patches a bug in your codebase
*/
Fix = 'fix',
/**
* a commit of the type feat introduces a new feature to the codebase
*/
Feat = 'feat',
}
/**
* Change categories.
*
Expand Down
134 changes: 134 additions & 0 deletions src/get-new-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { strict as assert } from 'assert';
import execa from 'execa';

export type AddNewCommitsOptions = {
mostRecentTag: string | null;
repoUrl: string;
loggedPrNumbers: string[];
projectRootDirectory?: string;
};

/**
* 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);
}

/**
* 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 the list of new change entries to add to a changelog.
*
* @param options - Options.
* @param options.mostRecentTag - The most recent tag.
* @param options.repoUrl - The GitHub repository URL for the current project.
* @param options.loggedPrNumbers - A list of all pull request numbers included in the relevant parsed changelog.
* @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.
* @returns A list of new change entries to add to the changelog, based on commits made since the last release.
*/
export async function getNewChangeEntries({
mostRecentTag,
repoUrl,
loggedPrNumbers,
projectRootDirectory,
}: AddNewCommitsOptions) {
const commitRange =
mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`;
const commitsHashesSinceLastRelease = await getCommitHashesInRange(
commitRange,
projectRootDirectory,
);
const commits = await getCommits(commitsHashesSinceLastRelease);

const newCommits = commits.filter(
({ prNumber }) => !prNumber || !loggedPrNumbers.includes(prNumber),
);

return newCommits.map(({ prNumber, description }) => {
if (prNumber) {
const suffix = `([#${prNumber}](${repoUrl}/pull/${prNumber}))`;
return `${description} ${suffix}`;
}
return description;
});
}

/**
* 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 !== '');
}
61 changes: 61 additions & 0 deletions src/update-changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as ChangeLogUtils from './get-new-changes';
import * as ChangeLogManager from './update-changelog';

const emptyChangelog = `# Changelog
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).

## [Unreleased]

[Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/
`;

describe('updateChangelog', () => {
it('should contain conventional support mappings categorization when autoCategorize is true', async () => {
// Set up the spy and mock the implementation if needed
jest
.spyOn(ChangeLogUtils, 'getNewChangeEntries')
.mockResolvedValue([
'fix: Fixed a critical bug',
'feat: Added new feature [PR#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/123)',
]);

const result = await ChangeLogManager.updateChangelog({
changelogContent: emptyChangelog,
currentVersion: '1.0.0',
repoUrl:
'https://github.com/ExampleUsernameOrOrganization/ExampleRepository',
isReleaseCandidate: true,
autoCategorize: true,
});

expect(result).toContain('### Fixed');
expect(result).toContain('### Added');
expect(result).not.toContain('### Uncategorized');
});

it('should not contain conventional support mappings categorization when autoCategorize is false', async () => {
// Set up the spy and mock the implementation if needed
jest
.spyOn(ChangeLogUtils, 'getNewChangeEntries')
.mockResolvedValue([
'fix: Fixed a critical bug',
'feat: Added new feature [PR#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/123)',
]);

const result = await ChangeLogManager.updateChangelog({
changelogContent: emptyChangelog,
currentVersion: '1.0.0',
repoUrl:
'https://github.com/ExampleUsernameOrOrganization/ExampleRepository',
isReleaseCandidate: true,
autoCategorize: false,
});

expect(result).toContain('### Uncategorized');
expect(result).not.toContain('### Fixed');
expect(result).not.toContain('### Added');
});
});
Loading
Loading