From 694d082037e007e6408e8c6f5f3a8aba0b6ed5c4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 9 Dec 2025 23:22:33 +0000 Subject: [PATCH 1/8] feat: Add automatic version bumping based on conventional commits Add automatic semantic version calculation triggered by `craft prepare auto`. Analyzes commits since the last tag using release config categories with a new `semver` field to determine the appropriate version bump (major/minor/patch). Key changes: - Add `semver` field to release config categories (major/minor/patch) - Update DEFAULT_RELEASE_CONFIG with conventional commit semver mappings - Create autoVersion.ts with BumpType enum for efficient max comparison - Add requiresMinVersion() helper to gate feature (requires minVersion >= 2.14.0) - Update prepare command to accept "auto" keyword The system finds the highest bump type across matched commits with early exit when a major bump is found. Throws an error if no commits match categories with semver fields defined. --- src/commands/__tests__/prepare.test.ts | 11 + src/commands/prepare.ts | 32 +- src/config.ts | 28 ++ src/utils/__tests__/autoVersion.test.ts | 566 ++++++++++++++++++++++++ src/utils/autoVersion.ts | 288 ++++++++++++ src/utils/changelog.ts | 35 +- 6 files changed, 946 insertions(+), 14 deletions(-) create mode 100644 src/utils/__tests__/autoVersion.test.ts create mode 100644 src/utils/autoVersion.ts diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts index e5baaad1..bc4d1fd5 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -69,6 +69,17 @@ describe('checkVersionOrPart', () => { } }); + test('return true for auto version', () => { + expect( + checkVersionOrPart( + { + newVersion: 'auto', + }, + null + ) + ).toBe(true); + }); + test('throw an error for invalid version', () => { const invalidVersions = [ { diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index d9429410..975a4456 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -8,6 +8,7 @@ import { getConfiguration, DEFAULT_RELEASE_BRANCH_NAME, getGlobalGitHubConfig, + requiresMinVersion, } from '../config'; import { logger } from '../logger'; import { ChangelogPolicy } from '../schemas/project_config'; @@ -26,6 +27,7 @@ import { reportError, } from '../utils/errors'; import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; +import { getAutoVersion } from '../utils/autoVersion'; import { isDryRun, promptConfirmation } from '../utils/helpers'; import { formatJson } from '../utils/strings'; import { spawnProcess } from '../utils/system'; @@ -40,10 +42,14 @@ export const description = '🚢 Prepare a new release branch'; /** Default path to bump-version script, relative to project root */ const DEFAULT_BUMP_VERSION_PATH = join('scripts', 'bump-version.sh'); +/** Minimum craft version required for auto-versioning */ +const AUTO_VERSION_MIN_VERSION = '2.14.0'; + export const builder: CommandBuilder = (yargs: Argv) => yargs .positional('NEW-VERSION', { - description: 'The new version you want to release', + description: + 'The new version you want to release, or "auto" to determine automatically from conventional commits (requires minVersion >= 2.14.0 in .craft.yml)', type: 'string', }) .option('rev', { @@ -106,14 +112,20 @@ const SLEEP_BEFORE_PUBLISH_SECONDS = 30; /** * Checks the provided version argument for validity * - * We check that the argument is either a valid version string, or a valid - * semantic version part. + * We check that the argument is either a valid version string, 'auto' for + * automatic version detection, or a valid semantic version part. * * @param argv Parsed yargs arguments * @param _opt A list of options and aliases */ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { const version = argv.newVersion; + + // Allow 'auto' for automatic version detection + if (version === 'auto') { + return true; + } + if (['major', 'minor', 'patch'].indexOf(version) > -1) { throw Error('Version part is not supported yet'); } else if (isValidVersion(version)) { @@ -469,7 +481,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { // Get repo configuration const config = getConfiguration(); const githubConfig = await getGlobalGitHubConfig(); - const newVersion = argv.newVersion; + let newVersion = argv.newVersion; const git = await getGitClient(); @@ -485,6 +497,18 @@ export async function prepareMain(argv: PrepareOptions): Promise { checkGitStatus(repoStatus, rev); } + // Handle automatic version detection + if (newVersion === 'auto') { + if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { + throw new ConfigurationError( + `Auto-versioning requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` + + 'Please update your configuration or specify the version explicitly.' + ); + } + + newVersion = await getAutoVersion(git, rev); + } + logger.info(`Releasing version ${newVersion} from ${rev}`); if (!argv.rev && rev !== defaultBranch) { logger.warn("You're not on your default branch, so I have to ask..."); diff --git a/src/config.ts b/src/config.ts index ededbbac..0e64e504 100644 --- a/src/config.ts +++ b/src/config.ts @@ -202,6 +202,34 @@ function checkMinimalConfigVersion(config: CraftProjectConfig): void { } } +/** + * Checks if the project's minVersion configuration meets a required minimum. + * + * This is used to gate features that require a certain version of craft. + * For example, auto-versioning requires minVersion >= 2.14.0. + * + * @param requiredVersion The minimum version required for the feature + * @returns true if the project's minVersion is >= requiredVersion, false otherwise + */ +export function requiresMinVersion(requiredVersion: string): boolean { + const config = getConfiguration(); + const minVersionRaw = config.minVersion; + + if (!minVersionRaw) { + // If no minVersion is configured, the feature is not available + return false; + } + + const configuredMinVersion = parseVersion(minVersionRaw); + const required = parseVersion(requiredVersion); + + if (!configuredMinVersion || !required) { + return false; + } + + return versionGreaterOrEqualThan(configuredMinVersion, required); +} + /** * Return the parsed global GitHub configuration */ diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts new file mode 100644 index 00000000..a35e5f6b --- /dev/null +++ b/src/utils/__tests__/autoVersion.test.ts @@ -0,0 +1,566 @@ +/* eslint-env jest */ + +jest.mock('../githubApi.ts'); +jest.mock('../git'); +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn(), +})); +jest.mock('../../config', () => ({ + ...jest.requireActual('../../config'), + getConfigFileDir: jest.fn(), + getGlobalGitHubConfig: jest.fn(), +})); + +import { readFileSync } from 'fs'; +import type { SimpleGit } from 'simple-git'; + +import * as config from '../../config'; +import { getChangesSince, getLatestTag } from '../git'; +import { getGitHubClient } from '../githubApi'; +import { + BumpType, + analyzeCommitsForBump, + calculateNextVersion, + getAutoVersion, +} from '../autoVersion'; + +const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction< + typeof config.getConfigFileDir +>; +const getGlobalGitHubConfigMock = + config.getGlobalGitHubConfig as jest.MockedFunction< + typeof config.getGlobalGitHubConfig + >; +const readFileSyncMock = readFileSync as jest.MockedFunction< + typeof readFileSync +>; +const getChangesSinceMock = getChangesSince as jest.MockedFunction< + typeof getChangesSince +>; +const getLatestTagMock = getLatestTag as jest.MockedFunction< + typeof getLatestTag +>; + +describe('BumpType enum', () => { + test('Major > Minor > Patch for comparison', () => { + expect(BumpType.Major).toBeGreaterThan(BumpType.Minor); + expect(BumpType.Minor).toBeGreaterThan(BumpType.Patch); + expect(BumpType.Major).toBeGreaterThan(BumpType.Patch); + }); +}); + +describe('calculateNextVersion', () => { + test('increments major version', () => { + expect(calculateNextVersion('1.2.3', BumpType.Major)).toBe('2.0.0'); + }); + + test('increments minor version', () => { + expect(calculateNextVersion('1.2.3', BumpType.Minor)).toBe('1.3.0'); + }); + + test('increments patch version', () => { + expect(calculateNextVersion('1.2.3', BumpType.Patch)).toBe('1.2.4'); + }); + + test('handles empty version as 0.0.0', () => { + expect(calculateNextVersion('', BumpType.Patch)).toBe('0.0.1'); + expect(calculateNextVersion('', BumpType.Minor)).toBe('0.1.0'); + expect(calculateNextVersion('', BumpType.Major)).toBe('1.0.0'); + }); + + test('handles prerelease versions', () => { + // Semver patch on prerelease "releases" it (removes prerelease suffix) + expect(calculateNextVersion('1.2.3-beta.1', BumpType.Patch)).toBe('1.2.3'); + // Minor bump on prerelease increments minor and removes prerelease + expect(calculateNextVersion('1.2.3-rc.0', BumpType.Minor)).toBe('1.3.0'); + }); +}); + +describe('analyzeCommitsForBump', () => { + const mockGit = {} as SimpleGit; + + beforeEach(() => { + jest.clearAllMocks(); + // Default: no release.yml file, use DEFAULT_RELEASE_CONFIG + getConfigFileDirMock.mockReturnValue('/test/repo'); + readFileSyncMock.mockImplementation(() => { + const error: NodeJS.ErrnoException = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + getGlobalGitHubConfigMock.mockResolvedValue({ + owner: 'testowner', + repo: 'testrepo', + }); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ repository: {} }), + }); + }); + + test('returns null bump type for no commits', async () => { + getChangesSinceMock.mockResolvedValue([]); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBeNull(); + expect(result.totalCommits).toBe(0); + expect(result.matchedCommits).toBe(0); + }); + + test('returns major bump for breaking changes', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Major); + expect(result.matchedCommits).toBe(1); + }); + + test('returns major bump for breaking changes with scope', async () => { + getChangesSinceMock.mockResolvedValue([ + { + hash: 'abc123', + title: 'fix(api)!: breaking fix', + body: '', + pr: null, + }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Major); + }); + + test('returns minor bump for features', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'feat: new feature', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Minor); + }); + + test('returns patch bump for fixes', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Patch); + }); + + test('returns patch bump for docs', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'docs: update readme', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Patch); + }); + + test('returns patch bump for chore', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'chore: cleanup', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Patch); + }); + + test('returns highest bump type when mixed commits (major wins)', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, + { hash: 'def456', title: 'feat: new feature', body: '', pr: null }, + { hash: 'ghi789', title: 'feat!: breaking change', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + Cdef456: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + Cghi789: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Major); + expect(result.matchedCommits).toBe(3); + }); + + test('returns minor when no major but has features', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, + { hash: 'def456', title: 'feat: new feature', body: '', pr: null }, + { hash: 'ghi789', title: 'docs: update docs', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + Cdef456: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + Cghi789: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Minor); + expect(result.matchedCommits).toBe(3); + }); + + test('skips commits with skip-changelog magic word', async () => { + getChangesSinceMock.mockResolvedValue([ + { + hash: 'abc123', + title: 'feat!: breaking change', + body: '#skip-changelog', + pr: null, + }, + { hash: 'def456', title: 'fix: bug fix', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cdef456: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + // Should be patch because the major commit was skipped + expect(result.bumpType).toBe(BumpType.Patch); + expect(result.totalCommits).toBe(1); + }); + + test('returns null bump type when no commits match categories with semver', async () => { + getChangesSinceMock.mockResolvedValue([ + { + hash: 'abc123', + title: 'random commit without conventional format', + body: '', + pr: null, + }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBeNull(); + expect(result.totalCommits).toBe(1); + expect(result.matchedCommits).toBe(0); + }); + + test('early exits when major bump is found', async () => { + // Put major commit first, followed by many others + const commits = [ + { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: null }, + ...Array.from({ length: 100 }, (_, i) => ({ + hash: `hash${i}`, + title: 'fix: bug fix', + body: '', + pr: null, + })), + ]; + getChangesSinceMock.mockResolvedValue(commits); + + const graphqlMock = jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + // Only add the first commit's data - if early exit works, others won't be needed + }, + }); + (getGitHubClient as jest.Mock).mockReturnValue({ graphql: graphqlMock }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Major); + // Due to early exit, matchedCommits should be 1 (just the major) + expect(result.matchedCommits).toBe(1); + }); + + test('uses custom release config semver values', async () => { + // Mock a custom release.yml with different semver mappings + readFileSyncMock.mockReturnValue(` +changelog: + categories: + - title: 'Custom Breaking' + commit_patterns: + - '^BREAKING:' + semver: major + - title: 'Custom Feature' + commit_patterns: + - '^FEATURE:' + semver: minor +`); + + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'FEATURE: custom feature', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe(BumpType.Minor); + }); + + test('returns null for categories without semver field', async () => { + // Mock a release.yml with no semver fields + readFileSyncMock.mockReturnValue(` +changelog: + categories: + - title: 'Features' + commit_patterns: + - '^feat:' +`); + + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'feat: new feature', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + + // Category matched but no semver field, so null + expect(result.bumpType).toBeNull(); + expect(result.totalCommits).toBe(1); + expect(result.matchedCommits).toBe(0); + }); +}); + +describe('getAutoVersion', () => { + const mockGit = {} as SimpleGit; + + beforeEach(() => { + jest.clearAllMocks(); + getConfigFileDirMock.mockReturnValue('/test/repo'); + readFileSyncMock.mockImplementation(() => { + const error: NodeJS.ErrnoException = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + getGlobalGitHubConfigMock.mockResolvedValue({ + owner: 'testowner', + repo: 'testrepo', + }); + }); + + test('calculates next version based on commits', async () => { + getLatestTagMock.mockResolvedValue('v1.0.0'); + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'feat: new feature', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const version = await getAutoVersion(mockGit); + + expect(version).toBe('1.1.0'); + }); + + test('throws error when no commits found', async () => { + getLatestTagMock.mockResolvedValue('v1.0.0'); + getChangesSinceMock.mockResolvedValue([]); + + await expect(getAutoVersion(mockGit)).rejects.toThrow( + 'Cannot determine version automatically: no commits found since the last release.' + ); + }); + + test('throws error when no commits match semver categories', async () => { + getLatestTagMock.mockResolvedValue('v1.0.0'); + getChangesSinceMock.mockResolvedValue([ + { + hash: 'abc123', + title: 'random commit without conventional format', + body: '', + pr: null, + }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + await expect(getAutoVersion(mockGit)).rejects.toThrow( + 'Cannot determine version automatically' + ); + }); + + test('handles new project with no tags', async () => { + getLatestTagMock.mockResolvedValue(''); + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'feat: initial feature', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const version = await getAutoVersion(mockGit); + + expect(version).toBe('0.1.0'); + }); + + test('uses specified revision instead of latest tag', async () => { + getChangesSinceMock.mockResolvedValue([ + { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, + ]); + (getGitHubClient as jest.Mock).mockReturnValue({ + graphql: jest.fn().mockResolvedValue({ + repository: { + Cabc123: { + author: { user: { login: 'testuser' } }, + associatedPullRequests: { nodes: [] }, + }, + }, + }), + }); + + const version = await getAutoVersion(mockGit, 'v2.0.0'); + + expect(version).toBe('2.0.1'); + expect(getLatestTagMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts new file mode 100644 index 00000000..59b04df1 --- /dev/null +++ b/src/utils/autoVersion.ts @@ -0,0 +1,288 @@ +import * as semver from 'semver'; +import type { SimpleGit } from 'simple-git'; + +import { logger } from '../logger'; +import { getChangesSince, getLatestTag } from './git'; +import { + readReleaseConfig, + normalizeReleaseConfig, + getPRAndLabelsFromCommit, + shouldExcludePR, + isCategoryExcluded, + SKIP_CHANGELOG_MAGIC_WORD, + type NormalizedReleaseConfig, + type NormalizedCategory, + type SemverBumpType, +} from './changelog'; +import { getVersion } from './version'; + +/** + * Enum representing version bump types with numeric values for comparison. + * Higher values indicate more significant changes. + * Using numeric values allows for easy max comparison and early exit. + */ +export enum BumpType { + Patch = 1, + Minor = 2, + Major = 3, +} + +/** + * Maps semver bump type strings to BumpType enum values + */ +const SEMVER_TO_BUMP_TYPE: Record = { + patch: BumpType.Patch, + minor: BumpType.Minor, + major: BumpType.Major, +}; + +/** + * Maps BumpType enum values back to semver release type strings + */ +const BUMP_TYPE_TO_SEMVER: Record = { + [BumpType.Patch]: 'patch', + [BumpType.Minor]: 'minor', + [BumpType.Major]: 'major', +}; + +/** + * Matches a commit/PR to a category and returns the category's semver bump type. + * Labels take precedence over commit log pattern matching. + * + * @returns The matched category with its semver bump type, or null if no match + */ +function matchCommitToCategory( + labels: Set, + author: string | undefined, + title: string, + config: NormalizedReleaseConfig +): NormalizedCategory | null { + if (config.changelog.categories.length === 0) { + return null; + } + + const regularCategories: NormalizedCategory[] = []; + let wildcardCategory: NormalizedCategory | null = null; + + for (const category of config.changelog.categories) { + // A category is valid if it has labels OR commit_patterns + if ( + category.labels.length === 0 && + category.commitLogPatterns.length === 0 + ) { + continue; + } + + if (category.labels.includes('*')) { + wildcardCategory = category; + continue; + } + + regularCategories.push(category); + } + + // First pass: try label matching (skip if no labels) + if (labels.size > 0) { + for (const category of regularCategories) { + const matchesCategory = category.labels.some(label => labels.has(label)); + if (matchesCategory && !isCategoryExcluded(category, labels, author)) { + return category; + } + } + } + + // Second pass: try commit_patterns matching + for (const category of regularCategories) { + const matchesPattern = category.commitLogPatterns.some(re => re.test(title)); + if (matchesPattern && !isCategoryExcluded(category, labels, author)) { + return category; + } + } + + if (wildcardCategory) { + if (isCategoryExcluded(wildcardCategory, labels, author)) { + return null; + } + return wildcardCategory; + } + + return null; +} + +/** + * Result of analyzing commits for version bump determination + */ +export interface BumpAnalysisResult { + /** The highest bump type found, or null if no commits matched categories with semver */ + bumpType: BumpType | null; + /** Number of commits analyzed */ + totalCommits: number; + /** Number of commits that matched a category with a semver field */ + matchedCommits: number; +} + +/** + * Analyzes commits to determine the highest version bump type needed. + * Uses early exit optimization - returns immediately when Major bump is found. + * + * @param git The SimpleGit instance + * @param rev The revision (tag) to start from + * @returns Analysis result with bump type and commit counts + */ +export async function analyzeCommitsForBump( + git: SimpleGit, + rev: string +): Promise { + const rawConfig = readReleaseConfig(); + const releaseConfig = normalizeReleaseConfig(rawConfig); + + if (!releaseConfig) { + return { bumpType: null, totalCommits: 0, matchedCommits: 0 }; + } + + // Get commits since the last tag + const gitCommits = (await getChangesSince(git, rev)).filter( + ({ body }) => !body.includes(SKIP_CHANGELOG_MAGIC_WORD) + ); + + if (gitCommits.length === 0) { + return { bumpType: null, totalCommits: 0, matchedCommits: 0 }; + } + + // Fetch PR metadata from GitHub for label matching + const githubCommits = await getPRAndLabelsFromCommit( + gitCommits.map(({ hash }) => hash) + ); + + let maxBumpType: BumpType | null = null; + let matchedCommits = 0; + + for (const gitCommit of gitCommits) { + const hash = gitCommit.hash; + const githubCommit = githubCommits[hash]; + + // Skip if PR body contains skip magic word + if (githubCommit?.prBody?.includes(SKIP_CHANGELOG_MAGIC_WORD)) { + continue; + } + + const labelsArray = githubCommit?.labels ?? []; + const labels = new Set(labelsArray); + const author = githubCommit?.author; + + // Skip if globally excluded + if (shouldExcludePR(labels, author, releaseConfig)) { + continue; + } + + // Use PR title if available, otherwise use commit title for pattern matching + const titleForMatching = githubCommit?.prTitle ?? gitCommit.title; + const matchedCategory = matchCommitToCategory( + labels, + author, + titleForMatching, + releaseConfig + ); + + // Only count commits that match a category with a semver field + if (matchedCategory?.semver) { + matchedCommits++; + const bumpType = SEMVER_TO_BUMP_TYPE[matchedCategory.semver]; + + // Update max if this is higher + if (maxBumpType === null || bumpType > maxBumpType) { + maxBumpType = bumpType; + + // Early exit: if we found a major bump, no need to continue + if (maxBumpType === BumpType.Major) { + logger.debug( + `Found major bump trigger in commit ${hash.slice(0, 8)}: "${titleForMatching}"` + ); + break; + } + } + } + } + + return { + bumpType: maxBumpType, + totalCommits: gitCommits.length, + matchedCommits, + }; +} + +/** + * Calculates the next version by applying the bump type to the current version. + * + * @param currentVersion The current version string (e.g., "1.2.3") + * @param bumpType The type of bump to apply + * @returns The new version string + * @throws Error if the version cannot be incremented + */ +export function calculateNextVersion( + currentVersion: string, + bumpType: BumpType +): string { + // Handle empty/missing current version (new project) + const versionToBump = currentVersion || '0.0.0'; + + const releaseType = BUMP_TYPE_TO_SEMVER[bumpType]; + const newVersion = semver.inc(versionToBump, releaseType); + + if (!newVersion) { + throw new Error( + `Failed to increment version "${versionToBump}" with bump type "${releaseType}"` + ); + } + + return newVersion; +} + +/** + * Automatically determines the next version based on conventional commits. + * + * @param git The SimpleGit instance + * @param rev Optional specific revision to analyze from (defaults to latest tag) + * @returns The calculated next version string + * @throws Error if no commits match categories with semver fields + */ +export async function getAutoVersion( + git: SimpleGit, + rev?: string +): Promise { + // Get the starting point - either specified rev or latest tag + const startRev = rev ?? (await getLatestTag(git)); + + logger.info( + `Analyzing commits since ${startRev || '(beginning of history)'} for auto-versioning...` + ); + + const analysis = await analyzeCommitsForBump(git, startRev); + + if (analysis.totalCommits === 0) { + throw new Error( + 'Cannot determine version automatically: no commits found since the last release.' + ); + } + + if (analysis.bumpType === null) { + throw new Error( + `Cannot determine version automatically: ${analysis.totalCommits} commit(s) found, ` + + 'but none matched a category with a "semver" field in the release configuration. ' + + 'Please ensure your .github/release.yml categories have "semver" fields defined, ' + + 'or specify the version explicitly.' + ); + } + + // Get current version from the tag + const currentVersion = getVersion(startRev) || '0.0.0'; + const newVersion = calculateNextVersion(currentVersion, analysis.bumpType); + + const bumpTypeName = BUMP_TYPE_TO_SEMVER[analysis.bumpType]; + logger.info( + `Auto-version: ${currentVersion} -> ${newVersion} (${bumpTypeName} bump, ` + + `${analysis.matchedCommits}/${analysis.totalCommits} commits matched)` + ); + + return newVersion; +} diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 6796bd7c..4e8d819c 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -265,20 +265,27 @@ interface Commit { category: string | null; } +/** + * Valid semver bump types for auto-versioning + */ +export type SemverBumpType = 'major' | 'minor' | 'patch'; + /** * Release configuration structure matching GitHub's release.yml format */ -interface ReleaseConfigCategory { +export interface ReleaseConfigCategory { title: string; labels?: string[]; commit_patterns?: string[]; + /** Semver bump type when commits match this category (for auto-versioning) */ + semver?: SemverBumpType; exclude?: { labels?: string[]; authors?: string[]; }; } -interface ReleaseConfig { +export interface ReleaseConfig { changelog?: { exclude?: { labels?: string[]; @@ -292,28 +299,33 @@ interface ReleaseConfig { * Default release configuration based on conventional commits * Used when .github/release.yml doesn't exist */ -const DEFAULT_RELEASE_CONFIG: ReleaseConfig = { +export const DEFAULT_RELEASE_CONFIG: ReleaseConfig = { changelog: { categories: [ { title: 'Breaking Changes 🛠', commit_patterns: ['^\\w+(?:\\([^)]+\\))?!:'], + semver: 'major', }, { title: 'New Features ✨', commit_patterns: ['^feat\\b'], + semver: 'minor', }, { title: 'Bug Fixes 🐛', commit_patterns: ['^fix\\b'], + semver: 'patch', }, { title: 'Documentation 📚', commit_patterns: ['^docs?\\b'], + semver: 'patch', }, { title: 'Build / dependencies / internal 🔧', commit_patterns: ['^(?:build|refactor|meta|chore|ci)\\b'], + semver: 'patch', }, ], }, @@ -323,7 +335,7 @@ const DEFAULT_RELEASE_CONFIG: ReleaseConfig = { * Normalized release config with Sets for efficient lookups * All fields are non-optional - use empty sets/arrays when not present */ -interface NormalizedReleaseConfig { +export interface NormalizedReleaseConfig { changelog: { exclude: { labels: Set; @@ -333,10 +345,12 @@ interface NormalizedReleaseConfig { }; } -interface NormalizedCategory { +export interface NormalizedCategory { title: string; labels: string[]; commitLogPatterns: RegExp[]; + /** Semver bump type when commits match this category (for auto-versioning) */ + semver?: SemverBumpType; exclude: { labels: Set; authors: Set; @@ -352,7 +366,7 @@ type CategoryWithPRs = { * Reads and parses .github/release.yml from the repository root * @returns Parsed release configuration, or the default config if file doesn't exist */ -function readReleaseConfig(): ReleaseConfig { +export function readReleaseConfig(): ReleaseConfig { const configFileDir = getConfigFileDir(); if (!configFileDir) { return DEFAULT_RELEASE_CONFIG; @@ -379,7 +393,7 @@ function readReleaseConfig(): ReleaseConfig { /** * Normalizes the release config by converting arrays to Sets and compiling regex patterns */ -function normalizeReleaseConfig( +export function normalizeReleaseConfig( config: ReleaseConfig ): NormalizedReleaseConfig | null { if (!config?.changelog) { @@ -436,6 +450,7 @@ function normalizeReleaseConfig( } }) .filter((r): r is RegExp => r !== null), + semver: category.semver, exclude: { labels: new Set(), authors: new Set(), @@ -466,7 +481,7 @@ function normalizeReleaseConfig( /** * Checks if a PR should be excluded globally based on release config */ -function shouldExcludePR( +export function shouldExcludePR( labels: Set, author: string | undefined, config: NormalizedReleaseConfig | null @@ -493,7 +508,7 @@ function shouldExcludePR( /** * Checks if a category excludes the given PR based on labels and author */ -function isCategoryExcluded( +export function isCategoryExcluded( category: NormalizedCategory, labels: Set, author: string | undefined @@ -887,7 +902,7 @@ interface CommitInfoResult { repository: CommitInfoMap; } -async function getPRAndLabelsFromCommit(hashes: string[]): Promise< +export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< Record< /* hash */ string, { From 7ee9a2401ddc2664b5a0874d8018295f3f89a8ed Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 00:04:37 +0000 Subject: [PATCH 2/8] feat: Add version bump types (major/minor/patch) Implement the previously stubbed version bump types feature. Users can now run: - `craft prepare major` - bump major version - `craft prepare minor` - bump minor version - `craft prepare patch` - bump patch version Like auto-versioning, this requires minVersion >= 2.14.0 in .craft.yml. Also update README with comprehensive documentation for all version specification options including auto-versioning and bump types. --- README.md | 45 ++++++++++++++++++- src/commands/__tests__/prepare.test.ts | 17 +++++-- src/commands/prepare.ts | 62 +++++++++++++++++++++----- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 182fc7e5..abbd290e 100644 --- a/README.md +++ b/README.md @@ -236,13 +236,56 @@ that CI triggered by pushing this branch will result in release artifacts being built and uploaded to the artifact provider you wish to use during the subsequent `publish` step. +**Version Specification** + +The `NEW-VERSION` argument can be specified in three ways: + +1. **Explicit version** (e.g., `1.2.3`): Release with the specified version +2. **Bump type** (`major`, `minor`, or `patch`): Automatically increment the latest tag +3. **Auto** (`auto`): Analyze commits since the last tag and determine bump type from conventional commit patterns + +The bump type and auto options require `minVersion: '2.14.0'` or higher in `.craft.yml`. + +**Auto-versioning Details** + +When using `auto`, craft analyzes commits since the last tag and matches them against +categories in `.github/release.yml` (or the default conventional commits config). +Each category can have a `semver` field (`major`, `minor`, or `patch`) that determines +the version bump. The highest bump type across all matched commits is used: + +- Breaking changes (e.g., `feat!:`, `fix!:`) trigger a **major** bump +- New features (`feat:`) trigger a **minor** bump +- Bug fixes, docs, chores trigger a **patch** bump + +Example `.github/release.yml` with semver fields: + +```yaml +changelog: + categories: + - title: Breaking Changes + commit_patterns: + - '^\w+(\(\w+\))?!:' + semver: major + - title: Features + commit_patterns: + - '^feat(\(\w+\))?:' + semver: minor + - title: Bug Fixes + commit_patterns: + - '^fix(\(\w+\))?:' + semver: patch +``` + ```shell craft prepare NEW-VERSION 🚢 Prepare a new release branch Positionals: - NEW-VERSION The new version you want to release [string] [required] + NEW-VERSION The new version to release. Can be: a semver string (e.g., + "1.2.3"), a bump type ("major", "minor", or "patch"), or "auto" + to determine automatically from conventional commits. + [string] [required] Options: --no-input Suppresses all user prompts [default: false] diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts index bc4d1fd5..d280fcde 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -80,6 +80,20 @@ describe('checkVersionOrPart', () => { ).toBe(true); }); + test('return true for version bump types', () => { + const bumpTypes = ['major', 'minor', 'patch']; + for (const bumpType of bumpTypes) { + expect( + checkVersionOrPart( + { + newVersion: bumpType, + }, + null + ) + ).toBe(true); + } + }); + test('throw an error for invalid version', () => { const invalidVersions = [ { @@ -91,9 +105,6 @@ describe('checkVersionOrPart', () => { e: 'Invalid version or version part specified: "v2.3.3". Removing the "v" prefix will likely fix the issue', }, - { v: 'major', e: 'Version part is not supported yet' }, - { v: 'minor', e: 'Version part is not supported yet' }, - { v: 'patch', e: 'Version part is not supported yet' }, ]; for (const t of invalidVersions) { const fn = () => { diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 975a4456..b878e613 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -27,7 +27,11 @@ import { reportError, } from '../utils/errors'; import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; -import { getAutoVersion } from '../utils/autoVersion'; +import { + getAutoVersion, + calculateNextVersion, + BumpType, +} from '../utils/autoVersion'; import { isDryRun, promptConfirmation } from '../utils/helpers'; import { formatJson } from '../utils/strings'; import { spawnProcess } from '../utils/system'; @@ -49,7 +53,9 @@ export const builder: CommandBuilder = (yargs: Argv) => yargs .positional('NEW-VERSION', { description: - 'The new version you want to release, or "auto" to determine automatically from conventional commits (requires minVersion >= 2.14.0 in .craft.yml)', + 'The new version to release. Can be: a semver string (e.g., "1.2.3"), ' + + 'a bump type ("major", "minor", or "patch"), or "auto" to determine automatically ' + + 'from conventional commits. Bump types and "auto" require minVersion >= 2.14.0 in .craft.yml', type: 'string', }) .option('rev', { @@ -109,11 +115,16 @@ interface PrepareOptions { */ const SLEEP_BEFORE_PUBLISH_SECONDS = 30; +/** Valid version bump types */ +const VERSION_BUMP_TYPES = ['major', 'minor', 'patch'] as const; +type VersionBumpType = (typeof VERSION_BUMP_TYPES)[number]; + /** * Checks the provided version argument for validity * * We check that the argument is either a valid version string, 'auto' for - * automatic version detection, or a valid semantic version part. + * automatic version detection, a version bump type (major/minor/patch), or + * a valid semantic version. * * @param argv Parsed yargs arguments * @param _opt A list of options and aliases @@ -126,9 +137,12 @@ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { return true; } - if (['major', 'minor', 'patch'].indexOf(version) > -1) { - throw Error('Version part is not supported yet'); - } else if (isValidVersion(version)) { + // Allow version bump types (major, minor, patch) + if (VERSION_BUMP_TYPES.includes(version as VersionBumpType)) { + return true; + } + + if (isValidVersion(version)) { return true; } else { let errMsg = `Invalid version or version part specified: "${version}"`; @@ -497,16 +511,44 @@ export async function prepareMain(argv: PrepareOptions): Promise { checkGitStatus(repoStatus, rev); } - // Handle automatic version detection - if (newVersion === 'auto') { + // Handle automatic version detection or version bump types + const isVersionBumpType = VERSION_BUMP_TYPES.includes( + newVersion as VersionBumpType + ); + + if (newVersion === 'auto' || isVersionBumpType) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { + const featureName = isVersionBumpType + ? 'Version bump types' + : 'Auto-versioning'; throw new ConfigurationError( - `Auto-versioning requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` + + `${featureName} requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` + 'Please update your configuration or specify the version explicitly.' ); } - newVersion = await getAutoVersion(git, rev); + if (newVersion === 'auto') { + newVersion = await getAutoVersion(git, rev); + } else { + // Handle explicit version bump type (major, minor, patch) + const latestTag = await getLatestTag(git); + const currentVersion = + latestTag && latestTag.replace(/^v/, '').match(/^\d/) + ? latestTag.replace(/^v/, '') + : '0.0.0'; + + const bumpTypeMap: Record = { + major: BumpType.Major, + minor: BumpType.Minor, + patch: BumpType.Patch, + }; + const bumpType = bumpTypeMap[newVersion as VersionBumpType]; + newVersion = calculateNextVersion(currentVersion, bumpType); + + logger.info( + `Version bump: ${currentVersion || '(none)'} -> ${newVersion} (${argv.newVersion} bump)` + ); + } } logger.info(`Releasing version ${newVersion} from ${rev}`); From b63fca5fd34d084a424be3f2b0c7333668a21488 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 00:27:48 +0000 Subject: [PATCH 3/8] refactor: Simplify auto-version to return BumpType Refactor getAutoVersion to getAutoBumpType which returns a BumpType enum instead of the computed version string. This allows prepareMain to handle both "auto" and explicit bump types (major/minor/patch) with unified logic: 1. Get latest tag 2. Determine bump type (from arg or commit analysis) 3. Calculate new version using calculateNextVersion This increases code reuse and simplifies the prepare command logic. --- src/commands/prepare.ts | 47 +++++++++++++------------ src/utils/__tests__/autoVersion.test.ts | 40 +++++++++------------ src/utils/autoVersion.ts | 32 +++++++---------- 3 files changed, 52 insertions(+), 67 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index b878e613..1718b3ae 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -28,7 +28,7 @@ import { } from '../utils/errors'; import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; import { - getAutoVersion, + getAutoBumpType, calculateNextVersion, BumpType, } from '../utils/autoVersion'; @@ -527,28 +527,29 @@ export async function prepareMain(argv: PrepareOptions): Promise { ); } - if (newVersion === 'auto') { - newVersion = await getAutoVersion(git, rev); - } else { - // Handle explicit version bump type (major, minor, patch) - const latestTag = await getLatestTag(git); - const currentVersion = - latestTag && latestTag.replace(/^v/, '').match(/^\d/) - ? latestTag.replace(/^v/, '') - : '0.0.0'; - - const bumpTypeMap: Record = { - major: BumpType.Major, - minor: BumpType.Minor, - patch: BumpType.Patch, - }; - const bumpType = bumpTypeMap[newVersion as VersionBumpType]; - newVersion = calculateNextVersion(currentVersion, bumpType); - - logger.info( - `Version bump: ${currentVersion || '(none)'} -> ${newVersion} (${argv.newVersion} bump)` - ); - } + // Determine bump type - either from arg or from commit analysis + const bumpTypeMap: Record = { + major: BumpType.Major, + minor: BumpType.Minor, + patch: BumpType.Patch, + }; + + const latestTag = await getLatestTag(git); + const bumpType = + newVersion === 'auto' + ? await getAutoBumpType(git, latestTag) + : bumpTypeMap[newVersion as VersionBumpType]; + + // Calculate new version from latest tag + const currentVersion = + latestTag && latestTag.replace(/^v/, '').match(/^\d/) + ? latestTag.replace(/^v/, '') + : '0.0.0'; + + newVersion = calculateNextVersion(currentVersion, bumpType); + logger.info( + `Version bump: ${currentVersion || '(none)'} -> ${newVersion} (${argv.newVersion} bump)` + ); } logger.info(`Releasing version ${newVersion} from ${rev}`); diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index a35e5f6b..41ed48ca 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -16,13 +16,13 @@ import { readFileSync } from 'fs'; import type { SimpleGit } from 'simple-git'; import * as config from '../../config'; -import { getChangesSince, getLatestTag } from '../git'; +import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; import { BumpType, analyzeCommitsForBump, calculateNextVersion, - getAutoVersion, + getAutoBumpType, } from '../autoVersion'; const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction< @@ -38,9 +38,6 @@ const readFileSyncMock = readFileSync as jest.MockedFunction< const getChangesSinceMock = getChangesSince as jest.MockedFunction< typeof getChangesSince >; -const getLatestTagMock = getLatestTag as jest.MockedFunction< - typeof getLatestTag ->; describe('BumpType enum', () => { test('Major > Minor > Patch for comparison', () => { @@ -449,7 +446,7 @@ changelog: }); }); -describe('getAutoVersion', () => { +describe('getAutoBumpType', () => { const mockGit = {} as SimpleGit; beforeEach(() => { @@ -466,8 +463,7 @@ describe('getAutoVersion', () => { }); }); - test('calculates next version based on commits', async () => { - getLatestTagMock.mockResolvedValue('v1.0.0'); + test('returns minor bump type for feature commits', async () => { getChangesSinceMock.mockResolvedValue([ { hash: 'abc123', title: 'feat: new feature', body: '', pr: null }, ]); @@ -482,22 +478,20 @@ describe('getAutoVersion', () => { }), }); - const version = await getAutoVersion(mockGit); + const bumpType = await getAutoBumpType(mockGit, 'v1.0.0'); - expect(version).toBe('1.1.0'); + expect(bumpType).toBe(BumpType.Minor); }); test('throws error when no commits found', async () => { - getLatestTagMock.mockResolvedValue('v1.0.0'); getChangesSinceMock.mockResolvedValue([]); - await expect(getAutoVersion(mockGit)).rejects.toThrow( + await expect(getAutoBumpType(mockGit, 'v1.0.0')).rejects.toThrow( 'Cannot determine version automatically: no commits found since the last release.' ); }); test('throws error when no commits match semver categories', async () => { - getLatestTagMock.mockResolvedValue('v1.0.0'); getChangesSinceMock.mockResolvedValue([ { hash: 'abc123', @@ -517,15 +511,14 @@ describe('getAutoVersion', () => { }), }); - await expect(getAutoVersion(mockGit)).rejects.toThrow( + await expect(getAutoBumpType(mockGit, 'v1.0.0')).rejects.toThrow( 'Cannot determine version automatically' ); }); - test('handles new project with no tags', async () => { - getLatestTagMock.mockResolvedValue(''); + test('returns patch bump type for fix commits', async () => { getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'feat: initial feature', body: '', pr: null }, + { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, ]); (getGitHubClient as jest.Mock).mockReturnValue({ graphql: jest.fn().mockResolvedValue({ @@ -538,14 +531,14 @@ describe('getAutoVersion', () => { }), }); - const version = await getAutoVersion(mockGit); + const bumpType = await getAutoBumpType(mockGit, 'v2.0.0'); - expect(version).toBe('0.1.0'); + expect(bumpType).toBe(BumpType.Patch); }); - test('uses specified revision instead of latest tag', async () => { + test('returns major bump type for breaking changes', async () => { getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, + { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: null }, ]); (getGitHubClient as jest.Mock).mockReturnValue({ graphql: jest.fn().mockResolvedValue({ @@ -558,9 +551,8 @@ describe('getAutoVersion', () => { }), }); - const version = await getAutoVersion(mockGit, 'v2.0.0'); + const bumpType = await getAutoBumpType(mockGit, ''); - expect(version).toBe('2.0.1'); - expect(getLatestTagMock).not.toHaveBeenCalled(); + expect(bumpType).toBe(BumpType.Major); }); }); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index 59b04df1..b6fe0c3c 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -2,7 +2,7 @@ import * as semver from 'semver'; import type { SimpleGit } from 'simple-git'; import { logger } from '../logger'; -import { getChangesSince, getLatestTag } from './git'; +import { getChangesSince } from './git'; import { readReleaseConfig, normalizeReleaseConfig, @@ -14,7 +14,6 @@ import { type NormalizedCategory, type SemverBumpType, } from './changelog'; -import { getVersion } from './version'; /** * Enum representing version bump types with numeric values for comparison. @@ -239,25 +238,22 @@ export function calculateNextVersion( } /** - * Automatically determines the next version based on conventional commits. + * Automatically determines the version bump type based on conventional commits. * * @param git The SimpleGit instance - * @param rev Optional specific revision to analyze from (defaults to latest tag) - * @returns The calculated next version string + * @param rev The revision (tag) to analyze from + * @returns The determined bump type * @throws Error if no commits match categories with semver fields */ -export async function getAutoVersion( +export async function getAutoBumpType( git: SimpleGit, - rev?: string -): Promise { - // Get the starting point - either specified rev or latest tag - const startRev = rev ?? (await getLatestTag(git)); - + rev: string +): Promise { logger.info( - `Analyzing commits since ${startRev || '(beginning of history)'} for auto-versioning...` + `Analyzing commits since ${rev || '(beginning of history)'} for auto-versioning...` ); - const analysis = await analyzeCommitsForBump(git, startRev); + const analysis = await analyzeCommitsForBump(git, rev); if (analysis.totalCommits === 0) { throw new Error( @@ -274,15 +270,11 @@ export async function getAutoVersion( ); } - // Get current version from the tag - const currentVersion = getVersion(startRev) || '0.0.0'; - const newVersion = calculateNextVersion(currentVersion, analysis.bumpType); - const bumpTypeName = BUMP_TYPE_TO_SEMVER[analysis.bumpType]; logger.info( - `Auto-version: ${currentVersion} -> ${newVersion} (${bumpTypeName} bump, ` + - `${analysis.matchedCommits}/${analysis.totalCommits} commits matched)` + `Auto-version: determined ${bumpTypeName} bump ` + + `(${analysis.matchedCommits}/${analysis.totalCommits} commits matched)` ); - return newVersion; + return analysis.bumpType; } From f249b3ad269fabca5e44f6fcb6578cd48f70e213 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 00:33:07 +0000 Subject: [PATCH 4/8] refactor: Use string-based BumpType with priority ordering Replace BumpType enum with simple string literal type and BUMP_TYPES array ordered by priority (major > minor > patch). This allows: 1. Direct use as semver.inc() argument (no mapping needed) 2. Priority lookup via array index (first found = highest priority) 3. Single source of truth for bump types shared between autoVersion and prepare The analyzeCommitsForBump function now: - Collects found bump types in a Set during commit iteration - Early exits on 'major' (no need to check more) - Returns first match from BUMP_TYPES array (highest priority) --- src/commands/prepare.ts | 28 +++----- src/utils/__tests__/autoVersion.test.ts | 62 +++++++++--------- src/utils/autoVersion.ts | 87 ++++++++++--------------- 3 files changed, 74 insertions(+), 103 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 1718b3ae..f4cfa219 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -30,7 +30,8 @@ import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; import { getAutoBumpType, calculateNextVersion, - BumpType, + BUMP_TYPES, + type BumpType, } from '../utils/autoVersion'; import { isDryRun, promptConfirmation } from '../utils/helpers'; import { formatJson } from '../utils/strings'; @@ -115,10 +116,6 @@ interface PrepareOptions { */ const SLEEP_BEFORE_PUBLISH_SECONDS = 30; -/** Valid version bump types */ -const VERSION_BUMP_TYPES = ['major', 'minor', 'patch'] as const; -type VersionBumpType = (typeof VERSION_BUMP_TYPES)[number]; - /** * Checks the provided version argument for validity * @@ -138,7 +135,7 @@ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { } // Allow version bump types (major, minor, patch) - if (VERSION_BUMP_TYPES.includes(version as VersionBumpType)) { + if (BUMP_TYPES.includes(version as BumpType)) { return true; } @@ -512,9 +509,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { } // Handle automatic version detection or version bump types - const isVersionBumpType = VERSION_BUMP_TYPES.includes( - newVersion as VersionBumpType - ); + const isVersionBumpType = BUMP_TYPES.includes(newVersion as BumpType); if (newVersion === 'auto' || isVersionBumpType) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { @@ -527,18 +522,13 @@ export async function prepareMain(argv: PrepareOptions): Promise { ); } - // Determine bump type - either from arg or from commit analysis - const bumpTypeMap: Record = { - major: BumpType.Major, - minor: BumpType.Minor, - patch: BumpType.Patch, - }; - const latestTag = await getLatestTag(git); - const bumpType = + + // Determine bump type - either from arg or from commit analysis + const bumpType: BumpType = newVersion === 'auto' ? await getAutoBumpType(git, latestTag) - : bumpTypeMap[newVersion as VersionBumpType]; + : (newVersion as BumpType); // Calculate new version from latest tag const currentVersion = @@ -548,7 +538,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { newVersion = calculateNextVersion(currentVersion, bumpType); logger.info( - `Version bump: ${currentVersion || '(none)'} -> ${newVersion} (${argv.newVersion} bump)` + `Version bump: ${currentVersion || '(none)'} -> ${newVersion} (${bumpType} bump)` ); } diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index 41ed48ca..b3626d4a 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -19,7 +19,7 @@ import * as config from '../../config'; import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; import { - BumpType, + BUMP_TYPES, analyzeCommitsForBump, calculateNextVersion, getAutoBumpType, @@ -39,38 +39,42 @@ const getChangesSinceMock = getChangesSince as jest.MockedFunction< typeof getChangesSince >; -describe('BumpType enum', () => { - test('Major > Minor > Patch for comparison', () => { - expect(BumpType.Major).toBeGreaterThan(BumpType.Minor); - expect(BumpType.Minor).toBeGreaterThan(BumpType.Patch); - expect(BumpType.Major).toBeGreaterThan(BumpType.Patch); +describe('BUMP_TYPES', () => { + test('ordered by priority: major > minor > patch', () => { + expect(BUMP_TYPES).toEqual(['major', 'minor', 'patch']); + }); + + test('major has lowest index (highest priority)', () => { + expect(BUMP_TYPES.indexOf('major')).toBe(0); + expect(BUMP_TYPES.indexOf('minor')).toBe(1); + expect(BUMP_TYPES.indexOf('patch')).toBe(2); }); }); describe('calculateNextVersion', () => { test('increments major version', () => { - expect(calculateNextVersion('1.2.3', BumpType.Major)).toBe('2.0.0'); + expect(calculateNextVersion('1.2.3', 'major')).toBe('2.0.0'); }); test('increments minor version', () => { - expect(calculateNextVersion('1.2.3', BumpType.Minor)).toBe('1.3.0'); + expect(calculateNextVersion('1.2.3', 'minor')).toBe('1.3.0'); }); test('increments patch version', () => { - expect(calculateNextVersion('1.2.3', BumpType.Patch)).toBe('1.2.4'); + expect(calculateNextVersion('1.2.3', 'patch')).toBe('1.2.4'); }); test('handles empty version as 0.0.0', () => { - expect(calculateNextVersion('', BumpType.Patch)).toBe('0.0.1'); - expect(calculateNextVersion('', BumpType.Minor)).toBe('0.1.0'); - expect(calculateNextVersion('', BumpType.Major)).toBe('1.0.0'); + expect(calculateNextVersion('', 'patch')).toBe('0.0.1'); + expect(calculateNextVersion('', 'minor')).toBe('0.1.0'); + expect(calculateNextVersion('', 'major')).toBe('1.0.0'); }); test('handles prerelease versions', () => { // Semver patch on prerelease "releases" it (removes prerelease suffix) - expect(calculateNextVersion('1.2.3-beta.1', BumpType.Patch)).toBe('1.2.3'); + expect(calculateNextVersion('1.2.3-beta.1', 'patch')).toBe('1.2.3'); // Minor bump on prerelease increments minor and removes prerelease - expect(calculateNextVersion('1.2.3-rc.0', BumpType.Minor)).toBe('1.3.0'); + expect(calculateNextVersion('1.2.3-rc.0', 'minor')).toBe('1.3.0'); }); }); @@ -122,7 +126,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Major); + expect(result.bumpType).toBe('major'); expect(result.matchedCommits).toBe(1); }); @@ -148,7 +152,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Major); + expect(result.bumpType).toBe('major'); }); test('returns minor bump for features', async () => { @@ -168,7 +172,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Minor); + expect(result.bumpType).toBe('minor'); }); test('returns patch bump for fixes', async () => { @@ -188,7 +192,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Patch); + expect(result.bumpType).toBe('patch'); }); test('returns patch bump for docs', async () => { @@ -208,7 +212,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Patch); + expect(result.bumpType).toBe('patch'); }); test('returns patch bump for chore', async () => { @@ -228,7 +232,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Patch); + expect(result.bumpType).toBe('patch'); }); test('returns highest bump type when mixed commits (major wins)', async () => { @@ -258,8 +262,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Major); - expect(result.matchedCommits).toBe(3); + expect(result.bumpType).toBe('major'); }); test('returns minor when no major but has features', async () => { @@ -289,8 +292,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Minor); - expect(result.matchedCommits).toBe(3); + expect(result.bumpType).toBe('minor'); }); test('skips commits with skip-changelog magic word', async () => { @@ -317,7 +319,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); // Should be patch because the major commit was skipped - expect(result.bumpType).toBe(BumpType.Patch); + expect(result.bumpType).toBe('patch'); expect(result.totalCommits).toBe(1); }); @@ -374,7 +376,7 @@ describe('analyzeCommitsForBump', () => { const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Major); + expect(result.bumpType).toBe('major'); // Due to early exit, matchedCommits should be 1 (just the major) expect(result.matchedCommits).toBe(1); }); @@ -410,7 +412,7 @@ changelog: const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - expect(result.bumpType).toBe(BumpType.Minor); + expect(result.bumpType).toBe('minor'); }); test('returns null for categories without semver field', async () => { @@ -480,7 +482,7 @@ describe('getAutoBumpType', () => { const bumpType = await getAutoBumpType(mockGit, 'v1.0.0'); - expect(bumpType).toBe(BumpType.Minor); + expect(bumpType).toBe('minor'); }); test('throws error when no commits found', async () => { @@ -533,7 +535,7 @@ describe('getAutoBumpType', () => { const bumpType = await getAutoBumpType(mockGit, 'v2.0.0'); - expect(bumpType).toBe(BumpType.Patch); + expect(bumpType).toBe('patch'); }); test('returns major bump type for breaking changes', async () => { @@ -553,6 +555,6 @@ describe('getAutoBumpType', () => { const bumpType = await getAutoBumpType(mockGit, ''); - expect(bumpType).toBe(BumpType.Major); + expect(bumpType).toBe('major'); }); }); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index b6fe0c3c..816c6329 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -12,43 +12,20 @@ import { SKIP_CHANGELOG_MAGIC_WORD, type NormalizedReleaseConfig, type NormalizedCategory, - type SemverBumpType, } from './changelog'; /** - * Enum representing version bump types with numeric values for comparison. - * Higher values indicate more significant changes. - * Using numeric values allows for easy max comparison and early exit. + * Version bump types ordered by priority (highest to lowest). + * Used for both validation and determining the highest bump type. */ -export enum BumpType { - Patch = 1, - Minor = 2, - Major = 3, -} - -/** - * Maps semver bump type strings to BumpType enum values - */ -const SEMVER_TO_BUMP_TYPE: Record = { - patch: BumpType.Patch, - minor: BumpType.Minor, - major: BumpType.Major, -}; - -/** - * Maps BumpType enum values back to semver release type strings - */ -const BUMP_TYPE_TO_SEMVER: Record = { - [BumpType.Patch]: 'patch', - [BumpType.Minor]: 'minor', - [BumpType.Major]: 'major', -}; +export const BUMP_TYPES = ['major', 'minor', 'patch'] as const; +export type BumpType = (typeof BUMP_TYPES)[number]; /** - * Matches a commit/PR to a category and returns the category's semver bump type. + * Matches a commit/PR to a category and returns the category. * Labels take precedence over commit log pattern matching. * - * @returns The matched category with its semver bump type, or null if no match + * @returns The matched category, or null if no match */ function matchCommitToCategory( labels: Set, @@ -122,7 +99,8 @@ export interface BumpAnalysisResult { /** * Analyzes commits to determine the highest version bump type needed. - * Uses early exit optimization - returns immediately when Major bump is found. + * Checks bump types in priority order (major > minor > patch) and returns + * the first one found. * * @param git The SimpleGit instance * @param rev The revision (tag) to start from @@ -153,8 +131,8 @@ export async function analyzeCommitsForBump( gitCommits.map(({ hash }) => hash) ); - let maxBumpType: BumpType | null = null; - let matchedCommits = 0; + // Collect all bump types found in commits + const foundBumpTypes = new Set(); for (const gitCommit of gitCommits) { const hash = gitCommit.hash; @@ -183,30 +161,33 @@ export async function analyzeCommitsForBump( releaseConfig ); - // Only count commits that match a category with a semver field + // Collect bump type if category has one if (matchedCategory?.semver) { - matchedCommits++; - const bumpType = SEMVER_TO_BUMP_TYPE[matchedCategory.semver]; - - // Update max if this is higher - if (maxBumpType === null || bumpType > maxBumpType) { - maxBumpType = bumpType; - - // Early exit: if we found a major bump, no need to continue - if (maxBumpType === BumpType.Major) { - logger.debug( - `Found major bump trigger in commit ${hash.slice(0, 8)}: "${titleForMatching}"` - ); - break; - } + foundBumpTypes.add(matchedCategory.semver); + + // Early exit: if we found major, no need to continue + if (matchedCategory.semver === 'major') { + logger.debug( + `Found major bump trigger in commit ${hash.slice(0, 8)}: "${titleForMatching}"` + ); + break; } } } + // Find highest priority bump type (BUMP_TYPES is ordered major > minor > patch) + let bumpType: BumpType | null = null; + for (const type of BUMP_TYPES) { + if (foundBumpTypes.has(type)) { + bumpType = type; + break; + } + } + return { - bumpType: maxBumpType, + bumpType, totalCommits: gitCommits.length, - matchedCommits, + matchedCommits: foundBumpTypes.size > 0 ? foundBumpTypes.size : 0, }; } @@ -225,12 +206,11 @@ export function calculateNextVersion( // Handle empty/missing current version (new project) const versionToBump = currentVersion || '0.0.0'; - const releaseType = BUMP_TYPE_TO_SEMVER[bumpType]; - const newVersion = semver.inc(versionToBump, releaseType); + const newVersion = semver.inc(versionToBump, bumpType); if (!newVersion) { throw new Error( - `Failed to increment version "${versionToBump}" with bump type "${releaseType}"` + `Failed to increment version "${versionToBump}" with bump type "${bumpType}"` ); } @@ -270,9 +250,8 @@ export async function getAutoBumpType( ); } - const bumpTypeName = BUMP_TYPE_TO_SEMVER[analysis.bumpType]; logger.info( - `Auto-version: determined ${bumpTypeName} bump ` + + `Auto-version: determined ${analysis.bumpType} bump ` + `(${analysis.matchedCommits}/${analysis.totalCommits} commits matched)` ); From 208f6495195f11430ec8a124b4543b941f953043 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 00:40:01 +0000 Subject: [PATCH 5/8] refactor: Merge auto-version and changelog generation to avoid duplicate work Previously, auto-versioning and changelog generation each independently: - Fetched commits since the last tag - Made GitHub API calls to get PR/label metadata - Iterated through commits to categorize them This was wasteful when both features are used together. Changes: - Move BUMP_TYPES and BumpType to changelog.ts (single source of truth) - Have generateChangesetFromGit return ChangelogResult with both: - changelog: the formatted changelog string - bumpType: the highest version bump determined from commits - Add matchCommitToCategory (exported) returning full NormalizedCategory - Simplify autoVersion.ts to just re-export types and provide: - calculateNextVersion: semver calculation - getChangelogWithBumpType: wrapper that validates and returns result - Update prepareMain to cache changelog result when using auto-versioning and pass it to prepareChangelog to avoid regenerating This eliminates duplicate GitHub API calls when using 'craft prepare auto' with changelog generation enabled. --- src/commands/prepare.ts | 34 +- src/utils/__tests__/autoVersion.test.ts | 430 +++--------------------- src/utils/__tests__/changelog.test.ts | 93 +++-- src/utils/autoVersion.ts | 212 +----------- src/utils/changelog.ts | 65 +++- 5 files changed, 203 insertions(+), 631 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index f4cfa219..2cc23382 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -20,6 +20,7 @@ import { removeChangeset, prependChangeset, generateChangesetFromGit, + type ChangelogResult, } from '../utils/changelog'; import { ConfigurationError, @@ -28,7 +29,7 @@ import { } from '../utils/errors'; import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; import { - getAutoBumpType, + getChangelogWithBumpType, calculateNextVersion, BUMP_TYPES, type BumpType, @@ -372,13 +373,15 @@ async function execPublish(remote: string, newVersion: string): Promise { * @param newVersion The new version we are releasing * @param changelogPolicy One of the changelog policies, such as "none", "simple", etc. * @param changelogPath Path to the changelog file + * @param cachedChangelog Optional pre-computed changelog result (to avoid duplicate work) */ async function prepareChangelog( git: SimpleGit, oldVersion: string, newVersion: string, changelogPolicy: ChangelogPolicy = ChangelogPolicy.None, - changelogPath: string = DEFAULT_CHANGELOG_PATH + changelogPath: string = DEFAULT_CHANGELOG_PATH, + cachedChangelog?: ChangelogResult ): Promise { if (changelogPolicy === ChangelogPolicy.None) { logger.debug( @@ -426,7 +429,13 @@ async function prepareChangelog( } if (!changeset.body) { replaceSection = changeset.name; - changeset.body = await generateChangesetFromGit(git, oldVersion); + // Use cached changelog if available, otherwise generate it + if (cachedChangelog) { + changeset.body = cachedChangelog.changelog; + } else { + const result = await generateChangesetFromGit(git, oldVersion); + changeset.body = result.changelog; + } } if (changeset.name === DEFAULT_UNRELEASED_TITLE) { replaceSection = changeset.name; @@ -510,6 +519,8 @@ export async function prepareMain(argv: PrepareOptions): Promise { // Handle automatic version detection or version bump types const isVersionBumpType = BUMP_TYPES.includes(newVersion as BumpType); + // Cache changelog result when using auto-versioning (to avoid duplicate GitHub API calls) + let cachedChangelogResult: ChangelogResult | undefined; if (newVersion === 'auto' || isVersionBumpType) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { @@ -524,11 +535,15 @@ export async function prepareMain(argv: PrepareOptions): Promise { const latestTag = await getLatestTag(git); - // Determine bump type - either from arg or from commit analysis - const bumpType: BumpType = - newVersion === 'auto' - ? await getAutoBumpType(git, latestTag) - : (newVersion as BumpType); + // For 'auto', analyze commits to determine both bump type AND generate changelog + // This avoids duplicate GitHub API calls later when preparing the changelog + let bumpType: BumpType; + if (newVersion === 'auto') { + cachedChangelogResult = await getChangelogWithBumpType(git, latestTag); + bumpType = cachedChangelogResult.bumpType!; // Already validated non-null by getChangelogWithBumpType + } else { + bumpType = newVersion as BumpType; + } // Calculate new version from latest tag const currentVersion = @@ -583,7 +598,8 @@ export async function prepareMain(argv: PrepareOptions): Promise { oldVersion, newVersion, argv.noChangelog ? ChangelogPolicy.None : changelogPolicy, - changelogPath + changelogPath, + cachedChangelogResult ); // Run a pre-release script (e.g. for version bumping) diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index b3626d4a..efd96e1d 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -20,9 +20,8 @@ import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; import { BUMP_TYPES, - analyzeCommitsForBump, calculateNextVersion, - getAutoBumpType, + getChangelogWithBumpType, } from '../autoVersion'; const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction< @@ -78,12 +77,11 @@ describe('calculateNextVersion', () => { }); }); -describe('analyzeCommitsForBump', () => { +describe('getChangelogWithBumpType', () => { const mockGit = {} as SimpleGit; beforeEach(() => { jest.clearAllMocks(); - // Default: no release.yml file, use DEFAULT_RELEASE_CONFIG getConfigFileDirMock.mockReturnValue('/test/repo'); readFileSyncMock.mockImplementation(() => { const error: NodeJS.ErrnoException = new Error('ENOENT'); @@ -94,401 +92,43 @@ describe('analyzeCommitsForBump', () => { owner: 'testowner', repo: 'testrepo', }); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ repository: {} }), - }); - }); - - test('returns null bump type for no commits', async () => { - getChangesSinceMock.mockResolvedValue([]); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBeNull(); - expect(result.totalCommits).toBe(0); - expect(result.matchedCommits).toBe(0); - }); - - test('returns major bump for breaking changes', async () => { - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('major'); - expect(result.matchedCommits).toBe(1); - }); - - test('returns major bump for breaking changes with scope', async () => { - getChangesSinceMock.mockResolvedValue([ - { - hash: 'abc123', - title: 'fix(api)!: breaking fix', - body: '', - pr: null, - }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('major'); }); - test('returns minor bump for features', async () => { + test('returns changelog and minor bump type for feature commits', async () => { getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'feat: new feature', body: '', pr: null }, + { hash: 'abc123', title: 'feat: new feature', body: '', pr: '123' }, ]); (getGitHubClient as jest.Mock).mockReturnValue({ graphql: jest.fn().mockResolvedValue({ repository: { Cabc123: { author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, + associatedPullRequests: { + nodes: [ + { + number: '123', + title: 'feat: new feature', + body: '', + labels: { nodes: [] }, + }, + ], + }, }, }, }), }); - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); + const result = await getChangelogWithBumpType(mockGit, 'v1.0.0'); expect(result.bumpType).toBe('minor'); - }); - - test('returns patch bump for fixes', async () => { - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('patch'); - }); - - test('returns patch bump for docs', async () => { - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'docs: update readme', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('patch'); - }); - - test('returns patch bump for chore', async () => { - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'chore: cleanup', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('patch'); - }); - - test('returns highest bump type when mixed commits (major wins)', async () => { - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, - { hash: 'def456', title: 'feat: new feature', body: '', pr: null }, - { hash: 'ghi789', title: 'feat!: breaking change', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - Cdef456: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - Cghi789: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('major'); - }); - - test('returns minor when no major but has features', async () => { - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, - { hash: 'def456', title: 'feat: new feature', body: '', pr: null }, - { hash: 'ghi789', title: 'docs: update docs', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - Cdef456: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - Cghi789: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('minor'); - }); - - test('skips commits with skip-changelog magic word', async () => { - getChangesSinceMock.mockResolvedValue([ - { - hash: 'abc123', - title: 'feat!: breaking change', - body: '#skip-changelog', - pr: null, - }, - { hash: 'def456', title: 'fix: bug fix', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cdef456: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - // Should be patch because the major commit was skipped - expect(result.bumpType).toBe('patch'); - expect(result.totalCommits).toBe(1); - }); - - test('returns null bump type when no commits match categories with semver', async () => { - getChangesSinceMock.mockResolvedValue([ - { - hash: 'abc123', - title: 'random commit without conventional format', - body: '', - pr: null, - }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBeNull(); + expect(result.changelog).toBeDefined(); expect(result.totalCommits).toBe(1); - expect(result.matchedCommits).toBe(0); - }); - - test('early exits when major bump is found', async () => { - // Put major commit first, followed by many others - const commits = [ - { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: null }, - ...Array.from({ length: 100 }, (_, i) => ({ - hash: `hash${i}`, - title: 'fix: bug fix', - body: '', - pr: null, - })), - ]; - getChangesSinceMock.mockResolvedValue(commits); - - const graphqlMock = jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - // Only add the first commit's data - if early exit works, others won't be needed - }, - }); - (getGitHubClient as jest.Mock).mockReturnValue({ graphql: graphqlMock }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('major'); - // Due to early exit, matchedCommits should be 1 (just the major) - expect(result.matchedCommits).toBe(1); - }); - - test('uses custom release config semver values', async () => { - // Mock a custom release.yml with different semver mappings - readFileSyncMock.mockReturnValue(` -changelog: - categories: - - title: 'Custom Breaking' - commit_patterns: - - '^BREAKING:' - semver: major - - title: 'Custom Feature' - commit_patterns: - - '^FEATURE:' - semver: minor -`); - - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'FEATURE: custom feature', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - expect(result.bumpType).toBe('minor'); - }); - - test('returns null for categories without semver field', async () => { - // Mock a release.yml with no semver fields - readFileSyncMock.mockReturnValue(` -changelog: - categories: - - title: 'Features' - commit_patterns: - - '^feat:' -`); - - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'feat: new feature', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const result = await analyzeCommitsForBump(mockGit, 'v1.0.0'); - - // Category matched but no semver field, so null - expect(result.bumpType).toBeNull(); - expect(result.totalCommits).toBe(1); - expect(result.matchedCommits).toBe(0); - }); -}); - -describe('getAutoBumpType', () => { - const mockGit = {} as SimpleGit; - - beforeEach(() => { - jest.clearAllMocks(); - getConfigFileDirMock.mockReturnValue('/test/repo'); - readFileSyncMock.mockImplementation(() => { - const error: NodeJS.ErrnoException = new Error('ENOENT'); - error.code = 'ENOENT'; - throw error; - }); - getGlobalGitHubConfigMock.mockResolvedValue({ - owner: 'testowner', - repo: 'testrepo', - }); - }); - - test('returns minor bump type for feature commits', async () => { - getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'feat: new feature', body: '', pr: null }, - ]); - (getGitHubClient as jest.Mock).mockReturnValue({ - graphql: jest.fn().mockResolvedValue({ - repository: { - Cabc123: { - author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, - }, - }, - }), - }); - - const bumpType = await getAutoBumpType(mockGit, 'v1.0.0'); - - expect(bumpType).toBe('minor'); }); test('throws error when no commits found', async () => { getChangesSinceMock.mockResolvedValue([]); - await expect(getAutoBumpType(mockGit, 'v1.0.0')).rejects.toThrow( + await expect(getChangelogWithBumpType(mockGit, 'v1.0.0')).rejects.toThrow( 'Cannot determine version automatically: no commits found since the last release.' ); }); @@ -513,48 +153,66 @@ describe('getAutoBumpType', () => { }), }); - await expect(getAutoBumpType(mockGit, 'v1.0.0')).rejects.toThrow( + await expect(getChangelogWithBumpType(mockGit, 'v1.0.0')).rejects.toThrow( 'Cannot determine version automatically' ); }); test('returns patch bump type for fix commits', async () => { getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'fix: bug fix', body: '', pr: null }, + { hash: 'abc123', title: 'fix: bug fix', body: '', pr: '456' }, ]); (getGitHubClient as jest.Mock).mockReturnValue({ graphql: jest.fn().mockResolvedValue({ repository: { Cabc123: { author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, + associatedPullRequests: { + nodes: [ + { + number: '456', + title: 'fix: bug fix', + body: '', + labels: { nodes: [] }, + }, + ], + }, }, }, }), }); - const bumpType = await getAutoBumpType(mockGit, 'v2.0.0'); + const result = await getChangelogWithBumpType(mockGit, 'v2.0.0'); - expect(bumpType).toBe('patch'); + expect(result.bumpType).toBe('patch'); }); test('returns major bump type for breaking changes', async () => { getChangesSinceMock.mockResolvedValue([ - { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: null }, + { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: '789' }, ]); (getGitHubClient as jest.Mock).mockReturnValue({ graphql: jest.fn().mockResolvedValue({ repository: { Cabc123: { author: { user: { login: 'testuser' } }, - associatedPullRequests: { nodes: [] }, + associatedPullRequests: { + nodes: [ + { + number: '789', + title: 'feat!: breaking change', + body: '', + labels: { nodes: [] }, + }, + ], + }, }, }, }), }); - const bumpType = await getAutoBumpType(mockGit, ''); + const result = await getChangelogWithBumpType(mockGit, ''); - expect(bumpType).toBe('major'); + expect(result.bumpType).toBe('major'); }); }); diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts index 7b266135..9a4e2879 100644 --- a/src/utils/__tests__/changelog.test.ts +++ b/src/utils/__tests__/changelog.test.ts @@ -804,7 +804,8 @@ describe('generateChangesetFromGit', () => { output: string ) => { setup(commits, releaseConfig); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toBe(output); } ); @@ -854,7 +855,8 @@ describe('generateChangesetFromGit', () => { expect(getConfigFileDirMock).toBeDefined(); expect(readFileSyncMock).toBeDefined(); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; // Verify getConfigFileDir was called expect(getConfigFileDirMock).toHaveBeenCalled(); @@ -909,7 +911,8 @@ describe('generateChangesetFromGit', () => { - feature` ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).not.toContain('#1'); expect(changes).toContain('#2'); }); @@ -952,7 +955,8 @@ describe('generateChangesetFromGit', () => { - skip-release` ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; // PR #1 is excluded from Features category but should appear in Other // (category-level exclusions only exclude from that specific category) expect(changes).toContain('#1'); @@ -989,7 +993,8 @@ describe('generateChangesetFromGit', () => { - '*'` ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### All Changes'); expect(changes).toContain( 'Any PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)' @@ -1027,7 +1032,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; // When no config exists, default conventional commits patterns are used expect(changes).toContain('### New Features'); expect(changes).toContain('### Bug Fixes'); @@ -1058,7 +1064,8 @@ describe('generateChangesetFromGit', () => { - feature` ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Features'); expect(changes).not.toContain('### Other'); expect(changes).toContain( @@ -1086,7 +1093,8 @@ describe('generateChangesetFromGit', () => { categories: "this is a string, not an array"` ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; // Should not crash, and PR should appear in output (no categories applied) expect(changes).toContain('#1'); }); @@ -1133,7 +1141,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Features'); expect(changes).toContain('### Bug Fixes'); @@ -1185,7 +1194,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Labeled Features'); expect(changes).toContain('### Pattern Features'); @@ -1264,7 +1274,8 @@ describe('generateChangesetFromGit', () => { null // No release.yml - should use default config ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; expect(changes).toContain('### New Features'); expect(changes).toContain('### Bug Fixes'); @@ -1300,7 +1311,8 @@ describe('generateChangesetFromGit', () => { ); // Should not crash, and valid pattern should still work - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Features'); expect(changes).toContain('feat: new feature'); }); @@ -1344,7 +1356,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Features'); expect(changes).not.toContain('### Other'); @@ -1393,7 +1406,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Features'); // PR #1 should be excluded from Features (but appear in Other) @@ -1440,7 +1454,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Features'); expect(changes).not.toContain('### Other'); @@ -1485,7 +1500,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3); + const changes = result.changelog; expect(changes).toContain('### Features'); expect(changes).toContain('feat(api): add endpoint'); @@ -1535,7 +1551,8 @@ describe('generateChangesetFromGit', () => { null // Use default config ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; expect(changes).toContain('### Bug Fixes'); expect(changes).toContain('### New Features'); @@ -1590,7 +1607,8 @@ describe('generateChangesetFromGit', () => { null // Use default config ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; expect(changes).toContain('### Build / dependencies / internal'); expect(changes).toContain('refactor: clean up code'); @@ -1631,7 +1649,8 @@ describe('generateChangesetFromGit', () => { null // Use default config ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; expect(changes).toContain('### Breaking Changes'); expect(changes).toContain('feat(my-api)!: breaking api change'); @@ -1700,7 +1719,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Sections should appear in config order, not encounter order const featuresIndex = changes.indexOf('### Features'); @@ -1781,7 +1801,8 @@ describe('generateChangesetFromGit', () => { releaseConfigYaml ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Features should still come before Bug Fixes per config order const featuresIndex = changes.indexOf('### Features'); @@ -1869,7 +1890,8 @@ describe('generateChangesetFromGit', () => { null // No config - use defaults ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Default order from DEFAULT_RELEASE_CONFIG: // Breaking Changes, Build/deps, Bug Fixes, Documentation, New Features @@ -1971,7 +1993,8 @@ describe('generateChangesetFromGit', () => { null // Use default config ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Verify Api scope header exists (has 2 entries) const apiSection = getSectionContent(changes, /#### Api\n/); @@ -2031,7 +2054,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Should have Api scope header (has 2 entries) expect(changes).toContain('#### Api'); @@ -2091,7 +2115,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Neither scope should have a header (both have only 1 entry) expect(changes).not.toContain('#### Api'); @@ -2145,7 +2170,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Should only have one Api header (all merged) const apiMatches = changes.match(/#### Api/gi); @@ -2238,7 +2264,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; const alphaIndex = changes.indexOf('#### Alpha'); const betaIndex = changes.indexOf('#### Beta'); @@ -2310,7 +2337,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Verify scope headers are formatted correctly (each has 2 entries) expect(changes).toContain('#### Another Component'); @@ -2397,7 +2425,8 @@ describe('generateChangesetFromGit', () => { - enhancement` ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; expect(changes).toContain('### Features'); @@ -2470,7 +2499,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; expect(changes).toContain('### Breaking Changes'); @@ -2519,7 +2549,8 @@ describe('generateChangesetFromGit', () => { null ); - const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const changes = result.changelog; // Should only have one "My Component" header (merged via normalization) const myComponentMatches = changes.match(/#### My Component/gi); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index 816c6329..6d81be0e 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -2,194 +2,15 @@ import * as semver from 'semver'; import type { SimpleGit } from 'simple-git'; import { logger } from '../logger'; -import { getChangesSince } from './git'; import { - readReleaseConfig, - normalizeReleaseConfig, - getPRAndLabelsFromCommit, - shouldExcludePR, - isCategoryExcluded, - SKIP_CHANGELOG_MAGIC_WORD, - type NormalizedReleaseConfig, - type NormalizedCategory, + generateChangesetFromGit, + BUMP_TYPES, + type BumpType, + type ChangelogResult, } from './changelog'; -/** - * Version bump types ordered by priority (highest to lowest). - * Used for both validation and determining the highest bump type. - */ -export const BUMP_TYPES = ['major', 'minor', 'patch'] as const; -export type BumpType = (typeof BUMP_TYPES)[number]; - -/** - * Matches a commit/PR to a category and returns the category. - * Labels take precedence over commit log pattern matching. - * - * @returns The matched category, or null if no match - */ -function matchCommitToCategory( - labels: Set, - author: string | undefined, - title: string, - config: NormalizedReleaseConfig -): NormalizedCategory | null { - if (config.changelog.categories.length === 0) { - return null; - } - - const regularCategories: NormalizedCategory[] = []; - let wildcardCategory: NormalizedCategory | null = null; - - for (const category of config.changelog.categories) { - // A category is valid if it has labels OR commit_patterns - if ( - category.labels.length === 0 && - category.commitLogPatterns.length === 0 - ) { - continue; - } - - if (category.labels.includes('*')) { - wildcardCategory = category; - continue; - } - - regularCategories.push(category); - } - - // First pass: try label matching (skip if no labels) - if (labels.size > 0) { - for (const category of regularCategories) { - const matchesCategory = category.labels.some(label => labels.has(label)); - if (matchesCategory && !isCategoryExcluded(category, labels, author)) { - return category; - } - } - } - - // Second pass: try commit_patterns matching - for (const category of regularCategories) { - const matchesPattern = category.commitLogPatterns.some(re => re.test(title)); - if (matchesPattern && !isCategoryExcluded(category, labels, author)) { - return category; - } - } - - if (wildcardCategory) { - if (isCategoryExcluded(wildcardCategory, labels, author)) { - return null; - } - return wildcardCategory; - } - - return null; -} - -/** - * Result of analyzing commits for version bump determination - */ -export interface BumpAnalysisResult { - /** The highest bump type found, or null if no commits matched categories with semver */ - bumpType: BumpType | null; - /** Number of commits analyzed */ - totalCommits: number; - /** Number of commits that matched a category with a semver field */ - matchedCommits: number; -} - -/** - * Analyzes commits to determine the highest version bump type needed. - * Checks bump types in priority order (major > minor > patch) and returns - * the first one found. - * - * @param git The SimpleGit instance - * @param rev The revision (tag) to start from - * @returns Analysis result with bump type and commit counts - */ -export async function analyzeCommitsForBump( - git: SimpleGit, - rev: string -): Promise { - const rawConfig = readReleaseConfig(); - const releaseConfig = normalizeReleaseConfig(rawConfig); - - if (!releaseConfig) { - return { bumpType: null, totalCommits: 0, matchedCommits: 0 }; - } - - // Get commits since the last tag - const gitCommits = (await getChangesSince(git, rev)).filter( - ({ body }) => !body.includes(SKIP_CHANGELOG_MAGIC_WORD) - ); - - if (gitCommits.length === 0) { - return { bumpType: null, totalCommits: 0, matchedCommits: 0 }; - } - - // Fetch PR metadata from GitHub for label matching - const githubCommits = await getPRAndLabelsFromCommit( - gitCommits.map(({ hash }) => hash) - ); - - // Collect all bump types found in commits - const foundBumpTypes = new Set(); - - for (const gitCommit of gitCommits) { - const hash = gitCommit.hash; - const githubCommit = githubCommits[hash]; - - // Skip if PR body contains skip magic word - if (githubCommit?.prBody?.includes(SKIP_CHANGELOG_MAGIC_WORD)) { - continue; - } - - const labelsArray = githubCommit?.labels ?? []; - const labels = new Set(labelsArray); - const author = githubCommit?.author; - - // Skip if globally excluded - if (shouldExcludePR(labels, author, releaseConfig)) { - continue; - } - - // Use PR title if available, otherwise use commit title for pattern matching - const titleForMatching = githubCommit?.prTitle ?? gitCommit.title; - const matchedCategory = matchCommitToCategory( - labels, - author, - titleForMatching, - releaseConfig - ); - - // Collect bump type if category has one - if (matchedCategory?.semver) { - foundBumpTypes.add(matchedCategory.semver); - - // Early exit: if we found major, no need to continue - if (matchedCategory.semver === 'major') { - logger.debug( - `Found major bump trigger in commit ${hash.slice(0, 8)}: "${titleForMatching}"` - ); - break; - } - } - } - - // Find highest priority bump type (BUMP_TYPES is ordered major > minor > patch) - let bumpType: BumpType | null = null; - for (const type of BUMP_TYPES) { - if (foundBumpTypes.has(type)) { - bumpType = type; - break; - } - } - - return { - bumpType, - totalCommits: gitCommits.length, - matchedCommits: foundBumpTypes.size > 0 ? foundBumpTypes.size : 0, - }; -} +// Re-export for convenience +export { BUMP_TYPES, type BumpType, type ChangelogResult }; /** * Calculates the next version by applying the bump type to the current version. @@ -219,31 +40,32 @@ export function calculateNextVersion( /** * Automatically determines the version bump type based on conventional commits. + * This reuses the changelog generation logic to avoid duplicate work. * * @param git The SimpleGit instance * @param rev The revision (tag) to analyze from - * @returns The determined bump type + * @returns The changelog result containing both changelog and bump type * @throws Error if no commits match categories with semver fields */ -export async function getAutoBumpType( +export async function getChangelogWithBumpType( git: SimpleGit, rev: string -): Promise { +): Promise { logger.info( `Analyzing commits since ${rev || '(beginning of history)'} for auto-versioning...` ); - const analysis = await analyzeCommitsForBump(git, rev); + const result = await generateChangesetFromGit(git, rev); - if (analysis.totalCommits === 0) { + if (result.totalCommits === 0) { throw new Error( 'Cannot determine version automatically: no commits found since the last release.' ); } - if (analysis.bumpType === null) { + if (result.bumpType === null) { throw new Error( - `Cannot determine version automatically: ${analysis.totalCommits} commit(s) found, ` + + `Cannot determine version automatically: ${result.totalCommits} commit(s) found, ` + 'but none matched a category with a "semver" field in the release configuration. ' + 'Please ensure your .github/release.yml categories have "semver" fields defined, ' + 'or specify the version explicitly.' @@ -251,9 +73,9 @@ export async function getAutoBumpType( } logger.info( - `Auto-version: determined ${analysis.bumpType} bump ` + - `(${analysis.matchedCommits}/${analysis.totalCommits} commits matched)` + `Auto-version: determined ${result.bumpType} bump ` + + `(${result.matchedCommitsWithSemver}/${result.totalCommits} commits matched)` ); - return analysis.bumpType; + return result; } diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 4e8d819c..8c5a3db6 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -13,6 +13,13 @@ import { getChangesSince } from './git'; import { getGitHubClient } from './githubApi'; import { getVersion } from './version'; +/** + * Version bump types ordered by priority (highest to lowest). + * Used for determining the highest bump type from commits. + */ +export const BUMP_TYPES = ['major', 'minor', 'patch'] as const; +export type BumpType = (typeof BUMP_TYPES)[number]; + /** * Path to the changelog file in the target repository */ @@ -529,18 +536,18 @@ export function isCategoryExcluded( } /** - * Matches a PR's labels or commit title to a category from release config + * Matches a PR's labels or commit title to a category from release config. * Labels take precedence over commit log pattern matching. * Category-level exclusions are checked here - they exclude the PR from matching this specific category, * allowing it to potentially match other categories or fall through to "Other" - * @returns Category title or null if no match or excluded from this category + * @returns The matched category or null if no match or excluded from all categories */ -function matchPRToCategory( +export function matchCommitToCategory( labels: Set, author: string | undefined, title: string, config: NormalizedReleaseConfig | null -): string | null { +): NormalizedCategory | null { if (!config?.changelog || config.changelog.categories.length === 0) { return null; } @@ -570,7 +577,7 @@ function matchPRToCategory( for (const category of regularCategories) { const matchesCategory = category.labels.some(label => labels.has(label)); if (matchesCategory && !isCategoryExcluded(category, labels, author)) { - return category.title; + return category; } } } @@ -581,7 +588,7 @@ function matchPRToCategory( re.test(title) ); if (matchesPattern && !isCategoryExcluded(category, labels, author)) { - return category.title; + return category; } } @@ -589,7 +596,7 @@ function matchPRToCategory( if (isCategoryExcluded(wildcardCategory, labels, author)) { return null; } - return wildcardCategory.title; + return wildcardCategory; } return null; @@ -657,11 +664,26 @@ function formatChangelogEntry(entry: ChangelogEntry): string { return text; } +/** + * Result of changelog generation, includes both the formatted changelog + * and the determined version bump type based on commit categories. + */ +export interface ChangelogResult { + /** The formatted changelog string */ + changelog: string; + /** The highest version bump type found, or null if no commits matched categories with semver */ + bumpType: BumpType | null; + /** Number of commits analyzed */ + totalCommits: number; + /** Number of commits that matched a category with a semver field */ + matchedCommitsWithSemver: number; +} + export async function generateChangesetFromGit( git: SimpleGit, rev: string, maxLeftovers: number = MAX_LEFTOVERS -): Promise { +): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); @@ -678,6 +700,9 @@ export async function generateChangesetFromGit( const leftovers: Commit[] = []; const missing: Commit[] = []; + // Track bump types for auto-versioning + const foundBumpTypes = new Set(); + for (const gitCommit of gitCommits) { const hash = gitCommit.hash; @@ -696,12 +721,18 @@ export async function generateChangesetFromGit( // Use PR title if available, otherwise use commit title for pattern matching const titleForMatching = githubCommit?.prTitle ?? gitCommit.title; - const categoryTitle = matchPRToCategory( + const matchedCategory = matchCommitToCategory( labels, author, titleForMatching, releaseConfig ); + const categoryTitle = matchedCategory?.title ?? null; + + // Track bump type if category has semver field + if (matchedCategory?.semver) { + foundBumpTypes.add(matchedCategory.semver); + } const commit: Commit = { author: author, @@ -759,6 +790,15 @@ export async function generateChangesetFromGit( } } + // Determine highest priority bump type (BUMP_TYPES is ordered major > minor > patch) + let bumpType: BumpType | null = null; + for (const type of BUMP_TYPES) { + if (foundBumpTypes.has(type)) { + bumpType = type; + break; + } + } + if (missing.length > 0) { logger.warn( 'The following commits were not found on GitHub:', @@ -870,7 +910,12 @@ export async function generateChangesetFromGit( } } - return changelogSections.join('\n\n'); + return { + changelog: changelogSections.join('\n\n'), + bumpType, + totalCommits: gitCommits.length, + matchedCommitsWithSemver: foundBumpTypes.size, + }; } interface CommitInfo { From 1f9cc5dcf4b49a78a8a0553776cd40e83502668e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 23:14:05 +0000 Subject: [PATCH 6/8] Address PR review comments - BUMP_TYPES: Use object with numeric priorities instead of array - Use min() for bump type comparison instead of Set - Fix matchedCommitsWithSemver to track actual count - Memoize generateChangesetFromGit (caches promise for concurrent calls) - ValidatedChangelogResult type with non-null bumpType - Remove useless BUMP_TYPES tests - Fix logger: currentVersion can never be empty --- src/commands/prepare.ts | 44 ++++++----------- src/utils/__tests__/autoVersion.test.ts | 18 +------ src/utils/autoVersion.ts | 17 +++++-- src/utils/changelog.ts | 66 +++++++++++++++++++------ 4 files changed, 80 insertions(+), 65 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 2cc23382..24343cd2 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -20,7 +20,6 @@ import { removeChangeset, prependChangeset, generateChangesetFromGit, - type ChangelogResult, } from '../utils/changelog'; import { ConfigurationError, @@ -136,7 +135,7 @@ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { } // Allow version bump types (major, minor, patch) - if (BUMP_TYPES.includes(version as BumpType)) { + if (version in BUMP_TYPES) { return true; } @@ -373,15 +372,13 @@ async function execPublish(remote: string, newVersion: string): Promise { * @param newVersion The new version we are releasing * @param changelogPolicy One of the changelog policies, such as "none", "simple", etc. * @param changelogPath Path to the changelog file - * @param cachedChangelog Optional pre-computed changelog result (to avoid duplicate work) */ async function prepareChangelog( git: SimpleGit, oldVersion: string, newVersion: string, changelogPolicy: ChangelogPolicy = ChangelogPolicy.None, - changelogPath: string = DEFAULT_CHANGELOG_PATH, - cachedChangelog?: ChangelogResult + changelogPath: string = DEFAULT_CHANGELOG_PATH ): Promise { if (changelogPolicy === ChangelogPolicy.None) { logger.debug( @@ -429,13 +426,9 @@ async function prepareChangelog( } if (!changeset.body) { replaceSection = changeset.name; - // Use cached changelog if available, otherwise generate it - if (cachedChangelog) { - changeset.body = cachedChangelog.changelog; - } else { - const result = await generateChangesetFromGit(git, oldVersion); - changeset.body = result.changelog; - } + // generateChangesetFromGit is memoized, so this won't duplicate API calls + const result = await generateChangesetFromGit(git, oldVersion); + changeset.body = result.changelog; } if (changeset.name === DEFAULT_UNRELEASED_TITLE) { replaceSection = changeset.name; @@ -518,9 +511,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { } // Handle automatic version detection or version bump types - const isVersionBumpType = BUMP_TYPES.includes(newVersion as BumpType); - // Cache changelog result when using auto-versioning (to avoid duplicate GitHub API calls) - let cachedChangelogResult: ChangelogResult | undefined; + const isVersionBumpType = newVersion in BUMP_TYPES; if (newVersion === 'auto' || isVersionBumpType) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { @@ -535,15 +526,13 @@ export async function prepareMain(argv: PrepareOptions): Promise { const latestTag = await getLatestTag(git); - // For 'auto', analyze commits to determine both bump type AND generate changelog - // This avoids duplicate GitHub API calls later when preparing the changelog - let bumpType: BumpType; - if (newVersion === 'auto') { - cachedChangelogResult = await getChangelogWithBumpType(git, latestTag); - bumpType = cachedChangelogResult.bumpType!; // Already validated non-null by getChangelogWithBumpType - } else { - bumpType = newVersion as BumpType; - } + // Determine bump type - either from arg or from commit analysis + // Note: getChangelogWithBumpType is memoized, so calling it here and later + // in prepareChangelog won't result in duplicate GitHub API calls + const bumpType: BumpType = + newVersion === 'auto' + ? (await getChangelogWithBumpType(git, latestTag)).bumpType + : (newVersion as BumpType); // Calculate new version from latest tag const currentVersion = @@ -552,9 +541,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { : '0.0.0'; newVersion = calculateNextVersion(currentVersion, bumpType); - logger.info( - `Version bump: ${currentVersion || '(none)'} -> ${newVersion} (${bumpType} bump)` - ); + logger.info(`Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)`); } logger.info(`Releasing version ${newVersion} from ${rev}`); @@ -598,8 +585,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { oldVersion, newVersion, argv.noChangelog ? ChangelogPolicy.None : changelogPolicy, - changelogPath, - cachedChangelogResult + changelogPath ); // Run a pre-release script (e.g. for version bumping) diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index efd96e1d..5883a7e5 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -18,11 +18,7 @@ import type { SimpleGit } from 'simple-git'; import * as config from '../../config'; import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; -import { - BUMP_TYPES, - calculateNextVersion, - getChangelogWithBumpType, -} from '../autoVersion'; +import { calculateNextVersion, getChangelogWithBumpType } from '../autoVersion'; const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction< typeof config.getConfigFileDir @@ -38,18 +34,6 @@ const getChangesSinceMock = getChangesSince as jest.MockedFunction< typeof getChangesSince >; -describe('BUMP_TYPES', () => { - test('ordered by priority: major > minor > patch', () => { - expect(BUMP_TYPES).toEqual(['major', 'minor', 'patch']); - }); - - test('major has lowest index (highest priority)', () => { - expect(BUMP_TYPES.indexOf('major')).toBe(0); - expect(BUMP_TYPES.indexOf('minor')).toBe(1); - expect(BUMP_TYPES.indexOf('patch')).toBe(2); - }); -}); - describe('calculateNextVersion', () => { test('increments major version', () => { expect(calculateNextVersion('1.2.3', 'major')).toBe('2.0.0'); diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index 6d81be0e..c97d9e91 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -12,6 +12,14 @@ import { // Re-export for convenience export { BUMP_TYPES, type BumpType, type ChangelogResult }; +/** + * Validated changelog result with guaranteed non-null bumpType. + * Returned by getChangelogWithBumpType after validation. + */ +export interface ValidatedChangelogResult extends ChangelogResult { + bumpType: BumpType; // Override to be non-null +} + /** * Calculates the next version by applying the bump type to the current version. * @@ -44,13 +52,13 @@ export function calculateNextVersion( * * @param git The SimpleGit instance * @param rev The revision (tag) to analyze from - * @returns The changelog result containing both changelog and bump type - * @throws Error if no commits match categories with semver fields + * @returns The changelog result with validated non-null bumpType + * @throws Error if no commits found or none match categories with semver fields */ export async function getChangelogWithBumpType( git: SimpleGit, rev: string -): Promise { +): Promise { logger.info( `Analyzing commits since ${rev || '(beginning of history)'} for auto-versioning...` ); @@ -77,5 +85,6 @@ export async function getChangelogWithBumpType( `(${result.matchedCommitsWithSemver}/${result.totalCommits} commits matched)` ); - return result; + // TypeScript knows bumpType is non-null here due to the check above + return result as ValidatedChangelogResult; } diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 8c5a3db6..f83f943b 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -14,11 +14,15 @@ import { getGitHubClient } from './githubApi'; import { getVersion } from './version'; /** - * Version bump types ordered by priority (highest to lowest). + * Version bump type priorities (lower number = higher priority). * Used for determining the highest bump type from commits. */ -export const BUMP_TYPES = ['major', 'minor', 'patch'] as const; -export type BumpType = (typeof BUMP_TYPES)[number]; +export const BUMP_TYPES = { + major: 0, + minor: 1, + patch: 2, +} as const; +export type BumpType = keyof typeof BUMP_TYPES; /** * Path to the changelog file in the target repository @@ -679,10 +683,40 @@ export interface ChangelogResult { matchedCommitsWithSemver: number; } +// Memoization cache for generateChangesetFromGit +// Caches the promise to coalesce concurrent calls +let changesetCache: { + key: string; + promise: Promise; +} | null = null; + +function getChangesetCacheKey(rev: string, maxLeftovers: number): string { + return `${rev}:${maxLeftovers}`; +} + export async function generateChangesetFromGit( git: SimpleGit, rev: string, maxLeftovers: number = MAX_LEFTOVERS +): Promise { + const cacheKey = getChangesetCacheKey(rev, maxLeftovers); + + // Return cached promise if available (coalesces concurrent calls) + if (changesetCache && changesetCache.key === cacheKey) { + return changesetCache.promise; + } + + // Create and cache the promise + const promise = generateChangesetFromGitImpl(git, rev, maxLeftovers); + changesetCache = { key: cacheKey, promise }; + + return promise; +} + +async function generateChangesetFromGitImpl( + git: SimpleGit, + rev: string, + maxLeftovers: number ): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); @@ -700,8 +734,9 @@ export async function generateChangesetFromGit( const leftovers: Commit[] = []; const missing: Commit[] = []; - // Track bump types for auto-versioning - const foundBumpTypes = new Set(); + // Track bump type for auto-versioning (lower priority value = higher bump) + let bumpPriority: number | null = null; + let matchedCommitsWithSemver = 0; for (const gitCommit of gitCommits) { const hash = gitCommit.hash; @@ -731,7 +766,9 @@ export async function generateChangesetFromGit( // Track bump type if category has semver field if (matchedCategory?.semver) { - foundBumpTypes.add(matchedCategory.semver); + matchedCommitsWithSemver++; + const priority = BUMP_TYPES[matchedCategory.semver]; + bumpPriority = Math.min(bumpPriority ?? priority, priority); } const commit: Commit = { @@ -790,14 +827,13 @@ export async function generateChangesetFromGit( } } - // Determine highest priority bump type (BUMP_TYPES is ordered major > minor > patch) - let bumpType: BumpType | null = null; - for (const type of BUMP_TYPES) { - if (foundBumpTypes.has(type)) { - bumpType = type; - break; - } - } + // Convert priority back to bump type + const bumpType: BumpType | null = + bumpPriority === null + ? null + : (Object.entries(BUMP_TYPES).find( + ([, priority]) => priority === bumpPriority + )?.[0] as BumpType) ?? null; if (missing.length > 0) { logger.warn( @@ -914,7 +950,7 @@ export async function generateChangesetFromGit( changelog: changelogSections.join('\n\n'), bumpType, totalCommits: gitCommits.length, - matchedCommitsWithSemver: foundBumpTypes.size, + matchedCommitsWithSemver, }; } From 2d6e6fa1bc603f6f65ea3abb1684cdd027da1e00 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 23:19:55 +0000 Subject: [PATCH 7/8] Use Map for type safety - Define BumpType first, then BUMP_TYPES as Map - Use .has() and .get() instead of 'in' operator (avoids prototype issues) - Add clearChangesetCache() for testing memoization --- src/commands/prepare.ts | 4 +-- src/utils/__tests__/autoVersion.test.ts | 2 ++ src/utils/changelog.ts | 47 +++++++++++++++++-------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 24343cd2..26ec5bed 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -135,7 +135,7 @@ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { } // Allow version bump types (major, minor, patch) - if (version in BUMP_TYPES) { + if (BUMP_TYPES.has(version)) { return true; } @@ -511,7 +511,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { } // Handle automatic version detection or version bump types - const isVersionBumpType = newVersion in BUMP_TYPES; + const isVersionBumpType = BUMP_TYPES.has(newVersion); if (newVersion === 'auto' || isVersionBumpType) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index 5883a7e5..c8ff5110 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -19,6 +19,7 @@ import * as config from '../../config'; import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; import { calculateNextVersion, getChangelogWithBumpType } from '../autoVersion'; +import { clearChangesetCache } from '../changelog'; const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction< typeof config.getConfigFileDir @@ -66,6 +67,7 @@ describe('getChangelogWithBumpType', () => { beforeEach(() => { jest.clearAllMocks(); + clearChangesetCache(); // Clear memoization cache between tests getConfigFileDirMock.mockReturnValue('/test/repo'); readFileSyncMock.mockImplementation(() => { const error: NodeJS.ErrnoException = new Error('ENOENT'); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index f83f943b..870d1f55 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -13,16 +13,20 @@ import { getChangesSince } from './git'; import { getGitHubClient } from './githubApi'; import { getVersion } from './version'; +/** + * Version bump types. + */ +export type BumpType = 'major' | 'minor' | 'patch'; + /** * Version bump type priorities (lower number = higher priority). * Used for determining the highest bump type from commits. */ -export const BUMP_TYPES = { - major: 0, - minor: 1, - patch: 2, -} as const; -export type BumpType = keyof typeof BUMP_TYPES; +export const BUMP_TYPES: Map = new Map([ + ['major', 0], + ['minor', 1], + ['patch', 2], +]); /** * Path to the changelog file in the target repository @@ -694,6 +698,14 @@ function getChangesetCacheKey(rev: string, maxLeftovers: number): string { return `${rev}:${maxLeftovers}`; } +/** + * Clears the memoization cache for generateChangesetFromGit. + * Primarily used for testing. + */ +export function clearChangesetCache(): void { + changesetCache = null; +} + export async function generateChangesetFromGit( git: SimpleGit, rev: string, @@ -766,9 +778,11 @@ async function generateChangesetFromGitImpl( // Track bump type if category has semver field if (matchedCategory?.semver) { - matchedCommitsWithSemver++; - const priority = BUMP_TYPES[matchedCategory.semver]; - bumpPriority = Math.min(bumpPriority ?? priority, priority); + const priority = BUMP_TYPES.get(matchedCategory.semver); + if (priority !== undefined) { + matchedCommitsWithSemver++; + bumpPriority = Math.min(bumpPriority ?? priority, priority); + } } const commit: Commit = { @@ -828,12 +842,15 @@ async function generateChangesetFromGitImpl( } // Convert priority back to bump type - const bumpType: BumpType | null = - bumpPriority === null - ? null - : (Object.entries(BUMP_TYPES).find( - ([, priority]) => priority === bumpPriority - )?.[0] as BumpType) ?? null; + let bumpType: BumpType | null = null; + if (bumpPriority !== null) { + for (const [type, priority] of BUMP_TYPES) { + if (priority === bumpPriority) { + bumpType = type; + break; + } + } + } if (missing.length > 0) { logger.warn( From 2e3ffd6cf7bdd5cfc442911bcd8754ebe860d3e1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 10 Dec 2025 23:36:41 +0000 Subject: [PATCH 8/8] Address PR review comments (round 2) - getChangelogWithBumpType no longer throws - returns null bumpType instead - Added validateBumpType() for callers that need to enforce bump type - Changed changesetCache to Map for proper cleanup - Added isBumpType() type guard for safe BumpType checks - Clear memoization cache in tests to prevent stale results --- src/commands/prepare.ts | 23 +++++---- src/utils/__tests__/autoVersion.test.ts | 64 +++++++++++++++++++++---- src/utils/__tests__/changelog.test.ts | 4 ++ src/utils/autoVersion.ts | 45 ++++++++--------- src/utils/changelog.ts | 23 +++++---- 5 files changed, 110 insertions(+), 49 deletions(-) diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 26ec5bed..a508deed 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -30,7 +30,8 @@ import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; import { getChangelogWithBumpType, calculateNextVersion, - BUMP_TYPES, + validateBumpType, + isBumpType, type BumpType, } from '../utils/autoVersion'; import { isDryRun, promptConfirmation } from '../utils/helpers'; @@ -135,7 +136,7 @@ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { } // Allow version bump types (major, minor, patch) - if (BUMP_TYPES.has(version)) { + if (isBumpType(version)) { return true; } @@ -511,7 +512,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { } // Handle automatic version detection or version bump types - const isVersionBumpType = BUMP_TYPES.has(newVersion); + const isVersionBumpType = isBumpType(newVersion); if (newVersion === 'auto' || isVersionBumpType) { if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { @@ -527,12 +528,16 @@ export async function prepareMain(argv: PrepareOptions): Promise { const latestTag = await getLatestTag(git); // Determine bump type - either from arg or from commit analysis - // Note: getChangelogWithBumpType is memoized, so calling it here and later - // in prepareChangelog won't result in duplicate GitHub API calls - const bumpType: BumpType = - newVersion === 'auto' - ? (await getChangelogWithBumpType(git, latestTag)).bumpType - : (newVersion as BumpType); + // Note: generateChangesetFromGit is memoized, so calling getChangelogWithBumpType + // here and later in prepareChangelog won't result in duplicate GitHub API calls + let bumpType: BumpType; + if (newVersion === 'auto') { + const changelogResult = await getChangelogWithBumpType(git, latestTag); + validateBumpType(changelogResult); // Throws if no valid bump type + bumpType = changelogResult.bumpType; + } else { + bumpType = newVersion as BumpType; + } // Calculate new version from latest tag const currentVersion = diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts index c8ff5110..20fdbe19 100644 --- a/src/utils/__tests__/autoVersion.test.ts +++ b/src/utils/__tests__/autoVersion.test.ts @@ -18,7 +18,11 @@ import type { SimpleGit } from 'simple-git'; import * as config from '../../config'; import { getChangesSince } from '../git'; import { getGitHubClient } from '../githubApi'; -import { calculateNextVersion, getChangelogWithBumpType } from '../autoVersion'; +import { + calculateNextVersion, + getChangelogWithBumpType, + validateBumpType, +} from '../autoVersion'; import { clearChangesetCache } from '../changelog'; const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction< @@ -62,6 +66,45 @@ describe('calculateNextVersion', () => { }); }); +describe('validateBumpType', () => { + test('throws error when no commits found', () => { + const result = { + changelog: '', + bumpType: null, + totalCommits: 0, + matchedCommitsWithSemver: 0, + }; + + expect(() => validateBumpType(result)).toThrow( + 'Cannot determine version automatically: no commits found since the last release.' + ); + }); + + test('throws error when no commits match semver categories', () => { + const result = { + changelog: '', + bumpType: null, + totalCommits: 5, + matchedCommitsWithSemver: 0, + }; + + expect(() => validateBumpType(result)).toThrow( + 'Cannot determine version automatically' + ); + }); + + test('does not throw when bumpType is present', () => { + const result = { + changelog: '### Features\n- feat: new feature', + bumpType: 'minor' as const, + totalCommits: 1, + matchedCommitsWithSemver: 1, + }; + + expect(() => validateBumpType(result)).not.toThrow(); + }); +}); + describe('getChangelogWithBumpType', () => { const mockGit = {} as SimpleGit; @@ -111,15 +154,16 @@ describe('getChangelogWithBumpType', () => { expect(result.totalCommits).toBe(1); }); - test('throws error when no commits found', async () => { + test('returns null bumpType when no commits found', async () => { getChangesSinceMock.mockResolvedValue([]); - await expect(getChangelogWithBumpType(mockGit, 'v1.0.0')).rejects.toThrow( - 'Cannot determine version automatically: no commits found since the last release.' - ); + const result = await getChangelogWithBumpType(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBeNull(); + expect(result.totalCommits).toBe(0); }); - test('throws error when no commits match semver categories', async () => { + test('returns null bumpType when no commits match semver categories', async () => { getChangesSinceMock.mockResolvedValue([ { hash: 'abc123', @@ -139,9 +183,11 @@ describe('getChangelogWithBumpType', () => { }), }); - await expect(getChangelogWithBumpType(mockGit, 'v1.0.0')).rejects.toThrow( - 'Cannot determine version automatically' - ); + const result = await getChangelogWithBumpType(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBeNull(); + expect(result.totalCommits).toBe(1); + expect(result.matchedCommitsWithSemver).toBe(0); }); test('returns patch bump type for fix commits', async () => { diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts index 9a4e2879..cdc595ba 100644 --- a/src/utils/__tests__/changelog.test.ts +++ b/src/utils/__tests__/changelog.test.ts @@ -25,6 +25,7 @@ import { generateChangesetFromGit, extractScope, formatScopeTitle, + clearChangesetCache, SKIP_CHANGELOG_MAGIC_WORD, BODY_IN_CHANGELOG_MAGIC_WORD, } from '../changelog'; @@ -331,6 +332,9 @@ describe('generateChangesetFromGit', () => { commits: TestCommit[], releaseConfig?: string | null ): void { + // Clear memoization cache to ensure fresh results + clearChangesetCache(); + mockGetChangesSince.mockResolvedValueOnce( commits.map(commit => ({ hash: commit.hash, diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts index c97d9e91..7537af41 100644 --- a/src/utils/autoVersion.ts +++ b/src/utils/autoVersion.ts @@ -5,20 +5,13 @@ import { logger } from '../logger'; import { generateChangesetFromGit, BUMP_TYPES, + isBumpType, type BumpType, type ChangelogResult, } from './changelog'; // Re-export for convenience -export { BUMP_TYPES, type BumpType, type ChangelogResult }; - -/** - * Validated changelog result with guaranteed non-null bumpType. - * Returned by getChangelogWithBumpType after validation. - */ -export interface ValidatedChangelogResult extends ChangelogResult { - bumpType: BumpType; // Override to be non-null -} +export { BUMP_TYPES, isBumpType, type BumpType, type ChangelogResult }; /** * Calculates the next version by applying the bump type to the current version. @@ -47,24 +40,40 @@ export function calculateNextVersion( } /** - * Automatically determines the version bump type based on conventional commits. - * This reuses the changelog generation logic to avoid duplicate work. + * Generates changelog and determines version bump type from commits. + * This is a convenience wrapper around generateChangesetFromGit that logs progress. * * @param git The SimpleGit instance * @param rev The revision (tag) to analyze from - * @returns The changelog result with validated non-null bumpType - * @throws Error if no commits found or none match categories with semver fields + * @returns The changelog result (bumpType may be null if no matching commits) */ export async function getChangelogWithBumpType( git: SimpleGit, rev: string -): Promise { +): Promise { logger.info( `Analyzing commits since ${rev || '(beginning of history)'} for auto-versioning...` ); const result = await generateChangesetFromGit(git, rev); + if (result.bumpType) { + logger.info( + `Auto-version: determined ${result.bumpType} bump ` + + `(${result.matchedCommitsWithSemver}/${result.totalCommits} commits matched)` + ); + } + + return result; +} + +/** + * Validates that a changelog result has the required bump type for auto-versioning. + * + * @param result The changelog result to validate + * @throws Error if no commits found or none match categories with semver fields + */ +export function validateBumpType(result: ChangelogResult): asserts result is ChangelogResult & { bumpType: BumpType } { if (result.totalCommits === 0) { throw new Error( 'Cannot determine version automatically: no commits found since the last release.' @@ -79,12 +88,4 @@ export async function getChangelogWithBumpType( 'or specify the version explicitly.' ); } - - logger.info( - `Auto-version: determined ${result.bumpType} bump ` + - `(${result.matchedCommitsWithSemver}/${result.totalCommits} commits matched)` - ); - - // TypeScript knows bumpType is non-null here due to the check above - return result as ValidatedChangelogResult; } diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 870d1f55..f46aa5e6 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -28,6 +28,13 @@ export const BUMP_TYPES: Map = new Map([ ['patch', 2], ]); +/** + * Type guard to check if a string is a valid BumpType. + */ +export function isBumpType(value: string): value is BumpType { + return BUMP_TYPES.has(value as BumpType); +} + /** * Path to the changelog file in the target repository */ @@ -688,11 +695,8 @@ export interface ChangelogResult { } // Memoization cache for generateChangesetFromGit -// Caches the promise to coalesce concurrent calls -let changesetCache: { - key: string; - promise: Promise; -} | null = null; +// Caches promises to coalesce concurrent calls with the same arguments +const changesetCache = new Map>(); function getChangesetCacheKey(rev: string, maxLeftovers: number): string { return `${rev}:${maxLeftovers}`; @@ -703,7 +707,7 @@ function getChangesetCacheKey(rev: string, maxLeftovers: number): string { * Primarily used for testing. */ export function clearChangesetCache(): void { - changesetCache = null; + changesetCache.clear(); } export async function generateChangesetFromGit( @@ -714,13 +718,14 @@ export async function generateChangesetFromGit( const cacheKey = getChangesetCacheKey(rev, maxLeftovers); // Return cached promise if available (coalesces concurrent calls) - if (changesetCache && changesetCache.key === cacheKey) { - return changesetCache.promise; + const cached = changesetCache.get(cacheKey); + if (cached) { + return cached; } // Create and cache the promise const promise = generateChangesetFromGitImpl(git, rev, maxLeftovers); - changesetCache = { key: cacheKey, promise }; + changesetCache.set(cacheKey, promise); return promise; }