From 5a134a00b66920d67562e7792c9d3366f1500c67 Mon Sep 17 00:00:00 2001 From: tal-rofe Date: Wed, 19 Apr 2023 12:24:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=94=A5=20support=20bodyFormat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit added new "bodyFormat" configuration option that accepts string or function, and added function option to "headerFormat" --- .eslintrc.cjs | 2 +- README.md | 43 +++---- package.json | 3 +- pnpm-lock.yaml | 4 +- src/constants/commit-type.ts | 4 +- src/constants/configuration.ts | 7 +- src/index.ts | 40 ++++-- src/interfaces/configuration.ts | 27 ---- src/interfaces/prompt-answers.ts | 12 ++ src/interfaces/unknown-record.ts | 1 - src/models/configuration.ts | 63 ++++++++++ src/models/env-configuration.ts | 49 ++++++++ src/pipes/commit-format.ts | 69 ++++++++-- src/pipes/commit-type.ts | 4 +- src/pipes/env-format.ts | 17 +++ src/pipes/schema-error.ts | 19 +++ src/services/logger.service.ts | 7 ++ src/types/final-configuration.ts | 5 + src/utils/configuration.ts | 47 ++++--- src/utils/questions.ts | 14 +-- src/validators/commit-type.ts | 43 ------- src/validators/configuration.ts | 95 -------------- tests/pipes/commit-format.spec.ts | 40 ++++-- tests/utils/configuration.spec.ts | 166 ------------------------- tests/validators/commit-type.spec.ts | 84 ------------- tests/validators/configuration.spec.ts | 78 ------------ 26 files changed, 359 insertions(+), 584 deletions(-) delete mode 100644 src/interfaces/configuration.ts create mode 100644 src/interfaces/prompt-answers.ts delete mode 100644 src/interfaces/unknown-record.ts create mode 100644 src/models/configuration.ts create mode 100644 src/models/env-configuration.ts create mode 100644 src/pipes/env-format.ts create mode 100644 src/pipes/schema-error.ts create mode 100644 src/services/logger.service.ts create mode 100644 src/types/final-configuration.ts delete mode 100644 src/validators/commit-type.ts delete mode 100644 src/validators/configuration.ts delete mode 100644 tests/utils/configuration.spec.ts delete mode 100644 tests/validators/commit-type.spec.ts delete mode 100644 tests/validators/configuration.spec.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 2f2ae9d2..8d5bc021 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -183,7 +183,7 @@ module.exports = { }, }, { - files: ['./scripts/onboarding.js'], + files: ['./scripts/onboarding.js', './src/services/logger.service.ts'], rules: { 'no-console': 'off', }, diff --git a/README.md b/README.md index 8eda4c41..2a8ddada 100644 --- a/README.md +++ b/README.md @@ -143,27 +143,28 @@ The default commit types, descriptions and emoji that are used are: ] ``` -| Environment variable | Key | Type | default | Description | -| :------------------------- | :-------------------- | :--------- | :------------------------------------------------------------ | :------------------------------------------------------------------------- | -| `CZ_HEADER_FORMAT` | `headerFormat` | `string` | `{type}: {emoji} [{ticket_id}] {subject}` | How the commit header will be formatted. Support: `type`, `scope`, `emoji`, `ticket_id`, `subject` -| `CZ_COMMIT_TYPES` | `commitTypes` | `{ value: string; description: string; emoji?: string }[]` | Above | The commit types to work with. Only `value` and `description` are required | -| `CZ_MAX_COMMIT_LINE_WIDTH` | `maxCommitLineWidth` | `number` | `72` | Wraps the commit body message with max line width | -| `CZ_TYPE_QUESTION` | `typeQuestion` | `string` | `Select the type of changes you're commiting:\n` | The CLI question for type | -| `CZ_SCOPE_QUESTION` | `scopeQuestion` | `string` | `Specify a scope:` | The CLI question for scope | -| `CZ_SKIP_SCOPE` | `skipScope` | `boolean` | `true` | Whether to prompt the user to a scope question | -| `CZ_SCOPES` | `scopes` | `string[]` | `[]` | Available scopes (empty array allows free text) | -| `CZ_TICKET_ID_QUESTION` | `ticketIdQuestion` | `string` | `Type the JIRA Id (ex. V-12345):` | The CLI question for ticket ID | -| `CZ_SKIP_TICKET_ID` | `skipTicketId` | `boolean` | `false` | Whether to prompt the user to a ticket ID question | -| `CZ_TICKET_ID_REGEX` | `ticketIdRegex` | `string` | `((? string` | `{type}: {emoji} [{ticket_id}] {subject}` | How the commit header will be formatted. Supported placeholders: type, scope, emoji, ticket_id, subject | +| CZ_BODY_FORMAT | `bodyFormat` | `string` \| `(commitType: string, scope: string \| undefined, ticketId: string \| undefined, body: string \| undefined) => string` | `{body}` | How the commit body will be formatted. Supported placeholders: type, scope, ticket_id, body | +| | `commitTypes` | `{ value: string; description: string, emoji?: string }[]` | See above | The commit types to work with. | +| CZ_MAX_COMMIT_LINE_WIDTH | `maxCommitLineWidth` | `number` | `72` | Wraps the commit body message with max line width | +| CZ_TYPE_QUESTION | `typeQuestion` | `string` | `Select the type of changes you're committing:\n` | The CLI question for type | +| CZ_SCOPE_QUESTION | `scopeQuestion` | `string` | `Specify a scope:` | The CLI question for scope | +| CZ_SKIP_SCOPE | `skipScope` | `boolean` | `true` | Whether to prompt the user to a scope question | +| | `scopes` | `string[]` | `[]` | Available scopes (empty array allows free text) | +| CZ_TICKET_ID_QUESTION | `ticketIdQuestion` | `string` | `Type the JIRA Id (ex. V-12345):` | The CLI question for ticket ID | +| CZ_SKIP_TICKET_ID | `skipTicketId` | `boolean` | `false` | Whether to prompt the user to a ticket ID question | +| CZ_TICKET_ID_REGEX | `ticketIdRegex` | `string` | `((? { cz.prompt.registerPrompt('autocomplete', InquirerAutoComplete); @@ -21,21 +22,34 @@ const prompter = async (cz: Inquirer, commit: ICommitFunc) => { }; const questions = await getQuestions(configuration); - const answers = await cz.prompt(questions); + const answers = await cz.prompt(questions); + + const answersForHeader = [ + answers.type.type, + answers.scope, + answers.type.emoji, + answers.ticket_id, + answers.subject, + ] as const; + + const header = + typeof configuration.headerFormat === 'function' + ? configuration.headerFormat.call(null, ...answersForHeader) + : formatHeader(configuration.headerFormat, ...answersForHeader); + + const answersForBody = [answers.type.type, answers.scope, answers.ticket_id, answers.body] as const; + + const body = + typeof configuration.bodyFormat === 'function' + ? configuration.bodyFormat.call(null, ...answersForBody) + : formatBody(configuration.bodyFormat, ...answersForBody); commit( [ - formatHeader( - configuration.headerFormat, - answers.type.type, - answers.scope, - answers.type.emoji, - answers.ticket_id, - answers.subject, - ), - wrap(answers.body || '', wrapOptions), - wrap(formatBreakingChange(answers.breakingBody) || '', wrapOptions), - formatIssues(answers.issues), + header, + body.length > 0 ? wrap(body, wrapOptions) : false, + answers.breakingBody ? wrap(formatBreakingChange(answers.breakingBody), wrapOptions) : false, + answers.issues ? formatIssues(answers.issues) : false, ] .filter(Boolean) .join('\n\n') diff --git a/src/interfaces/configuration.ts b/src/interfaces/configuration.ts deleted file mode 100644 index 795bc68c..00000000 --- a/src/interfaces/configuration.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface ICommitType { - readonly value: string; - readonly description: string; - readonly emoji?: string; -} - -export interface IConfiguration { - readonly headerFormat: string; - readonly commitTypes: ICommitType[]; - readonly maxCommitLineWidth: number; - readonly typeQuestion: string; - readonly scopeQuestion: string; - readonly skipScope: boolean; - readonly scopes: ReadonlyArray; - readonly ticketIdQuestion: string; - readonly skipTicketId: boolean; - readonly ticketIdRegex: string; - readonly allowEmptyTicketIdForBranches: string[]; - readonly subjectQuestion: string; - readonly subjectMaxLength: number; - readonly subjectMinLength: number; - readonly bodyQuestion: string; - readonly skipBody: boolean; - readonly skipBreakingChanges: boolean; - readonly issuesQuestion: string; - readonly skipIssues: boolean; -} diff --git a/src/interfaces/prompt-answers.ts b/src/interfaces/prompt-answers.ts new file mode 100644 index 00000000..5080a914 --- /dev/null +++ b/src/interfaces/prompt-answers.ts @@ -0,0 +1,12 @@ +export interface PromptAnswers { + readonly type: Readonly<{ + type: string; + emoji?: string; + }>; + readonly scope?: string; + readonly ticket_id?: string; + readonly subject: string; + readonly body?: string; + readonly breakingBody?: string; + readonly issues?: string; +} diff --git a/src/interfaces/unknown-record.ts b/src/interfaces/unknown-record.ts deleted file mode 100644 index 04f063d9..00000000 --- a/src/interfaces/unknown-record.ts +++ /dev/null @@ -1 +0,0 @@ -export type IUnknownRecord = Record; diff --git a/src/models/configuration.ts b/src/models/configuration.ts new file mode 100644 index 00000000..32f333da --- /dev/null +++ b/src/models/configuration.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; + +const ConfigurationSchema = z + .object({ + headerFormat: z.union([z.string(), z.function().returns(z.string())], { + invalid_type_error: + '"headerFormat" configuration key must be a string or function(must return string)', + }), + bodyFormat: z.union([z.string(), z.function().returns(z.string())], { + invalid_type_error: + '"bodyFormat" configuration key must be a string or function(must return string)', + }), + commitTypes: z.array( + z.object({ + value: z.string(), + description: z.string(), + emoji: z.string().emoji().optional(), + }), + { + invalid_type_error: + '"commitTypes" configuration key must be an array with object(s) matching the commit type schema', + }, + ), + maxCommitLineWidth: z.number({ + invalid_type_error: '"maxCommitLineWidth" configuration key must be a number', + }), + typeQuestion: z.string({ invalid_type_error: '"typeQuestion" configuration key must be a string' }), + scopeQuestion: z.string({ invalid_type_error: '"scopeQuestion" configuration key must be a string' }), + skipScope: z.boolean({ invalid_type_error: '"skipScope" configuration key must be a boolean' }), + scopes: z.array(z.string(), { + invalid_type_error: '"scopes" configuration key must be an array of strings', + }), + ticketIdQuestion: z.string({ + invalid_type_error: '"ticketIdQuestion" configuration key must be a string', + }), + skipTicketId: z.boolean({ invalid_type_error: '"skipTicketId" configuration key must be a boolean' }), + ticketIdRegex: z.string({ invalid_type_error: '"ticketIdRegex" configuration key must be a string' }), + allowEmptyTicketIdForBranches: z.array(z.string(), { + invalid_type_error: + '"allowEmptyTicketIdForBranches" configuration key must a an array of strings', + }), + subjectQuestion: z.string({ + invalid_type_error: '"subjectQuestion" configuration key must be a string', + }), + subjectMaxLength: z.number({ + invalid_type_error: '"subjectMaxLength" configuration key must be a number', + }), + subjectMinLength: z.number({ + invalid_type_error: '"subjectMinLength" configuration key must be a number', + }), + bodyQuestion: z.string({ invalid_type_error: '"bodyQuestion" configuration key must be a string' }), + skipBody: z.boolean({ invalid_type_error: '"skipBody" configuration key must be a boolean' }), + skipBreakingChanges: z.boolean({ + invalid_type_error: '"skipBreakingChanges" configuration key must be a boolean', + }), + issuesQuestion: z.string({ + invalid_type_error: '"issuesQuestion" configuration key must be a string', + }), + skipIssues: z.boolean({ invalid_type_error: '"skipIssues" configuration key must be a boolean' }), + }) + .partial(); + +export default ConfigurationSchema; diff --git a/src/models/env-configuration.ts b/src/models/env-configuration.ts new file mode 100644 index 00000000..ed6a2816 --- /dev/null +++ b/src/models/env-configuration.ts @@ -0,0 +1,49 @@ +import { z as baseZ } from 'zod'; + +const z = baseZ.coerce; + +const EnvConfigurationSchema = baseZ + .object({ + CZ_HEADER_FORMAT: z.string({ + invalid_type_error: '"CZ_HEADER_FORMAT" env must be a string', + }), + CZ_BODY_FORMAT: z.string({ + invalid_type_error: '"CZ_BODY_FORMAT" env must be a string', + }), + CZ_MAX_COMMIT_LINE_WIDTH: z.number({ + invalid_type_error: '"CZ_MAX_COMMIT_LINE_WIDTH" env must be a number', + }), + CZ_TYPE_QUESTION: z.string({ invalid_type_error: '"CZ_TYPE_QUESTION" env must be a string' }), + CZ_SCOPE_QUESTION: z.string({ invalid_type_error: '"CZ_SCOPE_QUESTION" env must be a string' }), + CZ_SKIP_SCOPE: z.boolean({ invalid_type_error: '"CZ_SKIP_SCOPE" env must be a boolean' }), + CZ_TICKET_ID_QUESTION: z.string({ + invalid_type_error: '"CZ_TICKET_ID_QUESTION" env must be a string', + }), + CZ_SKIP_TICKET_ID: z.boolean({ + invalid_type_error: '"CZ_SKIP_TICKET_ID" env must be a boolean', + }), + CZ_TICKET_ID_REGEX: z.string({ invalid_type_error: '"CZ_TICKET_ID_REGEX" env must be a string' }), + CZ_SUBJECT_QUESTION: z.string({ + invalid_type_error: '"CZ_SUBJECT_QUESTION" env must be a string', + }), + CZ_SUBJECT_MAX_LENGTH: z.number({ + invalid_type_error: '"CZ_SUBJECT_MAX_LENGTH" env must be a number', + }), + CZ_SUBJECT_MIN_LENGTH: z.number({ + invalid_type_error: '"CZ_SUBJECT_MIN_LENGTH" env must be a number', + }), + CZ_BODY_QUESTION: z.string({ + invalid_type_error: '"CZ_BODY_QUESTION" env must be a string', + }), + CZ_SKIP_BODY: z.boolean({ invalid_type_error: '"CZ_SKIP_BODY" env must be a boolean' }), + CZ_SKIP_BREAKING_CHANGES: z.boolean({ + invalid_type_error: '"CZ_SKIP_BREAKING_CHANGES" env must be a boolean', + }), + CZ_ISSUES_QUESTION: z.string({ + invalid_type_error: '"CZ_ISSUES_QUESTION" env must be a string', + }), + CZ_SKIP_ISSUES: z.boolean({ invalid_type_error: '"CZ_SKIP_ISSUES" env must be a boolean' }), + }) + .partial(); + +export default EnvConfigurationSchema; diff --git a/src/pipes/commit-format.ts b/src/pipes/commit-format.ts index 418bc9d3..7316e14e 100644 --- a/src/pipes/commit-format.ts +++ b/src/pipes/commit-format.ts @@ -6,13 +6,14 @@ import StringTemplate from 'string-template'; * @returns the formatted issues */ export const formatIssues = (issues: string) => { - const issuesInCommit = issues ? 'Closes ' + (issues.match(/#\d+/g) || []).join(', closes ') : ''; + const issuesInCommit = 'Closes ' + (issues.match(/#\d+/g) || []).join(', closes '); return issuesInCommit.trim(); }; /** - * The function receives the relevant inputs fro the header and format those to the commit message + * The function receives the relevant inputs for the header and formats those to the commit message + * @param format format of the header * @param type type of the commit * @param scope scope of the commit * @param emoji emoji of the commit @@ -23,13 +24,22 @@ export const formatIssues = (issues: string) => { export const formatHeader = ( format: string, type: string, - scope: string, - emoji: string, - ticketId: string, + scope: string | undefined, + emoji: string | undefined, + ticketId: string | undefined, subject: string, ) => { - if (!ticketId && format.includes('[{ticket_id}]')) { + if (!scope && format.includes('{scope}')) { + format = format.replace('{scope}', ''); + } + + if (!emoji && format.includes('{emoji}')) { + format = format.replace('{emoji}', ''); + } + + if (!ticketId && format.includes('{ticket_id}')) { format = format.replace('[{ticket_id}]', ''); + format = format.replace('{ticket_id}', ''); } const commitHeader = StringTemplate(format, { @@ -45,13 +55,50 @@ export const formatHeader = ( return commitHeader; }; +/** + * The function receives the relevant inputs for the body and formats those to the commit message + * @param format format of the body + * @param type type of the commit + * @param scope scope of the commit + * @param ticketId ticket Id of the commit + * @param body body of the commit + * @returns the formatted body + */ +export const formatBody = ( + format: string, + type: string, + scope: string | undefined, + ticketId: string | undefined, + body: string | undefined, +) => { + if (!scope && format.includes('{scope}')) { + format = format.replace('{scope}', ''); + } + + if (!body && format.includes('{body}')) { + format = format.replace('{body}', ''); + } + + if (!ticketId && format.includes('{ticket_id}')) { + format = format.replace('[{ticket_id}]', ''); + format = format.replace('{ticket_id}', ''); + } + + const commitBody = StringTemplate(format, { + type, + scope, + ticket_id: ticketId, + body, + }) + .replace(/\s{2,}/g, ' ') + .trim(); + + return commitBody; +}; + /** * The function receives the breaking change input and formats it into the commit message * @param breakingChange the breaking change input * @returns the formatted breaking change */ -export const formatBreakingChange = (breakingChange: string) => { - const breakingChangesInCommit = `${breakingChange ? `BREAKING CHANGE: ${breakingChange}` : ''}`; - - return breakingChangesInCommit; -}; +export const formatBreakingChange = (breakingChange: string) => `BREAKING CHANGE: ${breakingChange}`; diff --git a/src/pipes/commit-type.ts b/src/pipes/commit-type.ts index d0d538cc..2344d529 100644 --- a/src/pipes/commit-type.ts +++ b/src/pipes/commit-type.ts @@ -1,11 +1,11 @@ -import type { ICommitType } from '../interfaces/configuration'; +import type { FinalConfiguration } from '../types/final-configuration'; /** * The function gets a commit type and transforms it into a an object dedicated for inquirier * @param commitType the commit type to transform * @returns a detailed object */ -export const transformCommitType = (commitType: ICommitType) => { +export const transformCommitType = (commitType: FinalConfiguration['commitTypes'][0]) => { if (commitType.emoji) { return { name: `${commitType.emoji} ${commitType.value}: ${commitType.description}`, diff --git a/src/pipes/env-format.ts b/src/pipes/env-format.ts new file mode 100644 index 00000000..4c45deb4 --- /dev/null +++ b/src/pipes/env-format.ts @@ -0,0 +1,17 @@ +import type { z } from 'zod'; + +import type ConfigurationSchema from '../models/configuration'; + +const stringToCamelCase = (value: string) => + value + .toLowerCase() + .replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace('-', '').replace('_', '')); + +export const envToCamelCase = (env: Record) => { + return Object.keys(env).reduce>((finalObj, envKey) => { + return { + ...finalObj, + [stringToCamelCase(envKey.replace('CZ_', ''))]: env[envKey], + }; + }, {}); +}; diff --git a/src/pipes/schema-error.ts b/src/pipes/schema-error.ts new file mode 100644 index 00000000..c7cc23ff --- /dev/null +++ b/src/pipes/schema-error.ts @@ -0,0 +1,19 @@ +import type { ZodIssue } from 'zod'; + +export const formatSchemaError = (issues: ZodIssue[]) => { + return issues.reduce((finalErrorMessage, issue) => { + const configurationFieldPath = issue.path.join('.'); + const currentErrorMessage = `(cz-vinyl) Error from configuration field "${configurationFieldPath}": ${issue.message}`; + + return `${finalErrorMessage}\n${currentErrorMessage}`; + }, ''); +}; + +export const formatEnvSchemaError = (issues: ZodIssue[]) => { + return issues.reduce((finalErrorMessage, issue) => { + const configurationFieldPath = issue.path.join('.'); + const currentErrorMessage = `(cz-vinyl) Error from env configuration "${configurationFieldPath}": ${issue.message}`; + + return `${finalErrorMessage}\n${currentErrorMessage}`; + }, ''); +}; diff --git a/src/services/logger.service.ts b/src/services/logger.service.ts new file mode 100644 index 00000000..feeebc44 --- /dev/null +++ b/src/services/logger.service.ts @@ -0,0 +1,7 @@ +class LoggerService { + public static info(message: unknown) { + console.log(message); + } +} + +export default LoggerService; diff --git a/src/types/final-configuration.ts b/src/types/final-configuration.ts new file mode 100644 index 00000000..c9ffd0f8 --- /dev/null +++ b/src/types/final-configuration.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; + +import type ConfigurationSchema from '../models/configuration'; + +export type FinalConfiguration = Required>; diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts index c0ca16bc..f0ac41ce 100644 --- a/src/utils/configuration.ts +++ b/src/utils/configuration.ts @@ -2,9 +2,12 @@ import { cosmiconfig } from 'cosmiconfig'; import TypeScriptLoader from 'cosmiconfig-typescript-loader'; import { DEFAULT_CONFIGURATION } from '../constants/configuration'; -import type { IConfiguration } from '../interfaces/configuration'; -import { validateConfiguration, validateEnvConfiguration } from '../validators/configuration'; import { CONFIGURATION_MODULE_NAME, SEARCH_PLACES } from '../constants/cosmiconfig'; +import ConfigurationSchema from '../models/configuration'; +import { formatEnvSchemaError, formatSchemaError } from '../pipes/schema-error'; +import LoggerService from '../services/logger.service'; +import EnvConfigurationSchema from '../models/env-configuration'; +import { envToCamelCase } from '../pipes/env-format'; /** * The function sets a default configuration to work with, @@ -12,7 +15,7 @@ import { CONFIGURATION_MODULE_NAME, SEARCH_PLACES } from '../constants/cosmiconf * @returns The final configuration */ export const getConfiguration = async () => { - let finalConfiguration: IConfiguration = DEFAULT_CONFIGURATION; + let finalConfiguration = DEFAULT_CONFIGURATION; const explorer = cosmiconfig(CONFIGURATION_MODULE_NAME, { searchPlaces: SEARCH_PLACES, @@ -24,25 +27,39 @@ export const getConfiguration = async () => { try { const result = await explorer.search(); - let configurationFromFile: Partial; + if (result && !result.isEmpty && typeof result.config === 'object') { + const parseResult = await ConfigurationSchema.safeParseAsync(result.config); - if (!result || result.isEmpty || typeof result.config !== 'object') { - configurationFromFile = {}; - } else { - configurationFromFile = validateConfiguration(result.config); - } + if (!parseResult.success) { + const schemaErrorMessage = formatSchemaError(parseResult.error.issues); + + LoggerService.info(schemaErrorMessage); - finalConfiguration = { - ...finalConfiguration, - ...configurationFromFile, - }; + process.exit(1); + } + + finalConfiguration = { + ...finalConfiguration, + ...parseResult.data, + }; + } } catch {} - const configurationFromENVs = validateEnvConfiguration(); + const parseResult = await EnvConfigurationSchema.safeParseAsync(process.env); + + if (!parseResult.success) { + const schemaErrorMessage = formatEnvSchemaError(parseResult.error.issues); + + LoggerService.info(schemaErrorMessage); + + process.exit(1); + } + + const envConfiguration = envToCamelCase(parseResult.data); finalConfiguration = { ...finalConfiguration, - ...configurationFromENVs, + ...envConfiguration, }; return finalConfiguration; diff --git a/src/utils/questions.ts b/src/utils/questions.ts index e0a19d52..54811dd3 100644 --- a/src/utils/questions.ts +++ b/src/utils/questions.ts @@ -1,7 +1,7 @@ import fuse from 'fuse.js'; -import type { IConfiguration } from '../interfaces/configuration'; import { transformCommitType } from '../pipes/commit-type'; +import type { FinalConfiguration } from '../types/final-configuration'; import { getTicketIdFromBranchName, shouldValidateTicketId } from './git-info'; /** @@ -9,7 +9,7 @@ import { getTicketIdFromBranchName, shouldValidateTicketId } from './git-info'; * @param configuration the configuration to use to build the questions * @returns questions */ -export const getQuestions = async (configuration: IConfiguration) => { +export const getQuestions = async (configuration: FinalConfiguration) => { const defaultCommitTypes = configuration.commitTypes.map(transformCommitType); const isScopesListsMode = Array.isArray(configuration.scopes) && configuration.scopes.length > 0; @@ -45,9 +45,9 @@ export const getQuestions = async (configuration: IConfiguration) => { ), }, { - when: !configuration.skipScope, type: isScopesListsMode ? 'autocomplete' : 'input', name: 'scope', + when: !configuration.skipScope, message: configuration.scopeQuestion, source: (_: unknown, query: string) => Promise.resolve( @@ -59,6 +59,7 @@ export const getQuestions = async (configuration: IConfiguration) => { { type: 'input', name: 'ticket_id', + when: !configuration.skipTicketId, message: configuration.ticketIdQuestion, default: shouldValidateTicket ? await getTicketIdFromBranchName(new RegExp(configuration.ticketIdRegex)) @@ -70,7 +71,6 @@ export const getQuestions = async (configuration: IConfiguration) => { return new RegExp(configuration.ticketIdRegex).test(input) || 'Ticket Id must be valid'; }, - when: !configuration.skipTicketId, }, { type: 'maxlength-input', @@ -95,21 +95,21 @@ export const getQuestions = async (configuration: IConfiguration) => { { type: 'input', name: 'body', - message: configuration.bodyQuestion, when: !configuration.skipBody, + message: configuration.bodyQuestion, }, { type: 'input', name: 'breakingBody', + when: !configuration.skipBreakingChanges, message: 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself:\n', - when: !configuration.skipBreakingChanges, }, { type: 'input', name: 'issues', - message: configuration.issuesQuestion, when: !configuration.skipIssues, + message: configuration.issuesQuestion, }, ]; }; diff --git a/src/validators/commit-type.ts b/src/validators/commit-type.ts deleted file mode 100644 index 924342d4..00000000 --- a/src/validators/commit-type.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ICommitType } from '../interfaces/configuration'; -import type { IUnknownRecord } from '../interfaces/unknown-record'; - -/** - * The function validates a commit type - * @param commitType the commit type to validate - * @returns a boolean flag indicates the validity of the commit type - */ -const validateCommitType = (commitType: unknown) => { - if (!commitType || typeof commitType !== 'object') { - return false; - } - - const cType = commitType as IUnknownRecord; - const value = cType['value']; - const description = cType['description']; - const emoji = cType['emoji']; - - if ( - !value || - typeof value !== 'string' || - !description || - typeof description !== 'string' || - (emoji !== undefined && (!emoji || typeof emoji !== 'string')) - ) { - return false; - } - - return true; -}; - -/** - * The function validates a given array of commit types - * @param commitTypes the commit types array to validate - * @returns the input if valid, otherwise undefined - */ -export const validateCommitTypes = (commitTypes: unknown) => { - if (!Array.isArray(commitTypes)) { - return undefined; - } - - return commitTypes.filter(validateCommitType) as ICommitType[]; -}; diff --git a/src/validators/configuration.ts b/src/validators/configuration.ts deleted file mode 100644 index cb8e41f9..00000000 --- a/src/validators/configuration.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { withCleanObject } from '../utils/object'; -import type { ICommitType, IConfiguration } from '../interfaces/configuration'; -import type { IUnknownRecord } from '../interfaces/unknown-record'; -import { parseToBoolean } from '../pipes/base'; -import { validateBoolean, validatePositiveInt, validateString } from './base'; -import { validateCommitTypes } from './commit-type'; -import { validateRegex, validateStringArray } from './complex'; - -/** - * The function validates a given configuration. - * Will return only valid keys from the input configuration - * @param configuration the configuration to validate - * @returns configuration with only valid keys - */ -export const validateConfiguration = (configuration: IUnknownRecord) => { - const finalConfiguration: Partial = { - headerFormat: validateString(configuration['headerFormat']), - commitTypes: validateCommitTypes(configuration['commitTypes']), - maxCommitLineWidth: validatePositiveInt(configuration['maxCommitLineWidth']), - typeQuestion: validateString(configuration['typeQuestion']), - scopeQuestion: validateString(configuration['scopeQuestion']), - skipScope: validateBoolean(configuration['skipScope']), - scopes: validateStringArray(configuration['scopes']), - ticketIdQuestion: validateString(configuration['ticketIdQuestion']), - skipTicketId: validateBoolean(configuration['skipTicketId']), - ticketIdRegex: validateRegex(configuration['ticketIdRegex']), - allowEmptyTicketIdForBranches: validateStringArray(configuration['allowEmptyTicketIdForBranches']), - subjectQuestion: validateString(configuration['subjectQuestion']), - subjectMaxLength: validatePositiveInt(configuration['subjectMaxLength']), - subjectMinLength: validatePositiveInt(configuration['subjectMinLength']), - bodyQuestion: validateString(configuration['bodyQuestion']), - skipBody: validateBoolean(configuration['skipBody']), - skipBreakingChanges: validateBoolean(configuration['skipBreakingChanges']), - issuesQuestion: validateString(configuration['issuesQuestion']), - skipIssues: validateBoolean(configuration['skipIssues']), - }; - - return withCleanObject(finalConfiguration); -}; - -/** - * The function validates the configurations from environment variables. - * Will return only valid keys from the input configuration - * @returns configuration with only valid keys - */ -export const validateEnvConfiguration = () => { - let parsedCommitTypes: ICommitType[] | undefined; - - try { - const parsedValue = JSON.parse(process.env.CZ_COMMIT_TYPES ?? ''); - - parsedCommitTypes = validateCommitTypes(parsedValue); - } catch {} - - let parsedScopes: string[] | undefined; - let parsedExcludedBranches: string[] | undefined; - - try { - const parsedScopesValue = process.env.CZ_SCOPES ? JSON.parse(process.env.CZ_SCOPES) : undefined; - - parsedScopes = validateStringArray(parsedScopesValue); - } catch {} - - try { - const parsedExcludedBranchesValue = process.env.CZ_ALLOW_EMPTY_TICKET_ID_FOR_BRANCHES - ? JSON.parse(process.env.CZ_ALLOW_EMPTY_TICKET_ID_FOR_BRANCHES) - : undefined; - - parsedExcludedBranches = validateStringArray(parsedExcludedBranchesValue); - } catch {} - - const envConfiguration: Partial = { - headerFormat: validateString(process.env.CZ_HEADER_FORMAT), - commitTypes: parsedCommitTypes, - maxCommitLineWidth: validatePositiveInt(parseInt(process.env.CZ_MAX_COMMIT_LINE_WIDTH ?? '')), - typeQuestion: validateString(process.env.CZ_TYPE_QUESTION), - scopeQuestion: validateString(process.env.CZ_SCOPE_QUESTION), - skipScope: parseToBoolean(process.env.CZ_SKIP_SCOPE), - scopes: parsedScopes, - ticketIdQuestion: validateString(process.env.CZ_TICKET_ID_QUESTION), - skipTicketId: parseToBoolean(process.env.CZ_SKIP_TICKET_ID), - ticketIdRegex: validateRegex(process.env.CZ_TICKET_ID_REGEX), - allowEmptyTicketIdForBranches: parsedExcludedBranches, - subjectQuestion: validateString(process.env.CZ_SUBJECT_QUESTION), - subjectMaxLength: validatePositiveInt(parseInt(process.env.CZ_SUBJECT_MAX_LENGTH ?? '')), - subjectMinLength: validatePositiveInt(parseInt(process.env.CZ_SUBJECT_MIN_LENGTH ?? '')), - bodyQuestion: validateString(process.env.CZ_BODY_QUESTION), - skipBody: parseToBoolean(process.env.CZ_SKIP_BODY), - skipBreakingChanges: parseToBoolean(process.env.CZ_SKIP_BREAKING_CHANGES), - issuesQuestion: validateString(process.env.CZ_ISSUES_QUESTION), - skipIssues: parseToBoolean(process.env.CZ_SKIP_ISSUES), - }; - - return withCleanObject(envConfiguration); -}; diff --git a/tests/pipes/commit-format.spec.ts b/tests/pipes/commit-format.spec.ts index d96729f3..69782ad5 100644 --- a/tests/pipes/commit-format.spec.ts +++ b/tests/pipes/commit-format.spec.ts @@ -1,15 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { formatIssues, formatHeader, formatBreakingChange } from '@/pipes/commit-format'; +import { formatIssues, formatHeader, formatBreakingChange, formatBody } from '@/pipes/commit-format'; describe.concurrent('[pipes/commit-format]', () => { describe.concurrent('formatIssues()', () => { - it('should return empty output when there is empty input', () => { - const result = formatIssues(''); - - expect(result === '').toEqual(true); - }); - it('should return "Closes" output when there are no issues in input', () => { const result = formatIssues('JUST A TEST'); @@ -26,9 +20,9 @@ describe.concurrent('[pipes/commit-format]', () => { describe('formatHeader()', () => { it('should return proper output for all possible options', () => { const result1 = formatHeader( - '{type}: {emoji} [{ticket_id}] {subject}', + '{type}: {emoji} [{ticket_id}] {scope} {subject}', 'typeTest', - 'scopeTest', + undefined, 'emojiTest', 'ticketIdTest', 'subjectTest', @@ -68,13 +62,33 @@ describe.concurrent('[pipes/commit-format]', () => { }); }); - describe.concurrent('formatBreakingChange()', () => { - it('should return empty output when the input is empty', () => { - const result = formatBreakingChange(''); + describe('formatBody()', () => { + it('should return proper output for all possible options', () => { + const result1 = formatBody( + 'Type is: {type}. Ticket Id is: [{ticket_id}]. Body: {body}. From scope: {scope}', + 'typeTest', + 'scopeTest', + 'ticketIdTest', + 'bodyTest', + ); - expect(result === '').toEqual(true); + const result2 = formatBody( + '{type} {body} {scope} {ticket_id}', + 'typeTest', + undefined, + undefined, + undefined, + ); + + expect( + result1 === + 'Type is: typeTest. Ticket Id is: [ticketIdTest]. Body: bodyTest. From scope: scopeTest', + ); + expect(result2 === 'typeTest'); }); + }); + describe('formatBreakingChange()', () => { it('should return proper output when there is non-empty breaking change input', () => { const breakingChangeInput = 'JUST A TEST'; const result = formatBreakingChange(breakingChangeInput); diff --git a/tests/utils/configuration.spec.ts b/tests/utils/configuration.spec.ts deleted file mode 100644 index 862cdfd5..00000000 --- a/tests/utils/configuration.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { cosmiconfig } from 'cosmiconfig'; - -import { getConfiguration } from '@/utils/configuration'; -import { DEFAULT_CONFIGURATION } from '@/constants/configuration'; -import { validateConfiguration, validateEnvConfiguration } from '@/validators/configuration'; - -vi.mock('cosmiconfig'); -vi.mock('@/validators/configuration'); - -const cosmiconfigFunctions = { - clearCaches: () => undefined, - clearLoadCache: () => undefined, - clearSearchCache: () => undefined, - load: () => Promise.resolve(null), -}; - -describe('[utils/configuration]', () => { - describe('getConfiguration()', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should return the default when there is no configuration file', async () => { - vi.mocked(cosmiconfig).mockReturnValueOnce({ - search: () => Promise.resolve(null), - ...cosmiconfigFunctions, - }); - - const result = await getConfiguration(); - - expect(result).toWeakEqual(DEFAULT_CONFIGURATION); - }); - - it('should return the default when configuration file is empty', async () => { - vi.mocked(cosmiconfig).mockReturnValueOnce({ - search: () => - Promise.resolve({ - config: {}, - filepath: 'FILE_PATH_TEST', - isEmpty: true, - }), - ...cosmiconfigFunctions, - }); - - const result = await getConfiguration(); - - expect(result).toWeakEqual(DEFAULT_CONFIGURATION); - }); - - it('should return the default when trying to get configuration file fails', async () => { - vi.mocked(cosmiconfig).mockReturnValueOnce({ - search: Promise.reject, - ...cosmiconfigFunctions, - }); - - const result = await getConfiguration(); - - expect(result).toWeakEqual(DEFAULT_CONFIGURATION); - }); - - it('should return the proper configuration when found configuration file', async () => { - vi.mocked(cosmiconfig).mockReturnValueOnce({ - search: () => - Promise.resolve({ - config: { skipIssues: false }, - filepath: 'FILE_PATH_TEST', - isEmpty: false, - }), - ...cosmiconfigFunctions, - }); - - const result = await getConfiguration(); - - const expectedOutput = { - ...DEFAULT_CONFIGURATION, - skipIssues: false, - }; - - expect(result).toWeakEqual(expectedOutput); - }); - - it('should return the proper configuration when known environments variables are not set', async () => { - const configurationFromFile = { - skipIssues: false, - }; - - vi.mocked(cosmiconfig).mockReturnValueOnce({ - search: () => - Promise.resolve({ - config: configurationFromFile, - filepath: 'FILE_PATH_TEST', - isEmpty: false, - }), - ...cosmiconfigFunctions, - }); - - const result = await getConfiguration(); - - const expectedOutput = { - ...DEFAULT_CONFIGURATION, - ...configurationFromFile, - }; - - expect(result).toWeakEqual(expectedOutput); - }); - - it('should return the proper configuration when there are no configurations from fule returns an empty object and known environments variables are set', async () => { - const configurationFromENVs = { - skipIssues: false, - }; - - vi.mocked(cosmiconfig).mockReturnValueOnce({ - search: () => - Promise.resolve({ - config: {}, - filepath: 'FILE_PATH_TEST', - isEmpty: true, - }), - ...cosmiconfigFunctions, - }); - vi.mocked(validateConfiguration).mockReturnValueOnce({}); - vi.mocked(validateEnvConfiguration).mockReturnValueOnce(configurationFromENVs); - - const result = await getConfiguration(); - - const expectedOutput = { - ...DEFAULT_CONFIGURATION, - ...configurationFromENVs, - }; - - expect(result).toWeakEqual(expectedOutput); - }); - - it('should return the proper configuration when environment variables are set with same keys from configuration file (ENVs has priority)', async () => { - const configurationFromFile = { - skipIssues: true, - }; - - const configurationFromENVs = { - skipIssues: false, - }; - - vi.mocked(cosmiconfig).mockReturnValueOnce({ - search: () => - Promise.resolve({ - config: configurationFromFile, - filepath: 'FILE_PATH_TEST', - isEmpty: false, - }), - ...cosmiconfigFunctions, - }); - vi.mocked(validateConfiguration).mockReturnValueOnce(configurationFromFile); - vi.mocked(validateEnvConfiguration).mockReturnValueOnce(configurationFromENVs); - - const result = await getConfiguration(); - - const expectedOutput = { - ...DEFAULT_CONFIGURATION, - ...configurationFromENVs, - }; - - expect(result).toWeakEqual(expectedOutput); - }); - }); -}); diff --git a/tests/validators/commit-type.spec.ts b/tests/validators/commit-type.spec.ts deleted file mode 100644 index 06232981..00000000 --- a/tests/validators/commit-type.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { validateCommitTypes } from '@/validators/commit-type'; - -describe('[validators/commit-type]', () => { - it('validateCommitTypes | should return "undefined" for all invalid inputs', () => { - const result1 = validateCommitTypes(''); - const result2 = validateCommitTypes(true); - const result3 = validateCommitTypes(Infinity); - const result4 = validateCommitTypes(-5); - const result5 = validateCommitTypes({}); - - expect(result1 === undefined).toEqual(true); - expect(result2 === undefined).toEqual(true); - expect(result3 === undefined).toEqual(true); - expect(result4 === undefined).toEqual(true); - expect(result5 === undefined).toEqual(true); - }); - - it('validateCommitTypes | should return proper outputs for valid inputs or filter out invalid inputs in the array', () => { - const item1 = ''; - const item2 = 5; - - const item3 = { - value: '', - description: 'DUMMY_DESCRIPTION', - }; - - const item4 = { - value: 5, - description: 'DUMMY_DESCRIPTION', - }; - - const item5 = { - value: 'DUMMY_VALUE', - description: '', - }; - - const item6 = { - value: 'DUMMY_VALUE', - description: 5, - }; - - const item7 = { - value: 'DUMMY_VALUE', - description: 'DUMMY_DESCRIPTION', - emoji: '', - }; - - const item8 = { - value: 'DUMMY_VALUE', - description: 'DUMMY_DESCRIPTION', - emoji: 5, - }; - - const item9 = { - value: 'DUMMY_VALUE', - description: 'DUMMY_DESCRIPTION', - }; - - const item10 = { - value: 'DUMMY_VALUE', - description: 'DUMMY_DESCRIPTION', - emoji: 'DUMMY_EMOJI', - }; - - const result = validateCommitTypes([ - item1, - item2, - item3, - item4, - item5, - item6, - item7, - item8, - item9, - item10, - ]); - - const expectedOutput = [item9, item10]; - - expect(result).toWeakEqual(expectedOutput); - }); -}); diff --git a/tests/validators/configuration.spec.ts b/tests/validators/configuration.spec.ts deleted file mode 100644 index 9ef21924..00000000 --- a/tests/validators/configuration.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { validateConfiguration, validateEnvConfiguration } from '@/validators/configuration'; - -describe('[validators/configuration]', () => { - beforeEach(() => { - vi.stubEnv('CZ_HEADER_FORMAT', 'JUST A TEST'); - vi.stubEnv('CZ_SKIP_ISSUES', 'JUST A TEST'); - vi.stubEnv('CZ_SUBJECT_MAX_LENGTH', 'JUST A TEST'); - vi.stubEnv('CZ_SCOPES', '["SCOPE"]'); - vi.stubEnv('CZ_COMMIT_TYPES', '[]'); - vi.stubEnv('CZ_SUBJECT_MIN_LENGTH', '5'); - vi.stubEnv('CZ_MAX_COMMIT_LINE_WIDTH', '5'); - vi.stubEnv('CZ_ALLOW_EMPTY_TICKET_ID_FOR_BRANCHES', '["TEST"]'); - }); - - it('validateConfiguration | should return object with all the valid-only fields', () => { - const input = { - headerFormat: 'DUMMY_HEADER_FORMAT', - skipIssues: 'DUMMY_SKIP_ISSUES', - }; - - const expectedOutput = { - headerFormat: 'DUMMY_HEADER_FORMAT', - }; - - const result = validateConfiguration(input); - - expect(result).toWeakEqual(expectedOutput); - }); - - it('validateEnvConfiguration | should return object with all the valid-only fields', () => { - const expectedOutput = { - headerFormat: 'JUST A TEST', - commitTypes: [], - maxCommitLineWidth: 5, - scopes: ['SCOPE'], - subjectMinLength: 5, - allowEmptyTicketIdForBranches: ['TEST'], - }; - - const result = validateEnvConfiguration(); - - expect(result).toWeakEqual(expectedOutput); - }); - - it('validateEnvConfiguration | should return object with all the valid-only fields when env "CZ_SCOPES" is invalid', () => { - vi.stubEnv('CZ_SCOPES', '{'); - - const expectedOutputWithInvalidEnv = { - headerFormat: 'JUST A TEST', - commitTypes: [], - maxCommitLineWidth: 5, - subjectMinLength: 5, - allowEmptyTicketIdForBranches: ['TEST'], - }; - - const resultWithInvalidEnv = validateEnvConfiguration(); - - expect(expectedOutputWithInvalidEnv).toWeakEqual(resultWithInvalidEnv); - }); - - it('validateEnvConfiguration | should return object with all the valid-only fields when env "CZ_ALLOW_EMPTY_TICKET_ID_FOR_BRANCHES" is invalid', () => { - vi.stubEnv('CZ_ALLOW_EMPTY_TICKET_ID_FOR_BRANCHES', '{'); - - const expectedOutputWithInvalidEnv = { - headerFormat: 'JUST A TEST', - commitTypes: [], - maxCommitLineWidth: 5, - scopes: ['SCOPE'], - subjectMinLength: 5, - }; - - const resultWithInvalidEnv = validateEnvConfiguration(); - - expect(expectedOutputWithInvalidEnv).toWeakEqual(resultWithInvalidEnv); - }); -});