From 91bbca508a91b293a650af5b3ef1933cbf56f9ee Mon Sep 17 00:00:00 2001 From: Maksim Sharipov Date: Sun, 2 Jan 2022 22:00:44 +0100 Subject: [PATCH] refactor: improve commit message functionality --- .../model/commit-message-factory.ts | 51 ++++++++++++ .../repository/model/commit-message.spec.ts | 48 ------------ .../repository/model/commit-message.ts | 78 +++++++++++++------ .../model/custom-commit-message.spec.ts | 40 ++++++++++ .../repository/model/custom-commit-message.ts | 26 +++++++ .../model/semantic-commit-message.spec.ts | 59 ++++++++++++++ .../model/semantic-commit-message.ts | 62 +++++++++++++++ .../onboarding/branch/commit-message.ts | 25 ++---- .../onboarding/branch/create.spec.ts | 8 +- 9 files changed, 304 insertions(+), 93 deletions(-) create mode 100644 lib/workers/repository/model/commit-message-factory.ts create mode 100644 lib/workers/repository/model/custom-commit-message.spec.ts create mode 100644 lib/workers/repository/model/custom-commit-message.ts create mode 100644 lib/workers/repository/model/semantic-commit-message.spec.ts create mode 100644 lib/workers/repository/model/semantic-commit-message.ts diff --git a/lib/workers/repository/model/commit-message-factory.ts b/lib/workers/repository/model/commit-message-factory.ts new file mode 100644 index 00000000000000..547f25144c0bc3 --- /dev/null +++ b/lib/workers/repository/model/commit-message-factory.ts @@ -0,0 +1,51 @@ +import type { RenovateSharedConfig } from '../../../config/types'; +import type { CommitMessage } from './commit-message'; +import { CustomCommitMessage } from './custom-commit-message'; +import { SemanticCommitMessage } from './semantic-commit-message'; + +type CommitMessageConfig = Pick< + RenovateSharedConfig, + | 'commitMessagePrefix' + | 'semanticCommits' + | 'semanticCommitScope' + | 'semanticCommitType' +>; + +export class CommitMessageFactory { + private readonly config: CommitMessageConfig; + + constructor(config: CommitMessageConfig) { + this.config = config; + } + + create(): CommitMessage { + const message = this.areSemanticCommitsEnabled + ? this.createSemanticCommitMessage() + : this.createCustomCommitMessage(); + + return message; + } + + private createSemanticCommitMessage(): SemanticCommitMessage { + const message = new SemanticCommitMessage(); + + message.setType(this.config.semanticCommitType); + message.setScope(this.config.semanticCommitScope); + + return message; + } + + private createCustomCommitMessage(): CustomCommitMessage { + const message = new CustomCommitMessage(); + message.setPrefix(this.config.commitMessagePrefix); + + return message; + } + + private get areSemanticCommitsEnabled(): boolean { + return ( + !this.config.commitMessagePrefix && + this.config.semanticCommits === 'enabled' + ); + } +} diff --git a/lib/workers/repository/model/commit-message.spec.ts b/lib/workers/repository/model/commit-message.spec.ts index 6c554934381c29..4c6adf931ea9bc 100644 --- a/lib/workers/repository/model/commit-message.spec.ts +++ b/lib/workers/repository/model/commit-message.spec.ts @@ -2,56 +2,8 @@ import { CommitMessage } from './commit-message'; describe('workers/repository/model/commit-message', () => { describe('CommitMessage', () => { - const TEST_CASES: ReadonlyArray< - [message: string, prefix: string | undefined, result: string] - > = [ - ['test', undefined, 'Test'], - ['test', '', 'Test'], - [' test ', ' ', 'Test'], - ['test', 'fix', 'fix: test'], - ['test', 'fix:', 'fix: test'], - ]; - it('has colon character separator', () => { expect(CommitMessage.SEPARATOR).toBe(':'); }); - - it.each(TEST_CASES)( - 'given %p and %p as arguments, returns %p', - (message, prefix, result) => { - const commitMessage = new CommitMessage(message); - commitMessage.setCustomPrefix(prefix); - - expect(commitMessage.toString()).toEqual(result); - } - ); - - it('should handle not defined semantic prefix', () => { - const message = new CommitMessage('test'); - message.setSemanticPrefix(); - - expect(message.toString()).toBe('Test'); - }); - - it('should handle empty semantic prefix', () => { - const message = new CommitMessage('test'); - message.setSemanticPrefix(' ', ' '); - - expect(message.toString()).toBe('Test'); - }); - - it('should format sematic prefix', () => { - const message = new CommitMessage('test'); - message.setSemanticPrefix(' fix '); - - expect(message.toString()).toBe('fix: test'); - }); - - it('should format sematic prefix with scope', () => { - const message = new CommitMessage('test'); - message.setSemanticPrefix(' fix ', ' scope '); - - expect(message.toString()).toBe('fix(scope): test'); - }); }); }); diff --git a/lib/workers/repository/model/commit-message.ts b/lib/workers/repository/model/commit-message.ts index bab8afd4f6f429..029d4ee68f0022 100644 --- a/lib/workers/repository/model/commit-message.ts +++ b/lib/workers/repository/model/commit-message.ts @@ -1,13 +1,23 @@ -export class CommitMessage { - public static readonly SEPARATOR: string = ':'; - - private message = ''; +export interface CommitMessageJSON { + body?: string; + footer?: string; + subject?: string; +} - private prefix = ''; +/** + * @see https://git-scm.com/docs/git-commit#_discussion + * + * [optional prefix]: + * [optional body] + * [optional footer] + */ +export abstract class CommitMessage { + static readonly SEPARATOR: string = ':'; + private static readonly EXTRA_WHITESPACES = /\s+/g; - constructor(message = '') { - this.setMessage(message); - } + private body?: string; + private footer?: string; + private subject?: string; public static formatPrefix(prefix: string): string { if (!prefix) { @@ -21,34 +31,54 @@ export class CommitMessage { return `${prefix}${CommitMessage.SEPARATOR}`; } - public setMessage(message: string): void { - this.message = (message || '').trim(); + toString(): string { + const parts: ReadonlyArray = [ + this.title, + this.body, + this.footer, + ]; + + return parts.filter(Boolean).join('\n\n'); } - public setCustomPrefix(prefix?: string): void { - this.prefix = (prefix ?? '').trim(); + get title(): string { + return [CommitMessage.formatPrefix(this.prefix), this.formatSubject()] + .join(' ') + .trim(); } - public setSemanticPrefix(type?: string, scope?: string): void { - this.prefix = (type ?? '').trim(); + toJSON(): CommitMessageJSON { + return { + body: this.body, + footer: this.footer, + subject: this.subject, + }; + } - if (scope?.trim()) { - this.prefix += `(${scope.trim()})`; - } + setBody(body?: string): void { + this.body = body?.trim(); } - public toString(): string { - const prefix = CommitMessage.formatPrefix(this.prefix); - const message = this.formatMessage(); + setFooter(footer?: string): void { + this.footer = footer?.trim(); + } - return [prefix, message].join(' ').trim(); + setSubject(subject?: string): void { + this.subject = subject?.trim(); + this.subject = this.subject?.replace(CommitMessage.EXTRA_WHITESPACES, ' '); } - private formatMessage(): string { + formatSubject(): string { + if (!this.subject) { + return ''; + } + if (this.prefix) { - return this.message; + return this.subject.charAt(0).toLowerCase() + this.subject.slice(1); } - return this.message.charAt(0).toUpperCase() + this.message.slice(1); + return this.subject.charAt(0).toUpperCase() + this.subject.slice(1); } + + protected abstract get prefix(): string; } diff --git a/lib/workers/repository/model/custom-commit-message.spec.ts b/lib/workers/repository/model/custom-commit-message.spec.ts new file mode 100644 index 00000000000000..3b266ce3b6ef34 --- /dev/null +++ b/lib/workers/repository/model/custom-commit-message.spec.ts @@ -0,0 +1,40 @@ +import { CustomCommitMessage } from './custom-commit-message'; + +describe('workers/repository/model/custom-commit-message', () => { + describe('CustomCommitMessage', () => { + const TEST_CASES: ReadonlyArray< + [message: string, prefix: string | undefined, result: string] + > = [ + ['test', undefined, 'Test'], + ['test', '', 'Test'], + [' test ', ' ', 'Test'], + ['test', 'fix', 'fix: test'], + ['test', 'fix:', 'fix: test'], + [ + 'Message With Extra Whitespaces ', + ' refactor ', + 'refactor: message With Extra Whitespaces', + ], + ]; + + it.each(TEST_CASES)( + 'given %p and %p as arguments, returns %p', + (subject, prefix, result) => { + const commitMessage = new CustomCommitMessage(); + commitMessage.setSubject(subject); + commitMessage.setPrefix(prefix); + + expect(commitMessage.toString()).toEqual(result); + } + ); + + it('should provide ability to set body and footer', () => { + const commitMessage = new CustomCommitMessage(); + commitMessage.setSubject('subject'); + commitMessage.setBody('body'); + commitMessage.setFooter('footer'); + + expect(commitMessage.toString()).toBe('Subject\n\nbody\n\nfooter'); + }); + }); +}); diff --git a/lib/workers/repository/model/custom-commit-message.ts b/lib/workers/repository/model/custom-commit-message.ts new file mode 100644 index 00000000000000..09db8c5bd71962 --- /dev/null +++ b/lib/workers/repository/model/custom-commit-message.ts @@ -0,0 +1,26 @@ +import { CommitMessage, CommitMessageJSON } from './commit-message'; + +export interface CustomCommitMessageJSON extends CommitMessageJSON { + prefix?: string; +} + +export class CustomCommitMessage extends CommitMessage { + private _prefix?: string; + + setPrefix(prefix?: string): void { + this._prefix = prefix?.trim(); + } + + override toJSON(): CustomCommitMessageJSON { + const json = super.toJSON(); + + return { + ...json, + prefix: this._prefix, + }; + } + + protected get prefix(): string { + return this._prefix; + } +} diff --git a/lib/workers/repository/model/semantic-commit-message.spec.ts b/lib/workers/repository/model/semantic-commit-message.spec.ts new file mode 100644 index 00000000000000..2b4f2f522267c9 --- /dev/null +++ b/lib/workers/repository/model/semantic-commit-message.spec.ts @@ -0,0 +1,59 @@ +import { SemanticCommitMessage } from './semantic-commit-message'; + +describe('workers/repository/model/semantic-commit-message', () => { + it('should format message without prefix', () => { + const message = new SemanticCommitMessage(); + message.setSubject('test'); + + expect(message.toString()).toBe('Test'); + }); + + it('should format sematic type', () => { + const message = new SemanticCommitMessage(); + message.setSubject('test'); + message.setType(' fix '); + + expect(message.toString()).toBe('fix: test'); + }); + + it('should format sematic prefix with scope', () => { + const message = new SemanticCommitMessage(); + message.setSubject('test'); + message.setType(' fix '); + message.setScope(' scope '); + + expect(message.toString()).toBe('fix(scope): test'); + }); + + it('should create instance from string without scope', () => { + const instance = SemanticCommitMessage.fromString('feat: ticket 123'); + const json = instance.toJSON(); + + expect(SemanticCommitMessage.is(instance)).toBeTrue(); + expect(json.type).toBe('feat'); + expect(json.scope).toBeUndefined(); + expect(json.subject).toBe('ticket 123'); + }); + + it('should create instance from string with scope', () => { + const instance = SemanticCommitMessage.fromString( + 'fix(dashboard): ticket 123' + ); + const json = instance.toJSON(); + + expect(SemanticCommitMessage.is(instance)).toBeTrue(); + expect(json.type).toBe('fix'); + expect(json.scope).toBe('dashboard'); + expect(json.subject).toBe('ticket 123'); + }); + + it('should create instance from string with empty description', () => { + const instance = SemanticCommitMessage.fromString('fix(deps): '); + const json = instance.toJSON(); + + expect(SemanticCommitMessage.is(instance)).toBeTrue(); + expect(json.type).toBe('fix'); + expect(json.scope).toBe('deps'); + expect(json.subject).toBe(''); + }); +}); diff --git a/lib/workers/repository/model/semantic-commit-message.ts b/lib/workers/repository/model/semantic-commit-message.ts new file mode 100644 index 00000000000000..f7cb3af949fdcf --- /dev/null +++ b/lib/workers/repository/model/semantic-commit-message.ts @@ -0,0 +1,62 @@ +import { CommitMessage, CommitMessageJSON } from './commit-message'; + +export interface SemanticCommitMessageJSON extends CommitMessageJSON { + scope?: string; + type?: string; +} + +/** + * @see https://www.conventionalcommits.org/en/v1.0.0/#summary + * + * [optional scope]: + * [optional body] + * [optional footer] + */ +export class SemanticCommitMessage extends CommitMessage { + private static readonly REGEXP = + /^(?[\w]+)(\((?[\w-]+)\))?(?!)?: ((?([A-Z]+-|#)[\d]+) )?(?.*)/; + + private scope?: string; + private type?: string; + + static is(value: unknown): value is SemanticCommitMessage { + return value instanceof SemanticCommitMessage; + } + + static fromString(value: string): SemanticCommitMessage { + const { groups } = value.match(SemanticCommitMessage.REGEXP); + + const message = new SemanticCommitMessage(); + message.setType(groups.type); + message.setScope(groups.scope); + message.setSubject(groups.description); + + return message; + } + + override toJSON(): SemanticCommitMessageJSON { + const json = super.toJSON(); + + return { + ...json, + scope: this.scope, + type: this.type, + }; + } + + setScope(scope?: string): void { + this.scope = scope?.trim(); + } + + setType(type?: string): void { + this.type = type?.trim(); + } + + protected get prefix(): string { + if (!this.scope && !this.type) { + return ''; + } + + return this.scope ? `${this.type}(${this.scope})` : this.type; + } +} diff --git a/lib/workers/repository/onboarding/branch/commit-message.ts b/lib/workers/repository/onboarding/branch/commit-message.ts index 88c36c3b556058..fe455dfea2fbf9 100644 --- a/lib/workers/repository/onboarding/branch/commit-message.ts +++ b/lib/workers/repository/onboarding/branch/commit-message.ts @@ -1,5 +1,6 @@ import { RenovateConfig } from '../../../../config/types'; import { CommitMessage } from '../../model/commit-message'; +import { CommitMessageFactory } from '../../model/commit-message-factory'; export class OnboardingCommitMessageFactory { private readonly config: RenovateConfig; @@ -12,30 +13,16 @@ export class OnboardingCommitMessageFactory { } create(): CommitMessage { - const { - commitMessagePrefix, - onboardingCommitMessage, - semanticCommitScope, - semanticCommitType, - } = this.config; - const commitMessage = new CommitMessage(); - - if (commitMessagePrefix) { - commitMessage.setCustomPrefix(commitMessagePrefix); - } else if (this.areSemanticCommitsEnabled()) { - commitMessage.setSemanticPrefix(semanticCommitType, semanticCommitScope); - } + const { onboardingCommitMessage } = this.config; + const commitMessageFactory = new CommitMessageFactory(this.config); + const commitMessage = commitMessageFactory.create(); if (onboardingCommitMessage) { - commitMessage.setMessage(onboardingCommitMessage); + commitMessage.setSubject(onboardingCommitMessage); } else { - commitMessage.setMessage(`add ${this.configFile}`); + commitMessage.setSubject(`add ${this.configFile}`); } return commitMessage; } - - private areSemanticCommitsEnabled(): boolean { - return this.config.semanticCommits === 'enabled'; - } } diff --git a/lib/workers/repository/onboarding/branch/create.spec.ts b/lib/workers/repository/onboarding/branch/create.spec.ts index 342ed1a987b988..d8441e92adcf93 100644 --- a/lib/workers/repository/onboarding/branch/create.spec.ts +++ b/lib/workers/repository/onboarding/branch/create.spec.ts @@ -67,7 +67,9 @@ describe('workers/repository/onboarding/branch/create', () => { await createOnboardingBranch(config); expect(commitFiles).toHaveBeenCalledWith( buildExpectedCommitFilesArgument( - `${prefix}${CommitMessage.SEPARATOR} ${message}` + `${prefix}${CommitMessage.SEPARATOR} ${ + message.charAt(0).toLowerCase() + message.slice(1) + }` ) ); }); @@ -92,7 +94,9 @@ describe('workers/repository/onboarding/branch/create', () => { await createOnboardingBranch(config); expect(commitFiles).toHaveBeenCalledWith( buildExpectedCommitFilesArgument( - `${prefix}${CommitMessage.SEPARATOR} ${message}` + `${prefix}${CommitMessage.SEPARATOR} ${ + message.charAt(0).toLowerCase() + message.slice(1) + }` ) ); });