diff --git a/change/beachball-38e088f5-413c-44fe-98c1-6fccdbe43be0.json b/change/beachball-38e088f5-413c-44fe-98c1-6fccdbe43be0.json new file mode 100644 index 000000000..8c125c035 --- /dev/null +++ b/change/beachball-38e088f5-413c-44fe-98c1-6fccdbe43be0.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Support generating change files from conventional commits", + "packageName": "beachball", + "email": "asgramme@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/src/__e2e__/publishE2E.test.ts b/src/__e2e__/publishE2E.test.ts index 4e298a3a8..a3517eb40 100644 --- a/src/__e2e__/publishE2E.test.ts +++ b/src/__e2e__/publishE2E.test.ts @@ -72,6 +72,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -140,6 +141,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -230,6 +232,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -323,6 +326,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -396,6 +400,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -474,6 +479,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -547,6 +553,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -627,6 +634,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -700,6 +708,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: false, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -771,6 +780,7 @@ describe('publish command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -778,7 +788,7 @@ describe('publish command (e2e)', () => { bump: true, generateChangelog: true, dependentChangeType: null, - depth: 10 + depth: 10, }); const showResult = npm(['--registry', registry.getUrl(), 'show', 'foo', '--json']); @@ -793,5 +803,4 @@ describe('publish command (e2e)', () => { // no fetch when flag set to false expect(depthString).toEqual('--depth=10'); }); - }); diff --git a/src/__e2e__/publishGit.test.ts b/src/__e2e__/publishGit.test.ts index ff9645293..68e5882c5 100644 --- a/src/__e2e__/publishGit.test.ts +++ b/src/__e2e__/publishGit.test.ts @@ -64,6 +64,7 @@ describe('publish command (git)', () => { package: 'foo', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -125,6 +126,7 @@ describe('publish command (git)', () => { package: 'foo', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', diff --git a/src/__e2e__/publishRegistry.test.ts b/src/__e2e__/publishRegistry.test.ts index 1663b0009..9952f8b17 100644 --- a/src/__e2e__/publishRegistry.test.ts +++ b/src/__e2e__/publishRegistry.test.ts @@ -78,6 +78,7 @@ describe('publish command (registry)', () => { package: 'foo', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -136,6 +137,7 @@ describe('publish command (registry)', () => { package: 'foo', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -220,6 +222,7 @@ describe('publish command (registry)', () => { package: 'foopkg', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -300,6 +303,7 @@ describe('publish command (registry)', () => { package: 'foopkg', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -385,6 +389,7 @@ describe('publish command (registry)', () => { package: 'foopkg', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', diff --git a/src/__e2e__/syncE2E.test.ts b/src/__e2e__/syncE2E.test.ts index 56b0dcbf2..bec2011b1 100644 --- a/src/__e2e__/syncE2E.test.ts +++ b/src/__e2e__/syncE2E.test.ts @@ -91,6 +91,7 @@ describe('sync command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -145,6 +146,7 @@ describe('sync command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', @@ -208,6 +210,7 @@ describe('sync command (e2e)', () => { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, disallowedChangeTypes: null, defaultNpmTag: 'latest', diff --git a/src/__tests__/changefile/conventionalCommits.test.ts b/src/__tests__/changefile/conventionalCommits.test.ts new file mode 100644 index 000000000..306b81e7e --- /dev/null +++ b/src/__tests__/changefile/conventionalCommits.test.ts @@ -0,0 +1,14 @@ +import { parseConventionalCommit } from '../../changefile/conventionalCommits'; + +describe.each<[string, ReturnType]>([ + ['fix: change message\nbody', { type: 'patch', message: 'change message' }], + ['chore: change', { type: 'none', message: 'change' }], + ['feat: change', { type: 'minor', message: 'change' }], + ['feat(scope): change', { type: 'minor', message: 'change' }], + ['feat!: change', { type: 'major', message: 'change' }], + ['feat(scope)!: change', { type: 'major', message: 'change' }], + ['foo', undefined], + ['fix(foo-bar): change', { type: 'patch', message: 'change' }], +])('parse(%s)', (s, expected) => { + test('should parse correctly', () => expect(parseConventionalCommit(s)).toEqual(expected)); +}); diff --git a/src/changefile/conventionalCommits.ts b/src/changefile/conventionalCommits.ts new file mode 100644 index 000000000..c15844603 --- /dev/null +++ b/src/changefile/conventionalCommits.ts @@ -0,0 +1,44 @@ +import { ChangeType } from '../types/ChangeInfo'; + +/** + * 1. type + * 2. scope + * 3. breaking + * 4. message + */ +const COMMIT_RE = /([a-z]+)(?:\(([a-z\-]+)\))?(!)?: (.+)/i; + +interface ConventionalCommit { + type: string; + scope?: string; + breaking: boolean; + message: string; +} + +interface Change { + type: ChangeType; + message: string; +} + +export function parseConventionalCommit(commitMessage: string): Change | undefined { + const match = commitMessage.match(COMMIT_RE); + const data: ConventionalCommit | undefined = match + ? { type: match[1], scope: match[2], breaking: !!match[3], message: match[4] } + : undefined; + return data && map(data); +} + +function map(d: ConventionalCommit): Change | undefined { + if (d.breaking) { + return { type: 'major', message: d.message }; + } + + switch (d.type) { + case 'chore': + return { type: 'none', message: d.message }; + case 'fix': + return { type: 'patch', message: d.message }; + case 'feat': + return { type: 'minor', message: d.message }; + } +} diff --git a/src/changefile/promptForChange.ts b/src/changefile/promptForChange.ts index 4baf84f63..0416a8c24 100644 --- a/src/changefile/promptForChange.ts +++ b/src/changefile/promptForChange.ts @@ -9,6 +9,7 @@ import { getPackageGroups } from '../monorepo/getPackageGroups'; import { isValidChangeType } from '../validation/isValidChangeType'; import { DefaultPrompt } from '../types/ChangeFilePrompt'; import { getDisallowedChangeTypes } from './getDisallowedChangeTypes'; +import { parseConventionalCommit } from './conventionalCommits'; /** * Uses `prompts` package to prompt for change type and description, fills in git user.email and scope @@ -27,6 +28,15 @@ export async function promptForChange(options: BeachballOptions): Promise(obj: T | undefined): obj is T => !!obj)) || + []; + for (let pkg of changedPackages) { console.log(''); console.log(`Please describe the changes for: ${pkg}`); @@ -47,7 +57,7 @@ export async function promptForChange(options: BeachballOptions): Promise !disallowedChangeTypes?.includes(choice.value as ChangeType)), + ].filter((choice) => !disallowedChangeTypes?.includes(choice.value as ChangeType)), }; if (changeTypePrompt.choices!.length === 0) { @@ -64,16 +74,18 @@ export async function promptForChange(options: BeachballOptions): Promise { - return Promise.resolve([...recentMessages.filter(msg => msg.startsWith(input)), input]); + suggest: (input) => { + return Promise.resolve([...recentMessages.filter((msg) => msg.startsWith(input)), input]); }, }; - const showChangeTypePrompt = !options.type && changeTypePrompt.choices!.length > 1; + // Only include structured commit messages that map to an allowed change type. + const allowedConventionalCommit = fromConventionalCommits.find((c) => !disallowedChangeTypes?.includes(c.type)); + const showChangeTypePrompt = !options.type && !allowedConventionalCommit && changeTypePrompt.choices!.length > 1; const defaultPrompt: DefaultPrompt = { changeType: showChangeTypePrompt ? changeTypePrompt : undefined, - description: !options.message ? descriptionPrompt : undefined, + description: !options.message && !allowedConventionalCommit ? descriptionPrompt : undefined, }; let questions = [defaultPrompt.changeType, defaultPrompt.description]; @@ -86,11 +98,11 @@ export async function promptForChange(options: BeachballOptions): Promise !!q); + questions = questions.filter((q) => !!q); let response: { comment: string; type: ChangeType } = { - type: options.type || 'none', - comment: options.message || '', + type: options.type || allowedConventionalCommit?.type || 'none', + comment: options.message || allowedConventionalCommit?.message || '', }; if (questions.length > 0) { diff --git a/src/options/getCliOptions.ts b/src/options/getCliOptions.ts index b4e8359b0..3f2abb3fc 100644 --- a/src/options/getCliOptions.ts +++ b/src/options/getCliOptions.ts @@ -24,7 +24,15 @@ function getCliOptionsUncached(argv: string[]): CliOptions { const args = parser(trimmedArgv, { string: ['branch', 'tag', 'message', 'package', 'since', 'dependent-change-type', 'config'], array: ['scope', 'disallowed-change-types'], - boolean: ['git-tags', 'keep-change-files', 'force', 'disallow-deleted-change-files', 'no-commit', 'fetch'], + boolean: [ + 'git-tags', + 'keep-change-files', + 'force', + 'disallow-deleted-change-files', + 'no-commit', + 'fetch', + 'use-conventional-commits', + ], number: ['depth'], alias: { authType: ['a'], diff --git a/src/options/getDefaultOptions.ts b/src/options/getDefaultOptions.ts index 213fd7e71..0b5c0b97f 100644 --- a/src/options/getDefaultOptions.ts +++ b/src/options/getDefaultOptions.ts @@ -19,6 +19,7 @@ export function getDefaultOptions() { package: '', changehint: 'Run "beachball change" to create a change file', type: null, + useConventionalCommits: false, fetch: true, version: false, disallowedChangeTypes: null, diff --git a/src/types/BeachballOptions.ts b/src/types/BeachballOptions.ts index 4c24d2406..12ec85cc0 100644 --- a/src/types/BeachballOptions.ts +++ b/src/types/BeachballOptions.ts @@ -44,6 +44,7 @@ export interface CliOptions timeout?: number; token: string; type?: ChangeType | null; + useConventionalCommits: boolean; verbose?: boolean; version?: boolean; yes: boolean; @@ -141,7 +142,7 @@ export interface HooksOptions { * Runs for each package, before writing changelog and package.json updates * to the filesystem. May be called multiple times during publish. */ - prebump?: (packagePath: string, name: string, version: string) => void | Promise; + prebump?: (packagePath: string, name: string, version: string) => void | Promise; /** * Runs for each package, after writing changelog and package.json updates