diff --git a/.gitignore b/.gitignore index 8e1e11b2d8..c9d515cd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ lib/ package.json.lerna_backup /*.iml tsconfig.tsbuildinfo +coverage diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index 6507182032..ea67ee2db1 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -1,8 +1,8 @@ -import path from 'path'; import {fix, git} from '@commitlint/test'; import execa from 'execa'; -import merge from 'lodash/merge'; import fs from 'fs-extra'; +import merge from 'lodash/merge'; +import path from 'path'; const bin = require.resolve('../cli.js'); @@ -494,7 +494,8 @@ test('should print config', async () => { defaultIgnores: undefined, plugins: {}, rules: { 'type-enum': [ 2, 'never', [ 'foo' ] ] }, - helpUrl: 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint' + helpUrl: 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint', + prompt: {} }" `); }); diff --git a/@commitlint/config-conventional/index.js b/@commitlint/config-conventional/index.js index 8a7e3bd15a..b772bc333a 100644 --- a/@commitlint/config-conventional/index.js +++ b/@commitlint/config-conventional/index.js @@ -33,4 +33,103 @@ module.exports = { ], ], }, + prompt: { + questions: { + type: { + description: "Select the type of change that you're committing:", + enum: { + feat: { + description: 'A new feature', + title: 'Features', + emoji: '✨', + }, + fix: { + description: 'A bug fix', + title: 'Bug Fixes', + emoji: '🐛', + }, + docs: { + description: 'Documentation only changes', + title: 'Documentation', + emoji: '📚', + }, + style: { + description: + 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)', + title: 'Styles', + emoji: '💎', + }, + refactor: { + description: + 'A code change that neither fixes a bug nor adds a feature', + title: 'Code Refactoring', + emoji: '📦', + }, + perf: { + description: 'A code change that improves performance', + title: 'Performance Improvements', + emoji: '🚀', + }, + test: { + description: 'Adding missing tests or correcting existing tests', + title: 'Tests', + emoji: '🚨', + }, + build: { + description: + 'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)', + title: 'Builds', + emoji: '🛠', + }, + ci: { + description: + 'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)', + title: 'Continuous Integrations', + emoji: '⚙️', + }, + chore: { + description: "Other changes that don't modify src or test files", + title: 'Chores', + emoji: '♻️', + }, + revert: { + description: 'Reverts a previous commit', + title: 'Reverts', + emoji: '🗑', + }, + }, + }, + scope: { + description: + 'What is the scope of this change (e.g. component or file name)', + }, + subject: { + description: + 'Write a short, imperative tense description of the change', + }, + body: { + description: 'Provide a longer description of the change', + }, + isBreaking: { + description: 'Are there any breaking changes?', + }, + breakingBody: { + description: + 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself', + }, + breaking: { + description: 'Describe the breaking changes', + }, + isIssueAffected: { + description: 'Does this change affect any open issues?', + }, + issuesBody: { + description: + 'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself', + }, + issues: { + description: 'Add issue references (e.g. "fix #123", "re #123".)', + }, + }, + }, }; diff --git a/@commitlint/cz-commitlint/.gitignore b/@commitlint/cz-commitlint/.gitignore new file mode 100644 index 0000000000..722d5e71d9 --- /dev/null +++ b/@commitlint/cz-commitlint/.gitignore @@ -0,0 +1 @@ +.vscode diff --git a/@commitlint/cz-commitlint/README.md b/@commitlint/cz-commitlint/README.md new file mode 100644 index 0000000000..3f572ba595 --- /dev/null +++ b/@commitlint/cz-commitlint/README.md @@ -0,0 +1,88 @@ +> Commitizen adapter using the commitlint.config.js + +# @commitlint/cz-commitlint + +This is a commitizen adapter, using this adapter, commitizen works based on commitlint.config.js. + +Submit by commitizen, lint by commitlint, just need maintain one configuration file, Consistent and Scalable. + +The interactive process is inspired by [cz-conventional-changelog](https://github.com/commitizen/cz-conventional-changelog). + +## Getting started + +### Using commitizen adapter + +```bash +npm install --save-dev @commitlint/cz-commitlint commitizen +``` + +In package.json + +``` +{ + "scripts": { + "commit": "git-cz" + }, + "config": { + "commitizen": { + "path": "@commitlint/cz-commitlint" + } + } +} +``` + +### Configure commitlint + +```bash +# Install commitlint cli and conventional config +npm install --save-dev @commitlint/config-conventional @commitlint/cli + +# Simple: config with conventional +echo "module.exports = {extends: ['@commitlint/config-conventional']};" > commitlint.config.js + +# commitlint configuration is shareable, +# Install lerna-scopes +npm install --save-dev @commitlint/config-lerna-scopes +# Scalable: config with lerna-scopes in monorepo mode +echo "module.exports = {extends: ['@commitlint/config-conventional', '@commitlint/config-lerna-scopes']};" > commitlint.config.js +``` + +### Set Git Hooks by husky + +```base + +# ------- using npm ---------- +# Install Husky +npm install husky --save-dev +# Active hooks +npx husky install +# Add commitlint hook +npx husky add .husky/commit-msg 'npx --no-install commitlint --edit $1' +# Add commitizen hook +npx husky add .husky/prepare-commit-msg 'exec < /dev/tty && node_modules/.bin/cz --hook || true' + + +# ------- using yarn ---------- +# Install Husky +yarn add husky --dev +# Active hooks +yarn husky install +# Add commitlint hook +yarn husky add .husky/commit-msg 'yarn --no-install commitlint --edit $1' +# Add commitizen hook +yarn husky add .husky/prepare-commit-msg 'exec < /dev/tty && node_modules/.bin/cz --hook || true' + +``` + +### Try it out + +```bash +git add . +npm run commit +# or +yarn run commit +``` + +## Related + +- [Commitlint Shared Configuration](https://github.com/conventional-changelog/commitlint#shared-configuration) - You can find more shared configurations are available to install and use with commitlint diff --git a/@commitlint/cz-commitlint/TODO b/@commitlint/cz-commitlint/TODO new file mode 100644 index 0000000000..689365d222 --- /dev/null +++ b/@commitlint/cz-commitlint/TODO @@ -0,0 +1,5 @@ +[x] jest Test +[x] insert prompt settings to commitlint.config.js +[] support multi line +[] support emoji and title +[] recognize "signed-off-by" and "references-empty" rules diff --git a/@commitlint/cz-commitlint/license.md b/@commitlint/cz-commitlint/license.md new file mode 100644 index 0000000000..d13cc4b26a --- /dev/null +++ b/@commitlint/cz-commitlint/license.md @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/@commitlint/cz-commitlint/package.json b/@commitlint/cz-commitlint/package.json new file mode 100644 index 0000000000..7a8e89ced7 --- /dev/null +++ b/@commitlint/cz-commitlint/package.json @@ -0,0 +1,42 @@ +{ + "name": "cz-commitlint", + "version": "1.0.0", + "description": "Commitizen adapter using the commitlint.config.js", + "main": "./lib/index.js", + "files": [ + "lib" + ], + "scripts": { + "commit": "git-cz" + }, + "homepage": "https://github.com/conventional-changelog/commitlint#readme", + "repository": { + "type": "git", + "url": "https://github.com/conventional-changelog/commitlint.git" + }, + "engineStrict": true, + "engines": { + "node": ">= 10" + }, + "author": "Curly Brackets ", + "license": "MIT", + "config": { + "commitizen": { + "path": "./@commitlint/cz-commitlint" + } + }, + "dependencies": { + "@commitlint/load": "^12.1.1", + "@commitlint/types": "^12.1.1", + "chalk": "^4.1.0", + "lodash": "^4.17.21", + "word-wrap": "^1.2.3" + }, + "peerDependencies": { + "commitizen": "^4.0.3", + "inquirer": "^8.0.0" + }, + "devDependencies": { + "@types/inquirer": "^7.3.1" + } +} diff --git a/@commitlint/cz-commitlint/src/Process.test.ts b/@commitlint/cz-commitlint/src/Process.test.ts new file mode 100644 index 0000000000..c5de547c79 --- /dev/null +++ b/@commitlint/cz-commitlint/src/Process.test.ts @@ -0,0 +1,241 @@ +import {QualifiedRules, UserPromptConfig} from '@commitlint/types'; +import {Answers, DistinctQuestion} from 'inquirer'; +import isFunction from 'lodash/isFunction'; +import process from './Process'; + +const mockShowTitle = jest.fn(); +const mockShowValidation = jest.fn((message) => message); + +// mock inquirer +const mockPrompt = jest.fn(function (questions, answers) { + for (const {name, message, when, filter, validate} of questions) { + if (!when || when(answers)) { + const title = + message && isFunction(message) + ? message(answers) + : typeof message === 'string' + ? message + : ''; + mockShowTitle(title); + + const validation: boolean | string = + !validate || validate(answers[name] ?? '', answers); + + if (typeof validation === 'string') { + mockShowValidation(validation); + break; + } else { + if (filter && answers[name]) { + answers[name] = filter(answers[name]); + } + } + } + } +}); + +function InquirerFactory(answers: Answers) { + const inquirer = { + prompt: function (questions: DistinctQuestion) { + return { + then: function (callback: (answers: Answers) => void) { + mockPrompt(questions, answers); + callback(answers); + }, + }; + }, + }; + + return inquirer; +} + +const MESSAGES = { + skip: '(press enter to skip)', + max: 'upper %d chars', + min: '%d chars at least', + emptyWarning: '%s can not be empty', + upperLimitWarning: '%s: %s over limit %d', + lowerLimitWarning: '%s: %s below limit %d', +}; + +let rules: QualifiedRules; +let prompts: UserPromptConfig; + +afterEach(() => { + mockShowTitle.mockClear(); + mockShowValidation.mockClear(); +}); + +describe('conventional-changlog', () => { + beforeEach(() => { + rules = { + 'body-leading-blank': [1, 'always'], + 'body-max-line-length': [2, 'always', 100], + 'footer-leading-blank': [1, 'always'], + 'footer-max-line-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'subject-case': [ + 2, + 'never', + ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], + ], + 'subject-empty': [2, 'never'], + 'subject-full-stop': [2, 'never', '.'], + 'type-case': [2, 'always', 'lower-case'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + ], + ], + } as any; + prompts = { + messages: MESSAGES, + questions: { + type: { + description: "Select the type of change that you're committing:", + enum: { + feat: { + description: 'A new feature', + title: 'Features', + emoji: '✨', + }, + fix: { + description: 'A bug fix', + title: 'Bug Fixes', + emoji: '🐛', + }, + docs: { + description: 'Documentation only changes', + title: 'Documentation', + emoji: '📚', + }, + style: { + description: + 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)', + title: 'Styles', + emoji: '💎', + }, + refactor: { + description: + 'A code change that neither fixes a bug nor adds a feature', + title: 'Code Refactoring', + emoji: '📦', + }, + perf: { + description: 'A code change that improves performance', + title: 'Performance Improvements', + emoji: '🚀', + }, + test: { + description: 'Adding missing tests or correcting existing tests', + title: 'Tests', + emoji: '🚨', + }, + build: { + description: + 'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)', + title: 'Builds', + emoji: '🛠', + }, + ci: { + description: + 'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)', + title: 'Continuous Integrations', + emoji: '⚙️', + }, + chore: { + description: "Other changes that don't modify src or test files", + title: 'Chores', + emoji: '♻️', + }, + revert: { + description: 'Reverts a previous commit', + title: 'Reverts', + emoji: '🗑', + }, + }, + }, + scope: { + description: + 'What is the scope of this change (e.g. component or file name)', + }, + subject: { + description: + 'Write a short, imperative tense description of the change', + }, + body: { + description: 'Provide a longer description of the change', + }, + isBreaking: { + description: 'Are there any breaking changes?', + }, + breakingBody: { + description: + 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself', + }, + breaking: { + description: 'Describe the breaking changes', + }, + isIssueAffected: { + description: 'Does this change affect any open issues?', + }, + issuesBody: { + description: + 'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself', + }, + issues: { + description: 'Add issue references (e.g. "fix #123", "re #123".)', + }, + }, + }; + }); + test('should process works well', () => { + const answers = { + type: 'refactor', + scope: 'prompt', + subject: 'refactor prompt based on inquirer', + body: 'inspired by commitizen/cz-conventional-changelog', + isBreaking: true, + breaking: 'refactor types', + isIssueAffected: true, + issues: 'https://github.com/conventional-changelog/commitlint/issues/94', + }; + return process(rules as any, prompts, InquirerFactory(answers) as any).then( + (commitMessage) => { + expect(commitMessage).toBe( + 'refactor(prompt): refactor prompt based on inquirer\n\ninspired by commitizen/cz-conventional-changelog\n\nBREAKING CHANGE: refactor types\nhttps://github.com/conventional-changelog/commitlint/issues/94' + ); + } + ); + }); + + test('should show validation and stop process when subject is empty', () => { + const answers = { + type: 'refactor', + scope: 'prompt', + body: 'inspired by commitizen/cz-conventional-changelog', + isBreaking: true, + breaking: 'refactor types', + isIssueAffected: true, + issues: 'https://github.com/conventional-changelog/commitlint/issues/94', + }; + return process(rules as any, prompts, InquirerFactory(answers) as any).then( + () => { + expect(mockShowValidation).toBeCalledWith('subject can not be empty'); + expect(mockShowTitle).toBeCalledTimes(3); + } + ); + }); +}); diff --git a/@commitlint/cz-commitlint/src/Process.ts b/@commitlint/cz-commitlint/src/Process.ts new file mode 100644 index 0000000000..f1ec42d36f --- /dev/null +++ b/@commitlint/cz-commitlint/src/Process.ts @@ -0,0 +1,36 @@ +import {QualifiedRules, UserPromptConfig} from '@commitlint/types'; +import {Inquirer} from 'inquirer'; +import { + combineCommitMessage as combineBody, + getQuestions as getBodyQuestions, +} from './SectionBody'; +import { + combineCommitMessage as combineFooter, + getQuestions as getFooterQuestions, +} from './SectionFooter'; +import { + combineCommitMessage as combineHeader, + getQuestions as getHeaderQuestions, +} from './SectionHeader'; +import {setPromptConfig} from './store/prompts'; +import {setRules} from './store/rules'; + +export default async function ( + rules: QualifiedRules, + prompts: UserPromptConfig, + inquirer: Inquirer +): Promise { + setRules(rules); + setPromptConfig(prompts); + const questions = [ + ...getHeaderQuestions(), + ...getBodyQuestions(), + ...getFooterQuestions(), + ]; + const answers = await inquirer.prompt(questions); + const header = combineHeader(answers); + const body = combineBody(answers); + const footer = combineFooter(answers); + + return [header, body, footer].filter(Boolean).join('\n'); +} diff --git a/@commitlint/cz-commitlint/src/Question.test.ts b/@commitlint/cz-commitlint/src/Question.test.ts new file mode 100644 index 0000000000..9f94dfe90b --- /dev/null +++ b/@commitlint/cz-commitlint/src/Question.test.ts @@ -0,0 +1,317 @@ +import chalk from 'chalk'; +import inquirer, {Answers, InputQuestionOptions} from 'inquirer'; +import Question from './Question'; + +const MESSAGES = { + skip: '(press enter to skip)', + max: 'upper %d chars', + min: '%d chars at least', + emptyWarning: '%s can not be empty', + upperLimitWarning: '%s: %s over limit %d', + lowerLimitWarning: '%s: %s below limit %d', +}; +const QUESTION_CONFIG = { + title: 'please input', + messages: MESSAGES, +}; + +describe('name', () => { + test('should throw error when name is not a meaningful string', () => { + expect( + () => + new Question('' as any, { + ...QUESTION_CONFIG, + }) + ).toThrow(); + + expect( + () => + new Question( + function () { + return 'scope'; + } as any, + { + ...QUESTION_CONFIG, + } + ) + ).toThrow(); + }); + + test('should set name when name is valid', () => { + expect( + new Question('test' as any, { + ...QUESTION_CONFIG, + }).question + ).toHaveProperty('name', 'test'); + }); +}); + +describe('type', () => { + test('should return "list" type when enumList is array', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + enumList: ['cli', 'core'], + }).question; + expect(question).toHaveProperty('type', 'list'); + expect(question).toHaveProperty('choices', ['cli', 'core']); + expect(question).not.toHaveProperty('transformer'); + }); + + test('should contain "skip" list item when enumList is array and skip is true', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + enumList: ['cli', 'core'], + skip: true, + }).question; + expect(question).toHaveProperty('type', 'list'); + expect(question).toHaveProperty('choices', [ + 'cli', + 'core', + new inquirer.Separator(), + { + name: 'empty', + value: '', + }, + ]); + expect(question).not.toHaveProperty('transformer'); + }); + + test('should return "confirm" type when name is start with "is"', () => { + const question = new Question('isSubmit' as any, { + ...QUESTION_CONFIG, + }).question; + expect(question).toHaveProperty('type', 'confirm'); + expect(question).not.toHaveProperty('choices'); + expect(question).not.toHaveProperty('transformer'); + }); + + test('should return "input" type in other cases', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + }).question; + expect(question).toHaveProperty('type', 'input'); + expect(question).not.toHaveProperty('choices'); + expect(question).toHaveProperty('transformer', expect.any(Function)); + }); +}); + +describe('message', () => { + test('should display title when it is not input', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + enumList: ['cli', 'core'], + }).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe('please input: '); + }); + + test('should display skip hint when it is input and can skip', () => { + const question = new Question('body' as any, { + ...QUESTION_CONFIG, + skip: true, + }).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe( + 'please input(press enter to skip): \n' + ); + }); + + test('should not display skip hint when it is input and without skip string', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + messages: {}, + skip: true, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe('please input: \n'); + }); + + test('should display upper limit hint when it is input and has max length', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + maxLength: 80, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe('please input: upper 80 chars\n'); + }); + + test('should display lower limit hint when it is input and has min length', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + minLength: 10, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe( + 'please input: 10 chars at least\n' + ); + }); + + test('should display hints with correct format', () => { + const question = new Question('scope', { + ...QUESTION_CONFIG, + minLength: 10, + maxLength: 80, + skip: true, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + expect((question.message as any)()).toBe( + 'please input(press enter to skip): 10 chars at least, upper 80 chars\n' + ); + }); + + test('should execute function beforeQuestionStart when init message', () => { + const mockFn = jest.fn(); + class CustomQuestion extends Question { + beforeQuestionStart(answers: Answers): void { + mockFn(answers); + } + } + const question = new CustomQuestion('body', { + ...QUESTION_CONFIG, + } as any).question; + expect(question).toHaveProperty('message', expect.any(Function)); + + const answers = { + header: 'This is header', + footer: 'This is footer', + }; + (question.message as any)(answers); + expect(mockFn).toHaveBeenCalledWith(answers); + }); +}); + +describe('filter', () => { + test('should auto fix case and full-stop', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + caseFn: (input: string) => input[0].toUpperCase() + input.slice(1), + fullStopFn: (input: string) => input + '!', + }).question; + + expect(question.filter?.('xxxx', {})).toBe('Xxxx!'); + }); + + test('should works well when does not pass caseFn/fullStopFn', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + }).question; + + expect(question.filter?.('xxxx', {})).toBe('xxxx'); + }); +}); + +describe('validate', () => { + test('should display empty warning when can not skip but string is empty', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + skip: false, + }).question; + + expect(question.validate?.('')).toBe('body can not be empty'); + }); + + test('should ignore empty validation when can skip', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + skip: true, + }).question; + + expect(question.validate?.('')).toBe(true); + }); + + test('should display upper limit warning when char count is over upper limit', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + maxLength: 5, + }).question; + + expect(question.validate?.('xxxxxx')).toBe('body: body over limit 1'); + }); + + test('should display lower limit warning when char count is less than lower limit', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + minLength: 5, + }).question; + + expect(question.validate?.('xxx')).toBe('body: body below limit 2'); + }); + + test('should validate the final submit string', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + caseFn: () => '', + skip: false, + }).question; + + expect(question.validate?.('xxxx')).not.toBe(true); + }); +}); + +describe('transformer', () => { + test('should auto transform case and full-stop', () => { + const question = new Question('body', { + ...QUESTION_CONFIG, + caseFn: (input: string) => input[0].toUpperCase() + input.slice(1), + fullStopFn: (input: string) => input + '!', + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxxx', {}, {}) + ).toBe('Xxxx!'); + }); + + test('should char count with green color when in the limit range', () => { + let question = new Question('body', { + ...QUESTION_CONFIG, + maxLength: 5, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxx', {}, {}) + ).toEqual(chalk.green(`(3) xxx`)); + + question = new Question('body', { + ...QUESTION_CONFIG, + minLength: 2, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxx', {}, {}) + ).toEqual(chalk.green(`(3) xxx`)); + }); + + test('should char count with red color when over the limit range', () => { + let question = new Question('body', { + ...QUESTION_CONFIG, + maxLength: 5, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('xxxxxx', {}, {}) + ).toEqual(chalk.red(`(6) xxxxxx`)); + + question = new Question('body', { + ...QUESTION_CONFIG, + minLength: 2, + }).question; + + expect( + (question as InputQuestionOptions)?.transformer?.('x', {}, {}) + ).toEqual(chalk.red(`(1) x`)); + }); +}); + +describe('inquirer question', () => { + test('should pass "when" and "default" field to inquirer question', () => { + const when = (answers: Answers) => !!answers.header; + const question = new Question('body', { + ...QUESTION_CONFIG, + when, + defaultValue: 'update', + }).question; + + expect(question).toHaveProperty('default', 'update'); + expect(question).toHaveProperty('when', when); + }); +}); diff --git a/@commitlint/cz-commitlint/src/Question.ts b/@commitlint/cz-commitlint/src/Question.ts new file mode 100644 index 0000000000..4abec6b53c --- /dev/null +++ b/@commitlint/cz-commitlint/src/Question.ts @@ -0,0 +1,186 @@ +import {PromptMessages, PromptName} from '@commitlint/types'; +import chalk from 'chalk'; +import inquirer, {Answers, ChoiceCollection, DistinctQuestion} from 'inquirer'; +import {CaseFn} from './utils/case-fn'; +import {FullStopFn} from './utils/full-stop-fn'; + +export type QuestionConfig = { + title: string; + messages: PromptMessages; + maxLength?: number; + minLength?: number; + defaultValue?: string; + when?: DistinctQuestion['when']; + skip?: boolean; + enumList?: ChoiceCollection<{ + name: string; + value: string; + }> | null; + fullStopFn?: FullStopFn; + caseFn?: CaseFn; +}; + +export default class Question { + private _question: Readonly; + private messages: PromptMessages; + private skip: boolean; + private _maxLength: number; + private _minLength: number; + private title: string; + private caseFn: CaseFn; + private fullStopFn: FullStopFn; + constructor( + name: PromptName, + { + title, + enumList, + messages, + defaultValue, + when, + skip, + fullStopFn, + caseFn, + maxLength, + minLength, + }: QuestionConfig + ) { + if (!name || typeof name !== 'string') + throw new Error('Question: name is required'); + + this._maxLength = maxLength ?? Infinity; + this._minLength = minLength ?? 0; + this.messages = messages; + this.title = title ?? ''; + this.skip = skip ?? false; + this.fullStopFn = fullStopFn ?? ((_: string) => _); + this.caseFn = caseFn ?? ((_: string) => _); + + if (enumList && Array.isArray(enumList)) { + this._question = { + type: 'list', + choices: skip + ? [ + ...enumList, + new inquirer.Separator(), + { + name: 'empty', + value: '', + }, + ] + : [...enumList], + }; + } else if (/^is[A-Z]/.test(name)) { + this._question = { + type: 'confirm', + }; + } else { + this._question = { + type: 'input', + transformer: this.transformer.bind(this), + }; + } + + Object.assign(this._question, { + name, + default: defaultValue, + when, + validate: this.validate.bind(this), + filter: this.filter.bind(this), + message: this.decorateMessage.bind(this), + }); + } + + getMessage(key: string): string { + return this.messages[key] ?? ''; + } + + get question(): Readonly { + return this._question; + } + + get maxLength(): number { + return this._maxLength; + } + + set maxLength(maxLength: number) { + this._maxLength = maxLength; + } + + get minLength(): number { + return this._minLength; + } + + set minLength(minLength: number) { + this._minLength = minLength; + } + + protected beforeQuestionStart(_answers: Answers): void { + return; + } + + protected validate(input: string): boolean | string { + const output = this.filter(input); + const questionName = this.question.name ?? ''; + if (!this.skip && output.length === 0) { + return this.getMessage('emptyWarning').replace(/%s/g, questionName); + } + + if (output.length > this.maxLength) { + return this.getMessage('upperLimitWarning') + .replace(/%s/g, questionName) + .replace(/%d/g, `${output.length - this.maxLength}`); + } + + if (output.length < this.minLength) { + return this.getMessage('lowerLimitWarning') + .replace(/%s/g, questionName) + .replace(/%d/g, `${this.minLength - output.length}`); + } + + return true; + } + + protected filter(input: string): string { + return this.caseFn(this.fullStopFn(input)); + } + + protected transformer(input: string, _answers: Answers): string { + const output = this.filter(input); + + if (this.maxLength === Infinity && this.minLength === 0) { + return output; + } + const color = + output.length <= this.maxLength && output.length >= this.minLength + ? chalk.green + : chalk.red; + return color('(' + output.length + ') ' + input); + } + + protected decorateMessage(_answers: Answers): string { + this.beforeQuestionStart && this.beforeQuestionStart(_answers); + if (this.question.type === 'input') { + const countLimitMessage = (() => { + const messages = []; + if (this.minLength > 0 && this.getMessage('min')) { + messages.push( + this.getMessage('min').replace(/%d/g, this.minLength + '') + ); + } + if (this.maxLength < Infinity && this.getMessage('max')) { + messages.push( + this.getMessage('max').replace(/%d/g, this.maxLength + '') + ); + } + + return messages.join(', '); + })(); + + const skipMessage = this.skip ? this.getMessage('skip') : ''; + + return this.title + skipMessage + ': ' + countLimitMessage + '\n'; + } else { + return this.title + ': '; + } + } +} diff --git a/@commitlint/cz-commitlint/src/SectionBody.test.ts b/@commitlint/cz-commitlint/src/SectionBody.test.ts new file mode 100644 index 0000000000..12e4acc6e5 --- /dev/null +++ b/@commitlint/cz-commitlint/src/SectionBody.test.ts @@ -0,0 +1,77 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import {combineCommitMessage, getQuestions} from './SectionBody'; +import {setRules} from './store/rules'; + +describe('getQuestions', () => { + test('should exclude question when body must be empty', () => { + setRules({ + 'body-empty': [RuleConfigSeverity.Error, 'always'], + }); + const questions = getQuestions(); + expect(questions).toHaveLength(0); + }); + + test('should only return body question', () => { + setRules({}); + const questions = getQuestions(); + expect(questions).toHaveLength(1); + expect(questions).toEqual([ + expect.objectContaining({ + name: 'body', + }), + ]); + }); +}); + +describe('combineCommitMessage', () => { + test('should wrap message to multi lines when max-line-length set', () => { + setRules({ + 'body-max-line-length': [RuleConfigSeverity.Error, 'always', 10], + }); + + const commitMessage = combineCommitMessage({ + body: 'This is the body message.', + }); + + expect(commitMessage).toBe('This is\nthe body\nmessage.'); + }); + + test('should auto apply leading blank', () => { + setRules({ + 'body-leading-blank': [RuleConfigSeverity.Error, 'always'], + }); + + const commitMessage = combineCommitMessage({ + body: 'This is the body message.', + }); + + expect(commitMessage).toBe('\nThis is the body message.'); + }); + + test('should return correct string when leading-blank and max-line-length both set', () => { + setRules({ + 'body-max-line-length': [RuleConfigSeverity.Error, 'always', 10], + 'body-leading-blank': [RuleConfigSeverity.Error, 'always'], + }); + const commitMessage = combineCommitMessage({ + body: 'This is the body message.', + }); + expect(commitMessage).toBe('\nThis is\nthe body\nmessage.'); + }); + + test('should use breakingBody when body message is empty but commit has BREAK CHANGE', () => { + setRules({}); + const commitMessage = combineCommitMessage({ + breakingBody: 'This is breaking body message.', + }); + expect(commitMessage).toBe('This is breaking body message.'); + }); + + test('should use issueBody when body message is empty but commit has issue note', () => { + setRules({}); + const commitMessage = combineCommitMessage({ + issuesBody: 'This is issue body message.', + }); + expect(commitMessage).toBe('This is issue body message.'); + }); +}); diff --git a/@commitlint/cz-commitlint/src/SectionBody.ts b/@commitlint/cz-commitlint/src/SectionBody.ts new file mode 100644 index 0000000000..de528b8858 --- /dev/null +++ b/@commitlint/cz-commitlint/src/SectionBody.ts @@ -0,0 +1,37 @@ +import {Answers, DistinctQuestion} from 'inquirer'; +import wrap from 'word-wrap'; +import Question from './Question'; +import getRuleQuestionConfig from './services/getRuleQuestionConfig'; +import {getRule} from './store/rules'; +import getLeadingBlankFn from './utils/leading-blank-fn'; +import {getMaxLength} from './utils/rules'; + +export function getQuestions(): Array { + // body + const questionConfig = getRuleQuestionConfig('body'); + + if (!questionConfig) return []; + else return [new Question('body', questionConfig).question]; +} + +export function combineCommitMessage(answers: Answers): string { + const maxLineLength = getMaxLength(getRule('body', 'max-line-length')); + const leadingBlankFn = getLeadingBlankFn(getRule('body', 'leading-blank')); + const {body, breakingBody, issuesBody} = answers; + + const commitBody = body ?? breakingBody ?? issuesBody ?? '-'; + + if (commitBody) { + return leadingBlankFn( + maxLineLength < Infinity + ? wrap(commitBody, { + width: maxLineLength, + trim: true, + indent: '', + }) + : commitBody.trim() + ); + } else { + return ''; + } +} diff --git a/@commitlint/cz-commitlint/src/SectionFooter.test.ts b/@commitlint/cz-commitlint/src/SectionFooter.test.ts new file mode 100644 index 0000000000..06752401d5 --- /dev/null +++ b/@commitlint/cz-commitlint/src/SectionFooter.test.ts @@ -0,0 +1,314 @@ +import {RuleConfigSeverity} from '@commitlint/types'; +import {combineCommitMessage, getQuestions} from './SectionFooter'; +import {setPromptConfig} from './store/prompts'; +import {setRules} from './store/rules'; + +beforeEach(() => { + setRules({}); + setPromptConfig({}); +}); +describe('getQuestions', () => { + test('should only ask questions that listed in prompt question config', () => { + setPromptConfig({ + questions: { + footer: { + description: + '