From 9e09f609fa597411ca5cc21cf370813907a624c5 Mon Sep 17 00:00:00 2001 From: ribeirogab Date: Sat, 17 Aug 2024 16:56:24 -0300 Subject: [PATCH] feat: add custom context prompt generation logic --- package.json | 2 +- src/commands/generate-commit.spec.ts | 73 ++++++------ src/commands/generate-commit.ts | 159 ++++++++++++++++++++++---- src/commands/setup.spec.ts | 89 +++++++++++--- src/commands/setup.ts | 52 +++++++-- src/constants.ts | 33 ++++++ src/container.ts | 2 +- src/interfaces/commands/setup.ts | 4 + src/interfaces/index.ts | 6 + src/interfaces/provider.ts | 8 +- src/interfaces/utils/env.utils.ts | 7 ++ src/interfaces/utils/input.utils.ts | 2 +- src/providers/openai.provider.spec.ts | 13 ++- src/providers/openai.provider.ts | 17 ++- src/utils/env.utils.spec.ts | 7 +- src/utils/env.utils.ts | 30 +++-- tests/fakes/utils/env.utils.fake.ts | 15 ++- 17 files changed, 417 insertions(+), 102 deletions(-) create mode 100644 src/interfaces/commands/setup.ts diff --git a/package.json b/package.json index 6d3120e..ea26042 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commitfy", - "version": "0.0.14", + "version": "0.1.0", "main": "lib/index.js", "repository": "https://github.com/ribeirogab/commitfy.git", "author": "ribeirogab ", diff --git a/src/commands/generate-commit.spec.ts b/src/commands/generate-commit.spec.ts index 110d465..a8c8cae 100644 --- a/src/commands/generate-commit.spec.ts +++ b/src/commands/generate-commit.spec.ts @@ -2,6 +2,7 @@ import { GenerateCommit } from './generate-commit'; import { ProviderEnum } from '@/interfaces'; import { makeProvidersFake } from '@/tests/fakes/providers'; import { + DEFAULT_ENV, makeAppUtilsFake, makeEnvUtilsFake, makeInputUtilsFake, @@ -39,20 +40,21 @@ describe('GenerateCommit', () => { it('should create an instance of GenerateCommit', () => { const { sut } = makeSut(); - expect(sut).toBeInstanceOf(GenerateCommit); }); it('should log an error and exit if provider is not set', async () => { - const { sut, appUtils } = makeSut(); + const { sut, appUtils, processUtils } = makeSut(); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((number) => { - throw new Error('process.exit: ' + number); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); }); const loggerMessageSpy = vi.spyOn(appUtils.logger, 'message'); const loggerErrorSpy = vi.spyOn(appUtils.logger, 'error'); - await expect(sut.execute()).rejects.toThrow(); + vi.spyOn(processUtils, 'exec').mockResolvedValue('diff'); + + await expect(sut.execute()).rejects.toThrow('process.exit called'); expect(loggerErrorSpy).toHaveBeenCalledWith('AI provider not set.'); @@ -67,17 +69,18 @@ describe('GenerateCommit', () => { const { sut, appUtils, envUtils, processUtils } = makeSut(); const loggerMessageSpy = vi.spyOn(appUtils.logger, 'error'); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((number) => { - throw new Error('process.exit: ' + number); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); }); vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + ...DEFAULT_ENV, PROVIDER: ProviderEnum.OpenAI, }); vi.spyOn(processUtils, 'exec').mockResolvedValueOnce(''); - await expect(sut.execute()).rejects.toThrow(); + await expect(sut.execute()).rejects.toThrow('process.exit called'); expect(loggerMessageSpy).toHaveBeenCalledWith('No changes to commit.'); expect(exitSpy).toHaveBeenCalledWith(0); }); @@ -89,63 +92,65 @@ describe('GenerateCommit', () => { .spyOn(process, 'exit') .mockImplementation((number) => number as never); - const variablesSpy = vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + ...DEFAULT_ENV, PROVIDER: ProviderEnum.OpenAI, }); - const generateCommitMessagesSpy = vi - .spyOn(providers[ProviderEnum.OpenAI], 'generateCommitMessages') - .mockResolvedValueOnce(['commit message']); + vi.spyOn( + providers[ProviderEnum.OpenAI], + 'generateCommitMessages', + ).mockResolvedValueOnce(['commit message']); - const execSpy = vi - .spyOn(processUtils, 'exec') - .mockResolvedValueOnce('some changes'); + vi.spyOn(processUtils, 'exec').mockResolvedValue('some changes'); - const promptSpy = vi - .spyOn(inputUtils, 'prompt') - .mockResolvedValueOnce('commit message'); + vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce('commit message'); await expect(sut.execute()).resolves.not.toThrow(); - expect(variablesSpy).toHaveBeenCalled(); - - expect(generateCommitMessagesSpy).toHaveBeenCalledWith({ - diff: 'some changes', - }); + expect( + providers[ProviderEnum.OpenAI].generateCommitMessages, + ).toHaveBeenCalledOnce(); - expect(execSpy).toHaveBeenCalledWith(`git commit -m "commit message"`, { - showStdout: true, - }); + expect(processUtils.exec).toHaveBeenCalledWith( + `git commit -m "commit message"`, + { + showStdout: true, + }, + ); - expect(promptSpy).toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(0); }); it('should call execute again if user chooses to regenerate', async () => { const { sut, envUtils, processUtils, inputUtils, providers } = makeSut(); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - vi.spyOn(envUtils, 'variables').mockReturnValue({ - PROVIDER: ProviderEnum.OpenAI, - }); + vi.spyOn(envUtils, 'variables') + .mockReturnValueOnce({ + ...DEFAULT_ENV, + PROVIDER: ProviderEnum.OpenAI, + }) + .mockImplementationOnce(() => { + throw new Error('end process'); + }); vi.spyOn( providers[ProviderEnum.OpenAI], 'generateCommitMessages', ).mockResolvedValueOnce(['commit 1', 'commit 2']); - vi.spyOn(processUtils, 'exec').mockResolvedValueOnce('some changes'); + vi.spyOn(processUtils, 'exec').mockResolvedValue('some changes'); vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce('↻ regenerate'); const executeSpy = vi.spyOn(sut, 'execute'); - await expect(sut.execute()).rejects.toThrow('process.exit called'); + await expect(sut.execute()).rejects.toThrow('end process'); expect(executeSpy).toHaveBeenCalledTimes(2); - expect(exitSpy).toHaveBeenCalledWith(0); }); }); diff --git a/src/commands/generate-commit.ts b/src/commands/generate-commit.ts index 35c80c8..20da944 100644 --- a/src/commands/generate-commit.ts +++ b/src/commands/generate-commit.ts @@ -1,16 +1,18 @@ +import { CustomConfigKeysEnum, SemanticCommitContextEnum } from '@/constants'; import { type AppUtils, + type Env, type EnvUtils, type InputUtils, InputUtilsCustomChoiceEnum, type ProcessUtils, type Provider, type Providers, + SetupContextEnum, } from '@/interfaces'; export class GenerateCommit { private readonly regeneratorText = '↻ regenerate'; - private provider: Provider; constructor( private readonly providers: Providers, @@ -21,29 +23,15 @@ export class GenerateCommit { ) {} public async execute(): Promise { - this.provider = this.providers[this.envUtils.variables().PROVIDER]; + const env = this.envUtils.variables(); + const diff = await this.getGitDiff(); + const context = await this.getContext({ env }); - if (!this.provider) { - this.appUtils.logger.error('AI provider not set.'); - - this.appUtils.logger.message( - "Run 'commitfy setup' to set up the provider.", - ); - - process.exit(0); - } - - const diff = await this.processUtils.exec('git diff --cached', { - showStdout: false, + const commits = await this.getProvider({ env }).generateCommitMessages({ + prompt: this.generatePrompt({ context, env }), + diff, }); - if (!diff) { - this.appUtils.logger.error('No changes to commit.'); - - process.exit(0); - } - - const commits = await this.provider.generateCommitMessages({ diff }); const oneLineCommits = commits.map((commit) => commit.split('\n')[0]); const choices = [ @@ -68,4 +56,133 @@ export class GenerateCommit { process.exit(0); } + + private async getContext({ env }: { env: Env }): Promise { + if (env.SETUP_CONTEXT === SetupContextEnum.Automatic) { + return null; + } + + const context = await this.inputUtils.prompt({ + default: SetupContextEnum.Automatic, + message: 'Choose context for the commit', + type: 'list', + choices: [ + { + name: `${SemanticCommitContextEnum.Feat}: (new feature for the user, not a new feature for build script)`, + value: SemanticCommitContextEnum.Feat, + short: SemanticCommitContextEnum.Feat, + }, + { + name: `${SemanticCommitContextEnum.Fix}: (bug fix for the user, not a fix to a build script)`, + value: SemanticCommitContextEnum.Fix, + short: SemanticCommitContextEnum.Fix, + }, + { + name: `${SemanticCommitContextEnum.Docs}: (changes to the documentation)`, + value: SemanticCommitContextEnum.Docs, + short: SemanticCommitContextEnum.Docs, + }, + { + name: `${SemanticCommitContextEnum.Style}: (formatting, missing semi colons, etc; no production code change)`, + value: SemanticCommitContextEnum.Style, + short: SemanticCommitContextEnum.Style, + }, + { + name: `${SemanticCommitContextEnum.Refactor}: (refactoring production code, eg. renaming a variable)`, + value: SemanticCommitContextEnum.Refactor, + short: SemanticCommitContextEnum.Refactor, + }, + { + name: `${SemanticCommitContextEnum.Test}: (adding missing tests, refactoring tests; no production code change)`, + value: SemanticCommitContextEnum.Test, + short: SemanticCommitContextEnum.Test, + }, + { + name: `${SemanticCommitContextEnum.Chore}: (updating grunt tasks etc; no production code change)`, + value: SemanticCommitContextEnum.Chore, + short: SemanticCommitContextEnum.Chore, + }, + ], + }); + + return context; + } + + private generatePrompt({ + context, + env, + }: { + context?: string | null; + env: Env; + }) { + let prompt = context + ? env.CONFIG_PROMPT_MANUAL_CONTEXT + : env.CONFIG_PROMPT_AUTOMATIC_CONTEXT; + + if (context) { + prompt = prompt.replaceAll(CustomConfigKeysEnum.CustomContext, context); + } + + prompt = prompt + .replaceAll( + CustomConfigKeysEnum.CommitLanguage, + env.CONFIG_COMMIT_LANGUAGE, + ) + .replaceAll( + CustomConfigKeysEnum.MaxCommitCharacters, + String(env.CONFIG_MAX_COMMIT_CHARACTERS), + ); + + return prompt; + } + + private async getGitDiff(): Promise { + try { + await this.processUtils.exec('git --version', { + showStdout: false, + }); + } catch { + this.appUtils.logger.error('Git is not installed.'); + + process.exit(0); + } + + try { + await this.processUtils.exec('git status', { + showStdout: false, + }); + } catch { + this.appUtils.logger.error('Current directory is not a git repository.'); + + process.exit(0); + } + + const diff = await this.processUtils.exec('git diff --cached', { + showStdout: false, + }); + + if (!diff) { + this.appUtils.logger.error('No changes to commit.'); + + process.exit(0); + } + + return diff; + } + + private getProvider({ env }: { env: Env }): Provider { + const provider = this.providers[env.PROVIDER]; + + if (!provider) { + this.appUtils.logger.error('AI provider not set.'); + + this.appUtils.logger.message( + "Run 'commitfy setup' to set up the provider.", + ); + + process.exit(0); + } + + return provider; + } } diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts index d64df41..f5529bf 100644 --- a/src/commands/setup.spec.ts +++ b/src/commands/setup.spec.ts @@ -1,22 +1,23 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - import { Setup } from './setup'; -import { InputUtils, ProviderEnum } from '@/interfaces'; +import { ProviderEnum, SetupContextEnum } from '@/interfaces'; import { makeProvidersFake } from '@/tests/fakes/providers'; - -const makeInputUtilsFake = (): InputUtils => ({ - prompt: vi.fn(), -}); +import { + DEFAULT_ENV, + makeEnvUtilsFake, + makeInputUtilsFake, +} from '@/tests/fakes/utils'; const makeSut = () => { - const providers = makeProvidersFake(); const inputUtils = makeInputUtilsFake(); - const sut = new Setup(providers, inputUtils); + const providers = makeProvidersFake(); + const envUtils = makeEnvUtilsFake(); + const sut = new Setup(providers, inputUtils, envUtils); return { - sut, - providers, inputUtils, + providers, + envUtils, + sut, }; }; @@ -34,28 +35,86 @@ describe('Setup', () => { }); it('should call setup on the chosen provider', async () => { - const { sut, providers, inputUtils } = makeSut(); + const { sut, providers, inputUtils, envUtils } = makeSut(); - vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce(ProviderEnum.OpenAI); + vi.spyOn(inputUtils, 'prompt') + .mockResolvedValueOnce(SetupContextEnum.Automatic) + .mockResolvedValueOnce(ProviderEnum.OpenAI); await expect(sut.execute()).rejects.toThrow('process.exit called'); expect(providers[ProviderEnum.OpenAI].setup).toHaveBeenCalled(); expect(inputUtils.prompt).toHaveBeenCalledWith({ - choices: Object.values(ProviderEnum), + choices: Object.keys(providers), message: 'Choose your AI provider', type: 'list', }); + + expect(envUtils.update).toHaveBeenCalledWith({ + SETUP_CONTEXT: SetupContextEnum.Automatic, + }); }); it('should exit the process after setup is complete', async () => { const { sut, inputUtils } = makeSut(); - vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce(ProviderEnum.OpenAI); + vi.spyOn(inputUtils, 'prompt') + .mockResolvedValueOnce(SetupContextEnum.Automatic) + .mockResolvedValueOnce(ProviderEnum.OpenAI); await expect(sut.execute()).rejects.toThrow('process.exit called'); expect(exitSpy).toHaveBeenCalledWith(0); }); + + it('should update the environment with the chosen context', async () => { + const { sut, inputUtils, envUtils } = makeSut(); + + vi.spyOn(inputUtils, 'prompt') + .mockResolvedValueOnce(SetupContextEnum.Manual) + .mockResolvedValueOnce(ProviderEnum.OpenAI); + + await expect(sut.execute()).rejects.toThrow('process.exit called'); + + expect(envUtils.update).toHaveBeenCalledWith({ + SETUP_CONTEXT: SetupContextEnum.Manual, + }); + }); + + it('should prompt for both context and provider', async () => { + const { sut, inputUtils, providers } = makeSut(); + + const promptSpy = vi + .spyOn(inputUtils, 'prompt') + .mockResolvedValueOnce(SetupContextEnum.Automatic) + .mockResolvedValueOnce(ProviderEnum.OpenAI); + + await expect(sut.execute()).rejects.toThrow('process.exit called'); + + expect(promptSpy).toHaveBeenNthCalledWith(1, { + default: DEFAULT_ENV.SETUP_CONTEXT, + message: + 'Choose how you want to set the context (feat, refactor, fix, etc.)', + type: 'list', + choices: [ + { + name: 'Automatic in-context generation based on changes', + value: SetupContextEnum.Automatic, + short: SetupContextEnum.Automatic, + }, + { + name: 'Choose the context manually', + value: SetupContextEnum.Manual, + short: SetupContextEnum.Manual, + }, + ], + }); + + expect(promptSpy).toHaveBeenNthCalledWith(2, { + choices: Object.keys(providers), + message: 'Choose your AI provider', + type: 'list', + }); + }); }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 96bafb4..5f04d85 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,24 +1,62 @@ -import type { InputUtils, Provider, ProviderEnum } from '@/interfaces'; +import { + type Env, + type EnvUtils, + type InputUtils, + type Provider, + ProviderEnum, + SetupContextEnum, +} from '@/interfaces'; export class Setup { constructor( private readonly providers: { [ProviderEnum.OpenAI]: Provider }, private readonly inputUtils: InputUtils, + private readonly envUtils: EnvUtils, ) {} public async execute(): Promise { - const choices = Object.keys(this.providers); + const env = this.envUtils.variables(); - const providerKey = await this.inputUtils.prompt({ - message: 'Choose your AI provider', - type: 'list', - choices, - }); + await this.setupContext({ env }); + const providerKey = await this.getProviderKey(); const provider = this.providers[providerKey]; await provider.setup(); process.exit(0); } + + private async setupContext({ env }: { env: Env }): Promise { + const context = await this.inputUtils.prompt({ + default: env.SETUP_CONTEXT, + message: + 'Choose how you want to set the context (feat, refactor, fix, etc.)', + type: 'list', + choices: [ + { + name: 'Automatic in-context generation based on changes', + value: SetupContextEnum.Automatic, + short: SetupContextEnum.Automatic, + }, + { + name: 'Choose the context manually', + value: SetupContextEnum.Manual, + short: SetupContextEnum.Manual, + }, + ], + }); + + this.envUtils.update({ SETUP_CONTEXT: context as SetupContextEnum }); + } + + private async getProviderKey(): Promise { + const providerKey = await this.inputUtils.prompt({ + choices: Object.keys(this.providers), + message: 'Choose your AI provider', + type: 'list', + }); + + return providerKey; + } } diff --git a/src/constants.ts b/src/constants.ts index 7573411..62cc9c4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,3 +6,36 @@ export const PACKAGE_JSON_PATH = path.resolve(__dirname, '..', 'package.json'); export const USER_HOME_DIRECTORY = os.homedir(); export const TEMP_DIRECTORY = path.resolve(__dirname, '..', 'tmp'); + +export const DEFAULT_N_COMMITS = 2; + +export enum SemanticCommitContextEnum { + Refactor = 'refactor', + Style = 'style', + Chore = 'chore', + Docs = 'docs', + Test = 'test', + Feat = 'feat', + Fix = 'fix', +} + +export enum CustomConfigKeysEnum { + MaxCommitCharacters = '{{CONFIG_MAX_COMMIT_CHARACTERS}}', + CommitLanguage = '{{CONFIG_COMMIT_LANGUAGE}}', + CustomContext = '{{CUSTOM_CONTEXT}}', +} + +export enum CommitPromptEnum { + AutomaticContext = 'automatic-context', + ManualContext = 'manual-context', +} + +const semanticCommitContextsText = Object.values( + SemanticCommitContextEnum, +).join(', '); + +export const DEFAULT_COMMIT_PROMPTS = { + [CommitPromptEnum.AutomaticContext]: `Generate a short commit message in ${CustomConfigKeysEnum.CommitLanguage} with a maximum of ${CustomConfigKeysEnum.MaxCommitCharacters} characters, following the Conventional Commits format. The message should begin with one of the following types: ${semanticCommitContextsText}, followed by a concise description of the change in lowercase. Do not include a scope, do not end the message with a period, and always start the description with a lowercase letter. Ensure the total length does not exceed ${CustomConfigKeysEnum.MaxCommitCharacters} characters.`, + + [CommitPromptEnum.ManualContext]: `Generate a short commit message in ${CustomConfigKeysEnum.CommitLanguage} with a maximum of ${CustomConfigKeysEnum.MaxCommitCharacters} characters, following the Conventional Commits format. The message should begin with one of the following types: ${semanticCommitContextsText}, followed by a concise description of the change in lowercase. Context: ${CustomConfigKeysEnum.CustomContext}. Do not include a scope, do not end the message with a period, and always start the description with a lowercase letter. Ensure the total length does not exceed ${CustomConfigKeysEnum.MaxCommitCharacters} characters.`, +}; diff --git a/src/container.ts b/src/container.ts index ab7c99f..f938c49 100644 --- a/src/container.ts +++ b/src/container.ts @@ -12,7 +12,7 @@ const providers: Providers = { openai: new OpenAIProvider(envUtils, inputUtils, appUtils), }; -const setup = new Setup(providers, inputUtils); +const setup = new Setup(providers, inputUtils, envUtils); const help = new Help(); diff --git a/src/interfaces/commands/setup.ts b/src/interfaces/commands/setup.ts new file mode 100644 index 0000000..7110270 --- /dev/null +++ b/src/interfaces/commands/setup.ts @@ -0,0 +1,4 @@ +export enum SetupContextEnum { + Automatic = 'automatic', + Manual = 'manual', +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index b0e4bfd..d040e70 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,5 +1,11 @@ +// Commands +export * from './commands/setup'; + +// Utils export * from './utils/input.utils'; export * from './utils/process.utils'; export * from './utils/env.utils'; export * from './utils/app.utils'; + +// Providers export * from './provider'; diff --git a/src/interfaces/provider.ts b/src/interfaces/provider.ts index 6954e6e..8b79a0b 100644 --- a/src/interfaces/provider.ts +++ b/src/interfaces/provider.ts @@ -2,8 +2,14 @@ export enum ProviderEnum { OpenAI = 'openai', } +export type GenerateCommitMessagesDto = { + prompt: string; + diff: string; + n?: number; +}; + export interface Provider { - generateCommitMessages({ diff }: { diff: string }): Promise; + generateCommitMessages(dto: GenerateCommitMessagesDto): Promise; setup(): Promise; } diff --git a/src/interfaces/utils/env.utils.ts b/src/interfaces/utils/env.utils.ts index b29c748..f3e4597 100644 --- a/src/interfaces/utils/env.utils.ts +++ b/src/interfaces/utils/env.utils.ts @@ -1,9 +1,16 @@ +import type { SetupContextEnum } from '../commands/setup'; import type { ProviderEnum } from '../provider'; export type Env = { PROVIDER?: ProviderEnum; OPENAI_API_KEY?: string; OPENAI_N_COMMITS?: number | string; + SETUP_CONTEXT: SetupContextEnum; + + CONFIG_COMMIT_LANGUAGE: string; + CONFIG_MAX_COMMIT_CHARACTERS: string | number; + CONFIG_PROMPT_AUTOMATIC_CONTEXT: string; + CONFIG_PROMPT_MANUAL_CONTEXT: string; }; export interface EnvUtils { diff --git a/src/interfaces/utils/input.utils.ts b/src/interfaces/utils/input.utils.ts index 8425e05..ca4987b 100644 --- a/src/interfaces/utils/input.utils.ts +++ b/src/interfaces/utils/input.utils.ts @@ -20,7 +20,7 @@ export interface Input extends DefaultInput { export interface InputList extends DefaultInput { choices: ( | string - | { name: string; disabled: string } + | { name: string; value: string; short?: string } | InputUtilsCustomChoiceEnum )[]; type: 'list'; diff --git a/src/providers/openai.provider.spec.ts b/src/providers/openai.provider.spec.ts index f98ac1a..02026d4 100644 --- a/src/providers/openai.provider.spec.ts +++ b/src/providers/openai.provider.spec.ts @@ -4,6 +4,7 @@ import type { ChatCompletion } from 'openai/resources'; import { OpenAIProvider } from './openai.provider'; import { ProviderEnum } from '@/interfaces'; import { + DEFAULT_ENV, makeAppUtilsFake, makeEnvUtilsFake, makeInputUtilsFake, @@ -53,11 +54,11 @@ describe('OpenAIProvider', () => { it('should throw an error if OPENAI_API_KEY is not set', async () => { const { sut, envUtils, appUtils } = makeSut(); - vi.spyOn(envUtils, 'variables').mockReturnValueOnce({}); + vi.spyOn(envUtils, 'variables').mockReturnValueOnce(DEFAULT_ENV); const loggerErrorSpy = vi.spyOn(appUtils.logger, 'error'); await expect( - sut.generateCommitMessages({ diff: 'some changes' }), + sut.generateCommitMessages({ diff: 'some changes', prompt: 'prompt' }), ).rejects.toThrow(); expect(loggerErrorSpy).toHaveBeenCalledWith('OPENAI_API_KEY is required'); @@ -67,6 +68,7 @@ describe('OpenAIProvider', () => { const { sut, envUtils, openAIClient } = makeSut(); vi.spyOn(envUtils, 'variables').mockReturnValue({ + ...DEFAULT_ENV, OPENAI_API_KEY: 'valid-api-key', OPENAI_N_COMMITS: 2, }); @@ -84,6 +86,7 @@ describe('OpenAIProvider', () => { const commitMessages = await sut.generateCommitMessages({ diff: 'some changes', + prompt: 'prompt', }); expect(commitMessages).toEqual([ @@ -96,6 +99,7 @@ describe('OpenAIProvider', () => { const { sut, envUtils, appUtils, openAIClient } = makeSut(); vi.spyOn(envUtils, 'variables').mockReturnValue({ + ...DEFAULT_ENV, OPENAI_API_KEY: 'valid-api-key', OPENAI_N_COMMITS: 2, }); @@ -107,7 +111,7 @@ describe('OpenAIProvider', () => { ); await expect( - sut.generateCommitMessages({ diff: 'some changes' }), + sut.generateCommitMessages({ diff: 'some changes', prompt: 'prompt' }), ).rejects.toThrow('process.exit called'); expect(loggerErrorSpy).toHaveBeenCalledWith( @@ -122,6 +126,7 @@ describe('OpenAIProvider', () => { const { sut, envUtils, inputUtils, appUtils } = makeSut(); vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + ...DEFAULT_ENV, OPENAI_API_KEY: '', }); @@ -138,6 +143,7 @@ describe('OpenAIProvider', () => { await sut.setup(); expect(envUtils.update).toHaveBeenCalledWith({ + ...DEFAULT_ENV, OPENAI_API_KEY: 'new-api-key', OPENAI_N_COMMITS: 3, PROVIDER: ProviderEnum.OpenAI, @@ -155,6 +161,7 @@ describe('OpenAIProvider', () => { const { sut, envUtils, inputUtils, appUtils } = makeSut(); vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + ...DEFAULT_ENV, OPENAI_API_KEY: '', }); diff --git a/src/providers/openai.provider.ts b/src/providers/openai.provider.ts index a4a1e79..1256e18 100644 --- a/src/providers/openai.provider.ts +++ b/src/providers/openai.provider.ts @@ -3,11 +3,13 @@ import { OpenAI } from 'openai'; import { type AppUtils, type EnvUtils, + type GenerateCommitMessagesDto, InputTypeEnum, type InputUtils, type Provider, ProviderEnum, } from '../interfaces'; +import { DEFAULT_N_COMMITS } from '@/constants'; export class OpenAIProvider implements Provider { private openai: OpenAI; @@ -34,6 +36,12 @@ export class OpenAIProvider implements Provider { return this.openai; } + private get nCommits() { + return ( + Number(this.envUtils.variables().OPENAI_N_COMMITS) || DEFAULT_N_COMMITS + ); + } + public async setup(): Promise { const env = this.envUtils.variables(); @@ -79,16 +87,13 @@ export class OpenAIProvider implements Provider { } public async generateCommitMessages({ + n = this.nCommits, + prompt, diff, - }: { - diff: string; - }): Promise { + }: GenerateCommitMessagesDto): Promise { try { this.checkRequiredEnvVars(); - const prompt = `Generate a concise and clear commit message using the commitizen format (e.g., feat, chore, refactor, etc.) for the following code changes. The message should be at most 72 characters long:`; - const n = Number(this.envUtils.variables().OPENAI_N_COMMITS) || 2; - const chatCompletion = await this.client.chat.completions.create({ messages: [ { role: 'user', content: prompt }, diff --git a/src/utils/env.utils.spec.ts b/src/utils/env.utils.spec.ts index 7fe056b..da816ee 100644 --- a/src/utils/env.utils.spec.ts +++ b/src/utils/env.utils.spec.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'path'; -import { makeAppUtilsFake } from '../../tests/fakes/utils'; +import { DEFAULT_ENV, makeAppUtilsFake } from '../../tests/fakes/utils'; import { TEMP_DIRECTORY } from '../constants'; import { EnvUtils as EnvUtilsInterface, ProviderEnum } from '../interfaces'; import { EnvUtils } from './env.utils'; @@ -57,8 +57,9 @@ describe('EnvUtils', () => { const variables = sut.variables(); - expect(Object.keys(variables)).toEqual(Object.keys(updates)); - expect(Object.values(variables)).toEqual(Object.values(updates)); + expect(Object.keys(variables)).toEqual( + Object.keys({ ...DEFAULT_ENV, ...updates }), + ); }); it('should merge the updates with existing environment variables', () => { diff --git a/src/utils/env.utils.ts b/src/utils/env.utils.ts index 0bf07cc..dd7906f 100644 --- a/src/utils/env.utils.ts +++ b/src/utils/env.utils.ts @@ -1,17 +1,29 @@ import * as fs from 'node:fs'; -import type { - AppUtils, - Env, - EnvUtils as EnvUtilsInterface, +import { CommitPromptEnum, DEFAULT_COMMIT_PROMPTS } from '@/constants'; +import { + type AppUtils, + type Env, + type EnvUtils as EnvUtilsInterface, + SetupContextEnum, } from '@/interfaces'; export class EnvUtils implements EnvUtilsInterface { + private readonly defaultEnv: Env = { + SETUP_CONTEXT: SetupContextEnum.Automatic, + CONFIG_COMMIT_LANGUAGE: 'English (EN)', + CONFIG_MAX_COMMIT_CHARACTERS: '72', + CONFIG_PROMPT_AUTOMATIC_CONTEXT: + DEFAULT_COMMIT_PROMPTS[CommitPromptEnum.AutomaticContext], + CONFIG_PROMPT_MANUAL_CONTEXT: + DEFAULT_COMMIT_PROMPTS[CommitPromptEnum.ManualContext], + }; + constructor(private readonly appUtils: AppUtils) {} public variables(): Env { if (!fs.existsSync(this.appUtils.envFilePath)) { - return {}; + return this.defaultEnv; } return fs @@ -23,11 +35,15 @@ export class EnvUtils implements EnvUtilsInterface { variables[key] = value; return variables; - }, {} as Record); + }, this.defaultEnv); } public update(updates: Partial) { - const fileContent = Object.entries({ ...this.variables(), ...updates }) + const fileContent = Object.entries({ + ...this.defaultEnv, + ...this.variables(), + ...updates, + }) .map(([key, value]) => `${key}=${value}`) .join('\n'); diff --git a/tests/fakes/utils/env.utils.fake.ts b/tests/fakes/utils/env.utils.fake.ts index 8bae906..1a13cb8 100644 --- a/tests/fakes/utils/env.utils.fake.ts +++ b/tests/fakes/utils/env.utils.fake.ts @@ -1,11 +1,22 @@ -import { EnvUtils as EnvUtilsInterface } from '@/interfaces'; +import { CommitPromptEnum, DEFAULT_COMMIT_PROMPTS } from '@/constants'; +import { EnvUtils as EnvUtilsInterface, SetupContextEnum } from '@/interfaces'; + +export const DEFAULT_ENV = { + SETUP_CONTEXT: SetupContextEnum.Automatic, + CONFIG_COMMIT_LANGUAGE: 'English (EN)', + CONFIG_MAX_COMMIT_CHARACTERS: '72', + CONFIG_PROMPT_AUTOMATIC_CONTEXT: + DEFAULT_COMMIT_PROMPTS[CommitPromptEnum.AutomaticContext], + CONFIG_PROMPT_MANUAL_CONTEXT: + DEFAULT_COMMIT_PROMPTS[CommitPromptEnum.ManualContext], +}; export const makeEnvUtilsFake = () => { class EnvUtilsFake { public update = vi.fn(); public variables() { - return {}; + return DEFAULT_ENV; } }