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 e5baaad1..d280fcde 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -69,6 +69,31 @@ describe('checkVersionOrPart', () => { } }); + test('return true for auto version', () => { + expect( + checkVersionOrPart( + { + newVersion: 'auto', + }, + null + ) + ).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 = [ { @@ -80,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 d9429410..a508deed 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,13 @@ import { reportError, } from '../utils/errors'; import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git'; +import { + getChangelogWithBumpType, + calculateNextVersion, + validateBumpType, + isBumpType, + type BumpType, +} from '../utils/autoVersion'; import { isDryRun, promptConfirmation } from '../utils/helpers'; import { formatJson } from '../utils/strings'; import { spawnProcess } from '../utils/system'; @@ -40,10 +48,16 @@ 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 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', { @@ -106,17 +120,27 @@ 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, 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 */ export function checkVersionOrPart(argv: Arguments, _opt: any): boolean { const version = argv.newVersion; - if (['major', 'minor', 'patch'].indexOf(version) > -1) { - throw Error('Version part is not supported yet'); - } else if (isValidVersion(version)) { + + // Allow 'auto' for automatic version detection + if (version === 'auto') { + return true; + } + + // Allow version bump types (major, minor, patch) + if (isBumpType(version)) { + return true; + } + + if (isValidVersion(version)) { return true; } else { let errMsg = `Invalid version or version part specified: "${version}"`; @@ -403,7 +427,9 @@ async function prepareChangelog( } if (!changeset.body) { replaceSection = changeset.name; - changeset.body = await generateChangesetFromGit(git, oldVersion); + // 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; @@ -469,7 +495,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 +511,44 @@ export async function prepareMain(argv: PrepareOptions): Promise { checkGitStatus(repoStatus, rev); } + // Handle automatic version detection or version bump types + const isVersionBumpType = isBumpType(newVersion); + + if (newVersion === 'auto' || isVersionBumpType) { + if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) { + const featureName = isVersionBumpType + ? 'Version bump types' + : 'Auto-versioning'; + throw new ConfigurationError( + `${featureName} requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` + + 'Please update your configuration or specify the version explicitly.' + ); + } + + const latestTag = await getLatestTag(git); + + // Determine bump type - either from arg or from commit analysis + // 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 = + latestTag && latestTag.replace(/^v/, '').match(/^\d/) + ? latestTag.replace(/^v/, '') + : '0.0.0'; + + newVersion = calculateNextVersion(currentVersion, bumpType); + logger.info(`Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)`); + } + 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..20fdbe19 --- /dev/null +++ b/src/utils/__tests__/autoVersion.test.ts @@ -0,0 +1,250 @@ +/* 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 } from '../git'; +import { getGitHubClient } from '../githubApi'; +import { + calculateNextVersion, + getChangelogWithBumpType, + validateBumpType, +} from '../autoVersion'; +import { clearChangesetCache } from '../changelog'; + +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 +>; + +describe('calculateNextVersion', () => { + test('increments major version', () => { + expect(calculateNextVersion('1.2.3', 'major')).toBe('2.0.0'); + }); + + test('increments minor version', () => { + expect(calculateNextVersion('1.2.3', 'minor')).toBe('1.3.0'); + }); + + test('increments patch version', () => { + expect(calculateNextVersion('1.2.3', 'patch')).toBe('1.2.4'); + }); + + test('handles empty version as 0.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', 'patch')).toBe('1.2.3'); + // Minor bump on prerelease increments minor and removes prerelease + expect(calculateNextVersion('1.2.3-rc.0', 'minor')).toBe('1.3.0'); + }); +}); + +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; + + beforeEach(() => { + jest.clearAllMocks(); + clearChangesetCache(); // Clear memoization cache between tests + 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 changelog and minor bump type for feature commits', async () => { + getChangesSinceMock.mockResolvedValue([ + { 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: [ + { + number: '123', + title: 'feat: new feature', + body: '', + labels: { nodes: [] }, + }, + ], + }, + }, + }, + }), + }); + + const result = await getChangelogWithBumpType(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBe('minor'); + expect(result.changelog).toBeDefined(); + expect(result.totalCommits).toBe(1); + }); + + test('returns null bumpType when no commits found', async () => { + getChangesSinceMock.mockResolvedValue([]); + + const result = await getChangelogWithBumpType(mockGit, 'v1.0.0'); + + expect(result.bumpType).toBeNull(); + expect(result.totalCommits).toBe(0); + }); + + test('returns null bumpType when no commits match semver categories', 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 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 () => { + getChangesSinceMock.mockResolvedValue([ + { 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: [ + { + number: '456', + title: 'fix: bug fix', + body: '', + labels: { nodes: [] }, + }, + ], + }, + }, + }, + }), + }); + + const result = await getChangelogWithBumpType(mockGit, 'v2.0.0'); + + expect(result.bumpType).toBe('patch'); + }); + + test('returns major bump type for breaking changes', async () => { + getChangesSinceMock.mockResolvedValue([ + { 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: [ + { + number: '789', + title: 'feat!: breaking change', + body: '', + labels: { nodes: [] }, + }, + ], + }, + }, + }, + }), + }); + + const result = await getChangelogWithBumpType(mockGit, ''); + + expect(result.bumpType).toBe('major'); + }); +}); diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts index 7b266135..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, @@ -804,7 +808,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 +859,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 +915,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 +959,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 +997,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 +1036,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 +1068,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 +1097,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 +1145,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 +1198,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 +1278,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 +1315,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 +1360,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 +1410,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 +1458,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 +1504,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 +1555,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 +1611,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 +1653,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 +1723,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 +1805,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 +1894,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 +1997,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 +2058,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 +2119,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 +2174,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 +2268,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 +2341,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 +2429,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 +2503,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 +2553,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 new file mode 100644 index 00000000..7537af41 --- /dev/null +++ b/src/utils/autoVersion.ts @@ -0,0 +1,91 @@ +import * as semver from 'semver'; +import type { SimpleGit } from 'simple-git'; + +import { logger } from '../logger'; +import { + generateChangesetFromGit, + BUMP_TYPES, + isBumpType, + type BumpType, + type ChangelogResult, +} from './changelog'; + +// Re-export for convenience +export { BUMP_TYPES, isBumpType, type BumpType, type ChangelogResult }; + +/** + * 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 newVersion = semver.inc(versionToBump, bumpType); + + if (!newVersion) { + throw new Error( + `Failed to increment version "${versionToBump}" with bump type "${bumpType}"` + ); + } + + return newVersion; +} + +/** + * 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 (bumpType may be null if no matching commits) + */ +export async function getChangelogWithBumpType( + git: SimpleGit, + rev: string +): 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.' + ); + } + + if (result.bumpType === null) { + throw new Error( + `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.' + ); + } +} diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 6796bd7c..f46aa5e6 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -13,6 +13,28 @@ 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: Map = new Map([ + ['major', 0], + ['minor', 1], + ['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 */ @@ -265,20 +287,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 +321,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 +357,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 +367,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 +388,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 +415,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 +472,7 @@ function normalizeReleaseConfig( } }) .filter((r): r is RegExp => r !== null), + semver: category.semver, exclude: { labels: new Set(), authors: new Set(), @@ -466,7 +503,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 +530,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 @@ -514,18 +551,18 @@ 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; } @@ -555,7 +592,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; } } } @@ -566,7 +603,7 @@ function matchPRToCategory( re.test(title) ); if (matchesPattern && !isCategoryExcluded(category, labels, author)) { - return category.title; + return category; } } @@ -574,7 +611,7 @@ function matchPRToCategory( if (isCategoryExcluded(wildcardCategory, labels, author)) { return null; } - return wildcardCategory.title; + return wildcardCategory; } return null; @@ -642,11 +679,62 @@ 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; +} + +// Memoization cache for generateChangesetFromGit +// Caches promises to coalesce concurrent calls with the same arguments +const changesetCache = new Map>(); + +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.clear(); +} + export async function generateChangesetFromGit( git: SimpleGit, rev: string, maxLeftovers: number = MAX_LEFTOVERS -): Promise { +): Promise { + const cacheKey = getChangesetCacheKey(rev, maxLeftovers); + + // Return cached promise if available (coalesces concurrent calls) + const cached = changesetCache.get(cacheKey); + if (cached) { + return cached; + } + + // Create and cache the promise + const promise = generateChangesetFromGitImpl(git, rev, maxLeftovers); + changesetCache.set(cacheKey, promise); + + return promise; +} + +async function generateChangesetFromGitImpl( + git: SimpleGit, + rev: string, + maxLeftovers: number +): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); @@ -663,6 +751,10 @@ export async function generateChangesetFromGit( const leftovers: Commit[] = []; const missing: Commit[] = []; + // 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; @@ -681,12 +773,22 @@ 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) { + const priority = BUMP_TYPES.get(matchedCategory.semver); + if (priority !== undefined) { + matchedCommitsWithSemver++; + bumpPriority = Math.min(bumpPriority ?? priority, priority); + } + } const commit: Commit = { author: author, @@ -744,6 +846,17 @@ export async function generateChangesetFromGit( } } + // Convert priority back to bump type + 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( 'The following commits were not found on GitHub:', @@ -855,7 +968,12 @@ export async function generateChangesetFromGit( } } - return changelogSections.join('\n\n'); + return { + changelog: changelogSections.join('\n\n'), + bumpType, + totalCommits: gitCommits.length, + matchedCommitsWithSemver, + }; } interface CommitInfo { @@ -887,7 +1005,7 @@ interface CommitInfoResult { repository: CommitInfoMap; } -async function getPRAndLabelsFromCommit(hashes: string[]): Promise< +export async function getPRAndLabelsFromCommit(hashes: string[]): Promise< Record< /* hash */ string, {