Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <ribeirogabx@gmail.com>",
Expand Down
73 changes: 39 additions & 34 deletions src/commands/generate-commit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.');

Expand All @@ -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);
});
Expand All @@ -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);
});
});
159 changes: 138 additions & 21 deletions src/commands/generate-commit.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,29 +23,15 @@ export class GenerateCommit {
) {}

public async execute(): Promise<void> {
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 = [
Expand All @@ -68,4 +56,133 @@ export class GenerateCommit {

process.exit(0);
}

private async getContext({ env }: { env: Env }): Promise<string> {
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<string> {
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;
}
}
Loading