* test:  add the first E2E test and configuration to CI (#316)

* add tests

* Add push config (#220)

* feat: add instructions and support for configuring gpt-4-turbo (#320)

* 3.0.12

* build

* feat: add 'gpt-4-turbo' to supported models in README and config validation

---------

Co-authored-by: di-sukharev <dim.sukharev@gmail.com>

*  fix the broken E2E tests due to the addition of OCO_GITPUSH (#321)

* test(oneFile.test.ts): update test expectations to match new push prompt text

* build

* Feat: Add Claude 3 support (#318)

* 3.0.12

* build

* feat: anthropic claude 3 support

* fix: add system prompt

* fix: type check

* fix: package version

* fix: update anthropic for dependency bug fix

* feat: update build files

* feat: update version number

---------

Co-authored-by: di-sukharev <dim.sukharev@gmail.com>

* 🐛bug fix: enable to use the new format of OpenAI's project API Key (#328)

* fix(config.ts): remove validation for OCO_OPENAI_API_KEY length to accommodate variable key lengths

* build

* ♻️ refactor(config.ts): Addition of UnitTest environment and unittest for commands/config.ts#getConfig (#330)

* feat(jest.config.ts): update jest preset for TS ESM support and ignore patterns
feat(package.json): add test:unit script with NODE_OPTIONS for ESM
refactor(src/commands/config.ts): improve dotenv usage with dynamic paths
feat(src/commands/config.ts): allow custom config and env paths in getConfig
refactor(src/commands/config.ts): streamline environment variable access

feat(test/unit): add unit tests for config handling and utility functions

- Implement unit tests for `getConfig` function to ensure correct behavior
  in various scenarios including default values, global config, and local
  env file precedence.
- Add utility function `prepareFile` for creating temporary files during
  tests, facilitating testing of file-based configurations.

* feat(e2e.yml): add unit-test job to GitHub Actions for running unit tests on pull requests

* ci(test.yml): add GitHub Actions workflow for unit and e2e tests on pull requests

* refactor(config.ts): streamline environment variable access using process.env directly
test(config.test.ts): add setup and teardown for environment variables in tests to ensure test isolation

* feat(package.json): add `test:all` script to run all tests in Docker
refactor(package.json): consolidate Docker build steps into `test:docker-build` script for DRY principle
fix(package.json): ensure `test:unit:docker` and `test:e2e:docker` scripts use the same Docker image and remove container after run
chore(test/Dockerfile): remove default CMD to allow dynamic test script execution in Docker

* refactor(config.test.ts): anonymize API keys in tests for better security practices

* feat(config.test.ts): add tests for OCO_ANTHROPIC_API_KEY configuration

* refactor(config.ts): streamline path imports and remove unused DotenvParseOutput

- Simplify path module imports by removing default import and using named imports for `pathJoin` and `pathResolve`.
- Remove unused `DotenvParseOutput` import to clean up the code.

* refactor(config.test.ts): simplify API key mock values for clarity in tests

* test(config.test.ts): remove tests for default config values and redundant cases

- Removed tests that checked for default config values when no config or env files are present, as these scenarios are now handled differently.
- Eliminated tests for empty global config and local env files to streamline testing focus on actual config loading logic.
- Removed test for prioritizing local env over global config due to changes in config loading strategy, simplifying the configuration management.

* new version

---------

Co-authored-by: Takanori Matsumoto <matscube@gmail.com>
Co-authored-by: Moret84 <aurelienrivet@hotmail.fr>
Co-authored-by: yowatari <4982161+YOwatari@users.noreply.github.com>
Co-authored-by: metavind <94786679+metavind@users.noreply.github.com>
This commit is contained in:
GPT10
2024-05-05 19:07:55 +03:00
committed by GitHub
parent 0ac7211ff7
commit ec307d561f
23 changed files with 49911 additions and 5174 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
.env

46
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Testing
on: [pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install
- name: Run Unit Tests
run: npm run test:unit
e2e-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install git
run: |
sudo apt-get update
sudo apt-get install -y git
git --version
- name: Setup git
run: |
git config --global user.email "test@example.com"
git config --global user.name "Test User"
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Run E2E Tests
run: npm run test:e2e

View File

@@ -97,7 +97,7 @@ OCO_TOKENS_MAX_OUTPUT=<max response tokens (default: 500)>
OCO_OPENAI_BASE_PATH=<may be used to set proxy path to OpenAI api>
OCO_DESCRIPTION=<postface a message with ~3 sentences description of the changes>
OCO_EMOJI=<boolean, add GitMoji>
OCO_MODEL=<either 'gpt-4', 'gpt-3.5-turbo' (default), 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-turbo-preview' or 'gpt-4-0125-preview'>
OCO_MODEL=<either 'gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo' (default), 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-turbo-preview' or 'gpt-4-0125-preview'>
OCO_LANGUAGE=<locale, scroll to the bottom to see options>
OCO_MESSAGE_TEMPLATE_PLACEHOLDER=<message template placeholder, default: '$msg'>
OCO_PROMPT_MODULE=<either conventional-commit or @commitlint, default: conventional-commit>
@@ -169,6 +169,14 @@ oco config set OCO_LANGUAGE=française
The default language setting is **English**
All available languages are currently listed in the [i18n](https://github.com/di-sukharev/opencommit/tree/master/src/i18n) folder
### Push to git
Pushing to git is on by default but if you would like to turn it off just use:
```sh
oc config set OCO_GITPUSH=false
```
### Switch to `@commitlint`
OpenCommit allows you to choose the prompt module used to generate commit messages. By default, OpenCommit uses its conventional-commit message generator. However, you can switch to using the `@commitlint` prompt module if you prefer. This option lets you generate commit messages in respect with the local config.

28
jest.config.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
import type { Config } from 'jest';
const config: Config = {
testTimeout: 100_000,
coverageProvider: 'v8',
moduleDirectories: ['node_modules', 'src'],
preset: 'ts-jest/presets/js-with-ts-esm',
setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
testEnvironment: 'node',
testRegex: ['.*\\.test\\.ts$'],
transformIgnorePatterns: ['node_modules/(?!cli-testing-library)'],
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
diagnostics: false,
useESM: true
}
]
}
};
export default config;

17400
out/cli.cjs

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

6317
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "opencommit",
"version": "3.0.12",
"version": "3.0.14",
"description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
"keywords": [
"git",
@@ -47,19 +47,29 @@
"build:push": "npm run build && git add . && git commit -m 'build' && git push",
"deploy": "npm version patch && npm run build:push && git push --tags && npm publish --tag latest",
"lint": "eslint src --ext ts && tsc --noEmit",
"format": "prettier --write src"
"format": "prettier --write src",
"test:all": "npm run test:unit:docker && npm run test:e2e:docker",
"test:docker-build": "docker build -t oco-test -f test/Dockerfile .",
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest test/unit",
"test:unit:docker": "npm run test:docker-build && DOCKER_CONTENT_TRUST=0 docker run --rm oco-test npm run test:unit",
"test:e2e": "jest test/e2e",
"test:e2e:docker": "npm run test:docker-build && DOCKER_CONTENT_TRUST=0 docker run --rm oco-test npm run test:e2e"
},
"devDependencies": {
"@commitlint/types": "^17.4.4",
"@types/ini": "^1.3.31",
"@types/inquirer": "^9.0.3",
"@types/jest": "^29.5.12",
"@types/node": "^16.18.14",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"cli-testing-library": "^2.0.2",
"dotenv": "^16.0.3",
"esbuild": "^0.15.18",
"eslint": "^8.28.0",
"jest": "^29.7.0",
"prettier": "^2.8.4",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.1",
"typescript": "^4.9.3"
},
@@ -67,6 +77,7 @@
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1",
"@actions/github": "^5.1.1",
"@anthropic-ai/sdk": "^0.19.2",
"@clack/prompts": "^0.6.1",
"@dqbd/tiktoken": "^1.0.2",
"@octokit/webhooks-schemas": "^6.11.0",

View File

@@ -94,13 +94,17 @@ ${chalk.grey('——————————————————')}`
const remotes = await getGitRemotes();
// user isn't pushing, return early
if (config?.OCO_GITPUSH === false)
return
if (!remotes.length) {
const { stdout } = await execa('git', ['push']);
if (stdout) outro(stdout);
process.exit(0);
}
if (remotes.length === 1) {
if (remotes.length === 1 && config?.OCO_GITPUSH !== true) {
const isPushConfirmedByUser = await confirm({
message: 'Do you want to run `git push`?'
});

View File

@@ -4,17 +4,16 @@ import * as dotenv from 'dotenv';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { parse as iniParse, stringify as iniStringify } from 'ini';
import { homedir } from 'os';
import { join as pathJoin } from 'path';
import { join as pathJoin, resolve as pathResolve } from 'path';
import { intro, outro } from '@clack/prompts';
import { COMMANDS } from '../CommandsEnum';
import { getI18nLocal } from '../i18n';
dotenv.config();
export enum CONFIG_KEYS {
OCO_OPENAI_API_KEY = 'OCO_OPENAI_API_KEY',
OCO_ANTHROPIC_API_KEY = 'OCO_ANTHROPIC_API_KEY',
OCO_TOKENS_MAX_INPUT = 'OCO_TOKENS_MAX_INPUT',
OCO_TOKENS_MAX_OUTPUT = 'OCO_TOKENS_MAX_OUTPUT',
OCO_OPENAI_BASE_PATH = 'OCO_OPENAI_BASE_PATH',
@@ -25,6 +24,7 @@ export enum CONFIG_KEYS {
OCO_MESSAGE_TEMPLATE_PLACEHOLDER = 'OCO_MESSAGE_TEMPLATE_PLACEHOLDER',
OCO_PROMPT_MODULE = 'OCO_PROMPT_MODULE',
OCO_AI_PROVIDER = 'OCO_AI_PROVIDER',
OCO_GITPUSH = 'OCO_GITPUSH',
OCO_ONE_LINE_COMMIT = 'OCO_ONE_LINE_COMMIT'
}
@@ -33,6 +33,31 @@ export enum CONFIG_MODES {
set = 'set'
}
export const MODEL_LIST = {
openai: ['gpt-3.5-turbo',
'gpt-3.5-turbo-0125',
'gpt-4',
'gpt-4-turbo',
'gpt-4-1106-preview',
'gpt-4-turbo-preview',
'gpt-4-0125-preview'],
anthropic: ['claude-3-haiku-20240307',
'claude-3-sonnet-20240229',
'claude-3-opus-20240229']
}
const getDefaultModel = (provider: string | undefined): string => {
switch (provider) {
case 'ollama':
return '';
case 'anthropic':
return MODEL_LIST.anthropic[0];
default:
return MODEL_LIST.openai[0];
}
};
export enum DEFAULT_TOKEN_LIMITS {
DEFAULT_MAX_TOKENS_INPUT = 4096,
DEFAULT_MAX_TOKENS_OUTPUT = 500
@@ -56,19 +81,24 @@ export const configValidators = {
[CONFIG_KEYS.OCO_OPENAI_API_KEY](value: any, config: any = {}) {
//need api key unless running locally with ollama
validateConfig(
'API_KEY',
value || config.OCO_AI_PROVIDER == 'ollama',
'You need to provide an API key'
'OpenAI API_KEY',
value || config.OCO_ANTHROPIC_API_KEY || config.OCO_AI_PROVIDER == 'ollama' || config.OCO_AI_PROVIDER == 'test',
'You need to provide an OpenAI/Anthropic API key'
);
validateConfig(
CONFIG_KEYS.OCO_OPENAI_API_KEY,
value.startsWith('sk-'),
'Must start with "sk-"'
);
return value;
},
[CONFIG_KEYS.OCO_ANTHROPIC_API_KEY](value: any, config: any = {}) {
validateConfig(
CONFIG_KEYS.OCO_OPENAI_API_KEY,
config[CONFIG_KEYS.OCO_OPENAI_BASE_PATH] || value.length === 51,
'Must be 51 characters long'
'ANTHROPIC_API_KEY',
value || config.OCO_OPENAI_API_KEY || config.OCO_AI_PROVIDER == 'ollama' || config.OCO_AI_PROVIDER == 'test',
'You need to provide an OpenAI/Anthropic API key'
);
return value;
@@ -153,18 +183,12 @@ export const configValidators = {
[CONFIG_KEYS.OCO_MODEL](value: any) {
validateConfig(
CONFIG_KEYS.OCO_MODEL,
[
'gpt-3.5-turbo',
'gpt-3.5-turbo-0125',
'gpt-4',
'gpt-4-1106-preview',
'gpt-4-turbo-preview',
'gpt-4-0125-preview'
].includes(value),
`${value} is not supported yet, use 'gpt-4', 'gpt-3.5-turbo' (default), 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-turbo-preview' or 'gpt-4-0125-preview'`
[...MODEL_LIST.openai, ...MODEL_LIST.anthropic].includes(value),
`${value} is not supported yet, use 'gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo' (default), 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-turbo-preview', 'gpt-4-0125-preview', 'claude-3-opus-20240229', 'claude-3-sonnet-20240229' or 'claude-3-haiku-20240307'`
);
return value;
},
[CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER](value: any) {
validateConfig(
CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
@@ -180,7 +204,15 @@ export const configValidators = {
['conventional-commit', '@commitlint'].includes(value),
`${value} is not supported yet, use '@commitlint' or 'conventional-commit' (default)`
);
return value;
},
[CONFIG_KEYS.OCO_GITPUSH](value: any) {
validateConfig(
CONFIG_KEYS.OCO_GITPUSH,
typeof value === 'boolean',
'Must be true or false'
);
return value;
},
@@ -190,9 +222,11 @@ export const configValidators = {
[
'',
'openai',
'ollama'
'anthropic',
'ollama',
'test'
].includes(value),
`${value} is not supported yet, use 'ollama' or 'openai' (default)`
`${value} is not supported yet, use 'ollama' 'anthropic' or 'openai' (default)`
);
return value;
},
@@ -212,11 +246,20 @@ export type ConfigType = {
[key in CONFIG_KEYS]?: any;
};
const configPath = pathJoin(homedir(), '.opencommit');
const defaultConfigPath = pathJoin(homedir(), '.opencommit');
const defaultEnvPath = pathResolve(process.cwd(), '.env');
export const getConfig = (): ConfigType | null => {
export const getConfig = ({
configPath = defaultConfigPath,
envPath = defaultEnvPath
}: {
configPath?: string
envPath?: string
} = {}): ConfigType | null => {
dotenv.config({ path: envPath });
const configFromEnv = {
OCO_OPENAI_API_KEY: process.env.OCO_OPENAI_API_KEY,
OCO_ANTHROPIC_API_KEY: process.env.OCO_ANTHROPIC_API_KEY,
OCO_TOKENS_MAX_INPUT: process.env.OCO_TOKENS_MAX_INPUT
? Number(process.env.OCO_TOKENS_MAX_INPUT)
: undefined,
@@ -226,12 +269,13 @@ export const getConfig = (): ConfigType | null => {
OCO_OPENAI_BASE_PATH: process.env.OCO_OPENAI_BASE_PATH,
OCO_DESCRIPTION: process.env.OCO_DESCRIPTION === 'true' ? true : false,
OCO_EMOJI: process.env.OCO_EMOJI === 'true' ? true : false,
OCO_MODEL: process.env.OCO_MODEL || 'gpt-3.5-turbo',
OCO_MODEL: process.env.OCO_MODEL || getDefaultModel(process.env.OCO_AI_PROVIDER),
OCO_LANGUAGE: process.env.OCO_LANGUAGE || 'en',
OCO_MESSAGE_TEMPLATE_PLACEHOLDER:
process.env.OCO_MESSAGE_TEMPLATE_PLACEHOLDER || '$msg',
OCO_PROMPT_MODULE: process.env.OCO_PROMPT_MODULE || 'conventional-commit',
OCO_AI_PROVIDER: process.env.OCO_AI_PROVIDER || 'openai',
OCO_GITPUSH: process.env.OCO_GITPUSH === 'false' ? false : true,
OCO_ONE_LINE_COMMIT: process.env.OCO_ONE_LINE_COMMIT === 'true' ? true : false
};
@@ -243,7 +287,6 @@ export const getConfig = (): ConfigType | null => {
for (const configKey of Object.keys(config)) {
if (
!config[configKey] ||
['null', 'undefined'].includes(config[configKey])
) {
config[configKey] = undefined;
@@ -269,7 +312,7 @@ export const getConfig = (): ConfigType | null => {
return config;
};
export const setConfig = (keyValues: [key: string, value: string][]) => {
export const setConfig = (keyValues: [key: string, value: string][], configPath: string = defaultConfigPath) => {
const config = getConfig() || {};
for (const [configKey, configValue] of keyValues) {

124
src/engine/anthropic.ts Normal file
View File

@@ -0,0 +1,124 @@
import axios from 'axios';
import chalk from 'chalk';
import Anthropic from '@anthropic-ai/sdk';
import {ChatCompletionRequestMessage} from 'openai'
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources';
import { intro, outro } from '@clack/prompts';
import {
CONFIG_MODES,
DEFAULT_TOKEN_LIMITS,
getConfig
} from '../commands/config';
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
import { tokenCount } from '../utils/tokenCount';
import { AiEngine } from './Engine';
import { MODEL_LIST } from '../commands/config';
const config = getConfig();
const MAX_TOKENS_OUTPUT =
config?.OCO_TOKENS_MAX_OUTPUT ||
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;
const MAX_TOKENS_INPUT =
config?.OCO_TOKENS_MAX_INPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT;
let provider = config?.OCO_AI_PROVIDER;
let apiKey = config?.OCO_ANTHROPIC_API_KEY;
const [command, mode] = process.argv.slice(2);
if (
provider === 'anthropic' &&
!apiKey &&
command !== 'config' &&
mode !== CONFIG_MODES.set
) {
intro('opencommit');
outro(
'OCO_ANTHROPIC_API_KEY is not set, please run `oco config set OCO_ANTHROPIC_API_KEY=<your token> . If you are using Claude, make sure you add payment details, so API works.`'
);
outro(
'For help look into README https://github.com/di-sukharev/opencommit#setup'
);
process.exit(1);
}
const MODEL = config?.OCO_MODEL;
if (provider === 'anthropic' &&
!MODEL_LIST.anthropic.includes(MODEL) &&
command !== 'config' &&
mode !== CONFIG_MODES.set) {
outro(
`${chalk.red('✖')} Unsupported model ${MODEL} for Anthropic. Supported models are: ${MODEL_LIST.anthropic.join(
', '
)}`
);
process.exit(1);
}
class AnthropicAi implements AiEngine {
private anthropicAiApiConfiguration = {
apiKey: apiKey
};
private anthropicAI!: Anthropic;
constructor() {
this.anthropicAI = new Anthropic(this.anthropicAiApiConfiguration);
}
public generateCommitMessage = async (
messages: Array<ChatCompletionRequestMessage>
): Promise<string | undefined> => {
const systemMessage = messages.find(msg => msg.role === 'system')?.content as string;
const restMessages = messages.filter((msg) => msg.role !== 'system') as MessageParam[];
const params: MessageCreateParamsNonStreaming = {
model: MODEL,
system: systemMessage,
messages: restMessages,
temperature: 0,
top_p: 0.1,
max_tokens: MAX_TOKENS_OUTPUT
};
try {
const REQUEST_TOKENS = messages
.map((msg) => tokenCount(msg.content as string) + 4)
.reduce((a, b) => a + b, 0);
if (REQUEST_TOKENS > MAX_TOKENS_INPUT - MAX_TOKENS_OUTPUT) {
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
}
const data = await this.anthropicAI.messages.create(params);
const message = data?.content[0].text;
return message;
} catch (error) {
outro(`${chalk.red('✖')} ${JSON.stringify(params)}`);
const err = error as Error;
outro(`${chalk.red('✖')} ${err?.message || err}`);
if (
axios.isAxiosError<{ error?: { message: string } }>(error) &&
error.response?.status === 401
) {
const anthropicAiError = error.response.data.error;
if (anthropicAiError?.message) outro(anthropicAiError.message);
outro(
'For help look into README https://github.com/di-sukharev/opencommit#setup'
);
}
throw err;
}
};
}
export const anthropicAi = new AnthropicAi();

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import chalk from 'chalk';
import { execa } from 'execa';
import {
ChatCompletionRequestMessage,
Configuration as OpenAiApiConfiguration,
@@ -17,20 +18,28 @@ import {
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
import { tokenCount } from '../utils/tokenCount';
import { AiEngine } from './Engine';
import { MODEL_LIST } from '../commands/config';
const config = getConfig();
const MAX_TOKENS_OUTPUT = config?.OCO_TOKENS_MAX_OUTPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;
const MAX_TOKENS_INPUT = config?.OCO_TOKENS_MAX_INPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT;
const MAX_TOKENS_OUTPUT =
config?.OCO_TOKENS_MAX_OUTPUT ||
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;
const MAX_TOKENS_INPUT =
config?.OCO_TOKENS_MAX_INPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT;
let basePath = config?.OCO_OPENAI_BASE_PATH;
let apiKey = config?.OCO_OPENAI_API_KEY
let apiKey = config?.OCO_OPENAI_API_KEY;
const [command, mode] = process.argv.slice(2);
const isLocalModel = config?.OCO_AI_PROVIDER == 'ollama'
const provider = config?.OCO_AI_PROVIDER;
if (!apiKey && command !== 'config' && mode !== CONFIG_MODES.set && !isLocalModel) {
if (
provider === 'openai' &&
!apiKey &&
command !== 'config' &&
mode !== CONFIG_MODES.set
) {
intro('opencommit');
outro(
@@ -44,6 +53,18 @@ if (!apiKey && command !== 'config' && mode !== CONFIG_MODES.set && !isLocalMode
}
const MODEL = config?.OCO_MODEL || 'gpt-3.5-turbo';
if (provider === 'openai' &&
!MODEL_LIST.openai.includes(MODEL) &&
command !== 'config' &&
mode !== CONFIG_MODES.set) {
outro(
`${chalk.red('✖')} Unsupported model ${MODEL} for OpenAI. Supported models are: ${MODEL_LIST.openai.join(
', '
)}`
);
process.exit(1);
}
class OpenAi implements AiEngine {
private openAiApiConfiguration = new OpenAiApiConfiguration({
@@ -105,6 +126,4 @@ class OpenAi implements AiEngine {
};
}
export const api = new OpenAi();

12
src/engine/testAi.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ChatCompletionRequestMessage } from 'openai';
import { AiEngine } from './Engine';
export class TestAi implements AiEngine {
async generateCommitMessage(
messages: Array<ChatCompletionRequestMessage>
): Promise<string | undefined> {
return 'test commit message';
}
}
export const testAi = new TestAi();

View File

@@ -2,11 +2,17 @@ import { AiEngine } from '../engine/Engine';
import { api } from '../engine/openAi';
import { getConfig } from '../commands/config';
import { ollamaAi } from '../engine/ollama';
import { anthropicAi } from '../engine/anthropic'
import { testAi } from '../engine/testAi';
export function getEngine(): AiEngine {
const config = getConfig();
if (config?.OCO_AI_PROVIDER == 'ollama') {
return ollamaAi;
} else if (config?.OCO_AI_PROVIDER == 'anthropic') {
return anthropicAi;
} else if (config?.OCO_AI_PROVIDER == 'test') {
return testAi;
}
//open ai gpt by default
return api;

19
test/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM ubuntu:latest
RUN apt-get update && apt-get install -y curl git
# Install Node.js v20
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
RUN apt-get install -y nodejs
# Setup git
RUN git config --global user.email "test@example.com"
RUN git config --global user.name "Test User"
COPY . /app
WORKDIR /app
RUN ls -la
RUN npm install
RUN npm run build

View File

@@ -0,0 +1,13 @@
import { resolve } from 'path'
import { render } from 'cli-testing-library'
import 'cli-testing-library/extend-expect';
import { prepareEnvironment } from './utils';
it('cli flow when there are no changes', async () => {
const { gitDir, cleanup } = await prepareEnvironment();
const { findByText } = await render(`OCO_AI_PROVIDER='test' node`, [resolve('./out/cli.cjs')], { cwd: gitDir });
expect(await findByText('No changes detected')).toBeInTheConsole();
await cleanup();
});

56
test/e2e/oneFile.test.ts Normal file
View File

@@ -0,0 +1,56 @@
import { resolve } from 'path'
import { render } from 'cli-testing-library'
import 'cli-testing-library/extend-expect';
import { prepareEnvironment } from './utils';
it('cli flow to generate commit message for 1 new file (staged)', async () => {
const { gitDir, cleanup } = await prepareEnvironment();
await render('echo' ,[`'console.log("Hello World");' > index.ts`], { cwd: gitDir });
await render('git' ,['add index.ts'], { cwd: gitDir });
const { queryByText, findByText, userEvent } = await render(`OCO_AI_PROVIDER='test' node`, [resolve('./out/cli.cjs')], { cwd: gitDir });
expect(await queryByText('No files are staged')).not.toBeInTheConsole();
expect(await queryByText('Do you want to stage all files and generate commit message?')).not.toBeInTheConsole();
expect(await findByText('Generating the commit message')).toBeInTheConsole();
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
userEvent.keyboard('[Enter]');
expect(await findByText('Choose a remote to push to')).toBeInTheConsole();
userEvent.keyboard('[Enter]');
expect(await findByText('Successfully pushed all commits to origin')).toBeInTheConsole();
await cleanup();
});
it('cli flow to generate commit message for 1 changed file (not staged)', async () => {
const { gitDir, cleanup } = await prepareEnvironment();
await render('echo' ,[`'console.log("Hello World");' > index.ts`], { cwd: gitDir });
await render('git' ,['add index.ts'], { cwd: gitDir });
await render('git' ,[`commit -m 'add new file'`], { cwd: gitDir });
await render('echo' ,[`'console.log("Good night World");' >> index.ts`], { cwd: gitDir });
const { findByText, userEvent } = await render(`OCO_AI_PROVIDER='test' node`, [resolve('./out/cli.cjs')], { cwd: gitDir });
expect(await findByText('No files are staged')).toBeInTheConsole();
expect(await findByText('Do you want to stage all files and generate commit message?')).toBeInTheConsole();
userEvent.keyboard('[Enter]');
expect(await findByText('Generating the commit message')).toBeInTheConsole();
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
userEvent.keyboard('[Enter]');
expect(await findByText('Successfully committed')).toBeInTheConsole();
expect(await findByText('Choose a remote to push to')).toBeInTheConsole();
userEvent.keyboard('[Enter]');
expect(await findByText('Successfully pushed all commits to origin')).toBeInTheConsole();
await cleanup();
});

31
test/e2e/utils.ts Normal file
View File

@@ -0,0 +1,31 @@
import path from 'path'
import { mkdtemp, rm } from 'fs'
import { promisify } from 'util';
import { tmpdir } from 'os';
import { exec } from 'child_process';
const fsMakeTempDir = promisify(mkdtemp);
const fsExec = promisify(exec);
const fsRemove = promisify(rm);
/**
* Prepare the environment for the test
* Create a temporary git repository in the temp directory
*/
export const prepareEnvironment = async (): Promise<{
gitDir: string;
cleanup: () => Promise<void>;
}> => {
const tempDir = await fsMakeTempDir(path.join(tmpdir(), 'opencommit-test-'));
// Create a remote git repository int the temp directory. This is necessary to execute the `git push` command
await fsExec('git init --bare remote.git', { cwd: tempDir });
await fsExec('git clone remote.git test', { cwd: tempDir });
const gitDir = path.resolve(tempDir, 'test');
const cleanup = async () => {
return fsRemove(tempDir, { recursive: true });
}
return {
gitDir,
cleanup,
}
}

7
test/jest-setup.ts Normal file
View File

@@ -0,0 +1,7 @@
import 'cli-testing-library/extend-expect'
import { configure } from 'cli-testing-library'
/**
* Adjusted the wait time for waitFor/findByText to 2000ms, because the default 1000ms makes the test results flaky
*/
configure({ asyncUtilTimeout: 2000 })

105
test/unit/config.test.ts Normal file
View File

@@ -0,0 +1,105 @@
import { getConfig } from '../../src/commands/config';
import { prepareFile } from './utils';
describe('getConfig', () => {
const originalEnv = { ...process.env };
function resetEnv(env: NodeJS.ProcessEnv) {
Object.keys(process.env).forEach((key) => {
if (!(key in env)) {
delete process.env[key];
} else {
process.env[key] = env[key];
}
});
}
beforeEach(() => {
resetEnv(originalEnv);
});
afterAll(() => {
resetEnv(originalEnv);
});
it('return config values from the global config file', async () => {
const configFile = await prepareFile(
'.opencommit',
`
OCO_OPENAI_API_KEY="sk-key"
OCO_ANTHROPIC_API_KEY="secret-key"
OCO_TOKENS_MAX_INPUT="8192"
OCO_TOKENS_MAX_OUTPUT="1000"
OCO_OPENAI_BASE_PATH="/openai/api"
OCO_DESCRIPTION="true"
OCO_EMOJI="true"
OCO_MODEL="gpt-4"
OCO_LANGUAGE="de"
OCO_MESSAGE_TEMPLATE_PLACEHOLDER="$m"
OCO_PROMPT_MODULE="@commitlint"
OCO_AI_PROVIDER="ollama"
OCO_GITPUSH="false"
OCO_ONE_LINE_COMMIT="true"
`
);
const config = getConfig({ configPath: configFile.filePath, envPath: '' });
expect(config).not.toEqual(null);
expect(config!['OCO_OPENAI_API_KEY']).toEqual('sk-key');
expect(config!['OCO_ANTHROPIC_API_KEY']).toEqual('secret-key');
expect(config!['OCO_TOKENS_MAX_INPUT']).toEqual(8192);
expect(config!['OCO_TOKENS_MAX_OUTPUT']).toEqual(1000);
expect(config!['OCO_OPENAI_BASE_PATH']).toEqual('/openai/api');
expect(config!['OCO_DESCRIPTION']).toEqual(true);
expect(config!['OCO_EMOJI']).toEqual(true);
expect(config!['OCO_MODEL']).toEqual('gpt-4');
expect(config!['OCO_LANGUAGE']).toEqual('de');
expect(config!['OCO_MESSAGE_TEMPLATE_PLACEHOLDER']).toEqual('$m');
expect(config!['OCO_PROMPT_MODULE']).toEqual('@commitlint');
expect(config!['OCO_AI_PROVIDER']).toEqual('ollama');
expect(config!['OCO_GITPUSH']).toEqual(false);
expect(config!['OCO_ONE_LINE_COMMIT']).toEqual(true);
await configFile.cleanup();
});
it('return config values from the local env file', async () => {
const envFile = await prepareFile(
'.env',
`
OCO_OPENAI_API_KEY="sk-key"
OCO_ANTHROPIC_API_KEY="secret-key"
OCO_TOKENS_MAX_INPUT="8192"
OCO_TOKENS_MAX_OUTPUT="1000"
OCO_OPENAI_BASE_PATH="/openai/api"
OCO_DESCRIPTION="true"
OCO_EMOJI="true"
OCO_MODEL="gpt-4"
OCO_LANGUAGE="de"
OCO_MESSAGE_TEMPLATE_PLACEHOLDER="$m"
OCO_PROMPT_MODULE="@commitlint"
OCO_AI_PROVIDER="ollama"
OCO_GITPUSH="false"
OCO_ONE_LINE_COMMIT="true"
`
);
const config = getConfig({ configPath: '', envPath: envFile.filePath });
expect(config).not.toEqual(null);
expect(config!['OCO_OPENAI_API_KEY']).toEqual('sk-key');
expect(config!['OCO_ANTHROPIC_API_KEY']).toEqual('secret-key');
expect(config!['OCO_TOKENS_MAX_INPUT']).toEqual(8192);
expect(config!['OCO_TOKENS_MAX_OUTPUT']).toEqual(1000);
expect(config!['OCO_OPENAI_BASE_PATH']).toEqual('/openai/api');
expect(config!['OCO_DESCRIPTION']).toEqual(true);
expect(config!['OCO_EMOJI']).toEqual(true);
expect(config!['OCO_MODEL']).toEqual('gpt-4');
expect(config!['OCO_LANGUAGE']).toEqual('de');
expect(config!['OCO_MESSAGE_TEMPLATE_PLACEHOLDER']).toEqual('$m');
expect(config!['OCO_PROMPT_MODULE']).toEqual('@commitlint');
expect(config!['OCO_AI_PROVIDER']).toEqual('ollama');
expect(config!['OCO_GITPUSH']).toEqual(false);
expect(config!['OCO_ONE_LINE_COMMIT']).toEqual(true);
await envFile.cleanup();
});
});

29
test/unit/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
import path from 'path';
import { mkdtemp, rm, writeFile } from 'fs';
import { promisify } from 'util';
import { tmpdir } from 'os';
const fsMakeTempDir = promisify(mkdtemp);
const fsRemove = promisify(rm);
const fsWriteFile = promisify(writeFile);
/**
* Prepare tmp file for the test
*/
export async function prepareFile(
fileName: string,
content: string
): Promise<{
filePath: string;
cleanup: () => Promise<void>;
}> {
const tempDir = await fsMakeTempDir(path.join(tmpdir(), 'opencommit-test-'));
const filePath = path.resolve(tempDir, fileName);
await fsWriteFile(filePath, content);
const cleanup = async () => {
return fsRemove(tempDir, { recursive: true });
};
return {
filePath,
cleanup
};
}

View File

@@ -21,6 +21,9 @@
"skipLibCheck": true
},
"include": [
"test/jest-setup.ts"
],
"exclude": ["node_modules"],
"ts-node": {
"esm": true,