Skip to content

Commit bd7b9ec

Browse files
committed
feat: add extra-prefix-mapping to map arbitrary commit prefixes to conventional commit types
Add `extra-prefix-mapping` configuration to map arbitrary commit prefixes to conventional commit types, enabling flexible handling of various commit message conventions. This change enables release-please to handle various commit message conventions by providing a flexible mapping mechanism. Key use cases include: - **Emoji-based commits**: Map emoji prefixes (e.g., 🐛, ✨, 📝) to conventional types when using workflows like [gitmoji](https://github.com/arvinxx/gitmoji-commit-workflow) - **Custom commit prefixes**: Map organization-specific prefixes (e.g., "change:", "update:") to conventional types - **Legacy code integration**: Handle non-conventional commits when merging legacy codebases by mapping the empty string ("") to a default type Fully backward compatible - feature disabled by default. Fixes #2623. Fixes #2385.
1 parent 84a43ef commit bd7b9ec

File tree

5 files changed

+177
-3
lines changed

5 files changed

+177
-3
lines changed

schemas/config.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
"description": "Feature changes only bump semver patch if version < 1.0.0",
2121
"type": "boolean"
2222
},
23+
"extra-prefix-mapping": {
24+
"description": "Map custom commit prefixes to conventional commit types. Use empty string (\"\") to map non-conventional commits. Example: {\"change\": \"fix\", \"\": \"chore\"}",
25+
"type": "object",
26+
"additionalProperties": {
27+
"type": "string"
28+
}
29+
},
2330
"prerelease-type": {
2431
"description": "Configuration option for the prerelease versioning strategy. If prerelease strategy used and type set, will set the prerelease part of the version to the provided value in case prerelease part is not present.",
2532
"type": "string"

src/commit.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,14 +399,17 @@ function splitMessages(message: string): string[] {
399399
* Given a list of raw commits, parse and expand into conventional commits.
400400
*
401401
* @param commits {Commit[]} The input commits
402+
* @param logger {Logger} The logger to use for debug messages
403+
* @param extraPrefixMapping {Record<string, string>} Map custom prefixes to conventional commit types. Use empty string ("") for non-conventional commits.
402404
*
403405
* @returns {ConventionalCommit[]} Parsed and expanded commits. There may be
404406
* more commits returned as a single raw commit may contain multiple release
405407
* messages.
406408
*/
407409
export function parseConventionalCommits(
408410
commits: Commit[],
409-
logger: Logger = defaultLogger
411+
logger: Logger = defaultLogger,
412+
extraPrefixMapping?: Record<string, string>
410413
): ConventionalCommit[] {
411414
const conventionalCommits: ConventionalCommit[] = [];
412415

@@ -419,12 +422,22 @@ export function parseConventionalCommits(
419422
const breaking =
420423
parsedCommit.notes.filter(note => note.title === 'BREAKING CHANGE')
421424
.length > 0;
425+
426+
// Check if the parsed type should be remapped via extraPrefixMapping
427+
let finalType = parsedCommit.type;
428+
if (extraPrefixMapping && parsedCommit.type in extraPrefixMapping) {
429+
finalType = extraPrefixMapping[parsedCommit.type];
430+
logger.debug(
431+
`remapping commit type '${parsedCommit.type}' to '${finalType}': ${commit.sha}`
432+
);
433+
}
434+
422435
conventionalCommits.push({
423436
sha: commit.sha,
424437
message: parsedCommit.header,
425438
files: commit.files,
426439
pullRequest: commit.pullRequest,
427-
type: parsedCommit.type,
440+
type: finalType,
428441
scope: parsedCommit.scope,
429442
bareMessage: parsedCommit.subject,
430443
notes: parsedCommit.notes,
@@ -439,6 +452,26 @@ export function parseConventionalCommits(
439452
}`
440453
);
441454
logger.debug(`error message: ${_err}`);
455+
// Check for empty string mapping (non-conventional commits)
456+
if (extraPrefixMapping && '' in extraPrefixMapping) {
457+
const mappedType = extraPrefixMapping[''];
458+
const bareMessage = commitMessage.split('\n')[0];
459+
logger.debug(
460+
`treating non-conventional commit as '${mappedType}': ${commit.sha}`
461+
);
462+
conventionalCommits.push({
463+
sha: commit.sha,
464+
message: commitMessage,
465+
files: commit.files,
466+
pullRequest: commit.pullRequest,
467+
type: mappedType,
468+
scope: null,
469+
bareMessage,
470+
notes: [],
471+
references: [],
472+
breaking: false,
473+
});
474+
}
442475
}
443476
}
444477
}

src/manifest.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export interface ReleaserConfig {
101101
bumpMinorPreMajor?: boolean;
102102
bumpPatchForMinorPreMajor?: boolean;
103103
prereleaseType?: string;
104+
extraPrefixMapping?: Record<string, string>;
104105

105106
// Strategy options
106107
releaseAs?: string;
@@ -161,6 +162,7 @@ interface ReleaserConfigJson {
161162
'bump-minor-pre-major'?: boolean;
162163
'bump-patch-for-minor-pre-major'?: boolean;
163164
'prerelease-type'?: string;
165+
'extra-prefix-mapping'?: Record<string, string>;
164166
'changelog-sections'?: ChangelogSection[];
165167
'release-as'?: string;
166168
'skip-github-release'?: boolean;
@@ -735,7 +737,8 @@ export class Manifest {
735737
this.logger.debug(`targetBranch: ${this.targetBranch}`);
736738
let pathCommits = parseConventionalCommits(
737739
commitsPerPath[path],
738-
this.logger
740+
this.logger,
741+
config.extraPrefixMapping
739742
);
740743
// The processCommits hook can be implemented by plugins to
741744
// post-process commits. This can be used to perform cleanup, e.g,, sentence
@@ -1379,6 +1382,7 @@ function extractReleaserConfig(
13791382
bumpMinorPreMajor: config['bump-minor-pre-major'],
13801383
bumpPatchForMinorPreMajor: config['bump-patch-for-minor-pre-major'],
13811384
prereleaseType: config['prerelease-type'],
1385+
extraPrefixMapping: config['extra-prefix-mapping'],
13821386
versioning: config['versioning'],
13831387
changelogSections: config['changelog-sections'],
13841388
changelogPath: config['changelog-path'],

test/commits.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,91 @@ describe('parseConventionalCommits', () => {
282282
// expect(conventionalCommits[0].type).to.equal('docs');
283283
// expect(conventionalCommits[0].scope).is.null;
284284
// });
285+
286+
it('ignores non-conventional commits by default', async () => {
287+
const commits = [
288+
buildMockCommit('feat: some feature'),
289+
buildMockCommit('this is a non-conventional commit'),
290+
buildMockCommit('fix: some bugfix'),
291+
];
292+
const conventionalCommits = parseConventionalCommits(commits);
293+
expect(conventionalCommits).lengthOf(2);
294+
expect(conventionalCommits[0].type).to.equal('feat');
295+
expect(conventionalCommits[1].type).to.equal('fix');
296+
});
297+
298+
it('treats non-conventional commits as fix when mapped with empty string', async () => {
299+
const commits = [
300+
buildMockCommit('feat: some feature'),
301+
buildMockCommit('this is a non-conventional commit'),
302+
buildMockCommit('fix: some bugfix'),
303+
];
304+
const conventionalCommits = parseConventionalCommits(commits, undefined, {
305+
'': 'fix',
306+
});
307+
expect(conventionalCommits).lengthOf(3);
308+
expect(conventionalCommits[0].type).to.equal('feat');
309+
expect(conventionalCommits[1].type).to.equal('fix');
310+
expect(conventionalCommits[1].bareMessage).to.equal(
311+
'this is a non-conventional commit'
312+
);
313+
expect(conventionalCommits[1].scope).is.null;
314+
expect(conventionalCommits[1].breaking).to.be.false;
315+
expect(conventionalCommits[2].type).to.equal('fix');
316+
});
317+
318+
it('preserves files, empty notes and references when using prefix mapping', async () => {
319+
const commits = [
320+
buildMockCommit('non-conventional commit', [
321+
'path1/file1.txt',
322+
'path2/file2.txt',
323+
]),
324+
];
325+
const conventionalCommits = parseConventionalCommits(commits, undefined, {
326+
'': 'fix',
327+
});
328+
expect(conventionalCommits).lengthOf(1);
329+
expect(conventionalCommits[0].type).to.equal('fix');
330+
expect(conventionalCommits[0].files).to.deep.equal([
331+
'path1/file1.txt',
332+
'path2/file2.txt',
333+
]);
334+
expect(conventionalCommits[0].notes).to.be.empty;
335+
expect(conventionalCommits[0].references).to.be.empty;
336+
expect(conventionalCommits[0].breaking).to.be.false;
337+
});
338+
339+
it('maps custom prefix "change" to "fix"', async () => {
340+
const commits = [
341+
buildMockCommit('change: update documentation'),
342+
buildMockCommit('feat: add new feature'),
343+
];
344+
const conventionalCommits = parseConventionalCommits(commits, undefined, {
345+
change: 'fix',
346+
});
347+
expect(conventionalCommits).lengthOf(2);
348+
expect(conventionalCommits[0].type).to.equal('fix');
349+
expect(conventionalCommits[0].bareMessage).to.equal('update documentation');
350+
expect(conventionalCommits[1].type).to.equal('feat');
351+
});
352+
353+
it('maps emoji prefix to conventional commit type', async () => {
354+
const commits = [
355+
buildMockCommit('🐛: fix the bug'),
356+
buildMockCommit('✨: add new feature'),
357+
buildMockCommit('feat: regular feature'),
358+
];
359+
const conventionalCommits = parseConventionalCommits(commits, undefined, {
360+
'🐛': 'fix',
361+
'✨': 'feat',
362+
});
363+
expect(conventionalCommits).lengthOf(3);
364+
expect(conventionalCommits[0].type).to.equal('fix');
365+
expect(conventionalCommits[0].bareMessage).to.equal('fix the bug');
366+
expect(conventionalCommits[1].type).to.equal('feat');
367+
expect(conventionalCommits[1].bareMessage).to.equal('add new feature');
368+
expect(conventionalCommits[2].type).to.equal('feat');
369+
});
285370
});
286371

287372
function assertHasCommit(

test/util/filter-commits.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,49 @@ describe('filterCommits', () => {
8989
]);
9090
expect(commits.length).to.equal(1);
9191
});
92+
it('includes commits with fix type', () => {
93+
const commits = filterCommits([
94+
{
95+
type: 'fix',
96+
notes: [],
97+
references: [],
98+
bareMessage: 'update readme',
99+
message: 'update readme',
100+
scope: null,
101+
breaking: false,
102+
sha: 'abc123',
103+
},
104+
]);
105+
expect(commits.length).to.equal(1);
106+
});
107+
it('includes commits with feat type', () => {
108+
const commits = filterCommits([
109+
{
110+
type: 'feat',
111+
notes: [],
112+
references: [],
113+
bareMessage: 'add new feature',
114+
message: 'add new feature',
115+
scope: null,
116+
breaking: false,
117+
sha: 'def456',
118+
},
119+
]);
120+
expect(commits.length).to.equal(1);
121+
});
122+
it('excludes commits with chore type', () => {
123+
const commits = filterCommits([
124+
{
125+
type: 'chore',
126+
notes: [],
127+
references: [],
128+
bareMessage: 'update dependencies',
129+
message: 'update dependencies',
130+
scope: null,
131+
breaking: false,
132+
sha: 'ghi789',
133+
},
134+
]);
135+
expect(commits.length).to.equal(0);
136+
});
92137
});

0 commit comments

Comments
 (0)