* 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
View File
@@ -0,0 +1 @@
.env
+46
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
+9 -1
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_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_DESCRIPTION=<postface a message with ~3 sentences description of the changes>
OCO_EMOJI=<boolean, add GitMoji> 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_LANGUAGE=<locale, scroll to the bottom to see options>
OCO_MESSAGE_TEMPLATE_PLACEHOLDER=<message template placeholder, default: '$msg'> OCO_MESSAGE_TEMPLATE_PLACEHOLDER=<message template placeholder, default: '$msg'>
OCO_PROMPT_MODULE=<either conventional-commit or @commitlint, default: conventional-commit> 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** 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 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` ### 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. 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
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;
+15269 -2131
View File
File diff suppressed because one or more lines are too long
+28594 -2135
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+5446 -871
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "opencommit", "name": "opencommit",
"version": "3.0.12", "version": "3.0.14",
"description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫", "description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
"keywords": [ "keywords": [
"git", "git",
@@ -47,19 +47,29 @@
"build:push": "npm run build && git add . && git commit -m 'build' && git push", "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", "deploy": "npm version patch && npm run build:push && git push --tags && npm publish --tag latest",
"lint": "eslint src --ext ts && tsc --noEmit", "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": { "devDependencies": {
"@commitlint/types": "^17.4.4", "@commitlint/types": "^17.4.4",
"@types/ini": "^1.3.31", "@types/ini": "^1.3.31",
"@types/inquirer": "^9.0.3", "@types/inquirer": "^9.0.3",
"@types/jest": "^29.5.12",
"@types/node": "^16.18.14", "@types/node": "^16.18.14",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
"cli-testing-library": "^2.0.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"esbuild": "^0.15.18", "esbuild": "^0.15.18",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"jest": "^29.7.0",
"prettier": "^2.8.4", "prettier": "^2.8.4",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.9.3" "typescript": "^4.9.3"
}, },
@@ -67,6 +77,7 @@
"@actions/core": "^1.10.0", "@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1", "@actions/exec": "^1.1.1",
"@actions/github": "^5.1.1", "@actions/github": "^5.1.1",
"@anthropic-ai/sdk": "^0.19.2",
"@clack/prompts": "^0.6.1", "@clack/prompts": "^0.6.1",
"@dqbd/tiktoken": "^1.0.2", "@dqbd/tiktoken": "^1.0.2",
"@octokit/webhooks-schemas": "^6.11.0", "@octokit/webhooks-schemas": "^6.11.0",
+5 -1
View File
@@ -94,13 +94,17 @@ ${chalk.grey('——————————————————')}`
const remotes = await getGitRemotes(); const remotes = await getGitRemotes();
// user isn't pushing, return early
if (config?.OCO_GITPUSH === false)
return
if (!remotes.length) { if (!remotes.length) {
const { stdout } = await execa('git', ['push']); const { stdout } = await execa('git', ['push']);
if (stdout) outro(stdout); if (stdout) outro(stdout);
process.exit(0); process.exit(0);
} }
if (remotes.length === 1) { if (remotes.length === 1 && config?.OCO_GITPUSH !== true) {
const isPushConfirmedByUser = await confirm({ const isPushConfirmedByUser = await confirm({
message: 'Do you want to run `git push`?' message: 'Do you want to run `git push`?'
}); });
+68 -25
View File
@@ -4,17 +4,16 @@ import * as dotenv from 'dotenv';
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import { parse as iniParse, stringify as iniStringify } from 'ini'; import { parse as iniParse, stringify as iniStringify } from 'ini';
import { homedir } from 'os'; 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 { intro, outro } from '@clack/prompts';
import { COMMANDS } from '../CommandsEnum'; import { COMMANDS } from '../CommandsEnum';
import { getI18nLocal } from '../i18n'; import { getI18nLocal } from '../i18n';
dotenv.config();
export enum CONFIG_KEYS { export enum CONFIG_KEYS {
OCO_OPENAI_API_KEY = 'OCO_OPENAI_API_KEY', 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_INPUT = 'OCO_TOKENS_MAX_INPUT',
OCO_TOKENS_MAX_OUTPUT = 'OCO_TOKENS_MAX_OUTPUT', OCO_TOKENS_MAX_OUTPUT = 'OCO_TOKENS_MAX_OUTPUT',
OCO_OPENAI_BASE_PATH = 'OCO_OPENAI_BASE_PATH', 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_MESSAGE_TEMPLATE_PLACEHOLDER = 'OCO_MESSAGE_TEMPLATE_PLACEHOLDER',
OCO_PROMPT_MODULE = 'OCO_PROMPT_MODULE', OCO_PROMPT_MODULE = 'OCO_PROMPT_MODULE',
OCO_AI_PROVIDER = 'OCO_AI_PROVIDER', OCO_AI_PROVIDER = 'OCO_AI_PROVIDER',
OCO_GITPUSH = 'OCO_GITPUSH',
OCO_ONE_LINE_COMMIT = 'OCO_ONE_LINE_COMMIT' OCO_ONE_LINE_COMMIT = 'OCO_ONE_LINE_COMMIT'
} }
@@ -33,6 +33,31 @@ export enum CONFIG_MODES {
set = 'set' 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 { export enum DEFAULT_TOKEN_LIMITS {
DEFAULT_MAX_TOKENS_INPUT = 4096, DEFAULT_MAX_TOKENS_INPUT = 4096,
DEFAULT_MAX_TOKENS_OUTPUT = 500 DEFAULT_MAX_TOKENS_OUTPUT = 500
@@ -56,19 +81,24 @@ export const configValidators = {
[CONFIG_KEYS.OCO_OPENAI_API_KEY](value: any, config: any = {}) { [CONFIG_KEYS.OCO_OPENAI_API_KEY](value: any, config: any = {}) {
//need api key unless running locally with ollama //need api key unless running locally with ollama
validateConfig( validateConfig(
'API_KEY', 'OpenAI API_KEY',
value || config.OCO_AI_PROVIDER == 'ollama', value || config.OCO_ANTHROPIC_API_KEY || config.OCO_AI_PROVIDER == 'ollama' || config.OCO_AI_PROVIDER == 'test',
'You need to provide an API key' 'You need to provide an OpenAI/Anthropic API key'
); );
validateConfig( validateConfig(
CONFIG_KEYS.OCO_OPENAI_API_KEY, CONFIG_KEYS.OCO_OPENAI_API_KEY,
value.startsWith('sk-'), value.startsWith('sk-'),
'Must start with "sk-"' 'Must start with "sk-"'
); );
return value;
},
[CONFIG_KEYS.OCO_ANTHROPIC_API_KEY](value: any, config: any = {}) {
validateConfig( validateConfig(
CONFIG_KEYS.OCO_OPENAI_API_KEY, 'ANTHROPIC_API_KEY',
config[CONFIG_KEYS.OCO_OPENAI_BASE_PATH] || value.length === 51, value || config.OCO_OPENAI_API_KEY || config.OCO_AI_PROVIDER == 'ollama' || config.OCO_AI_PROVIDER == 'test',
'Must be 51 characters long' 'You need to provide an OpenAI/Anthropic API key'
); );
return value; return value;
@@ -153,18 +183,12 @@ export const configValidators = {
[CONFIG_KEYS.OCO_MODEL](value: any) { [CONFIG_KEYS.OCO_MODEL](value: any) {
validateConfig( validateConfig(
CONFIG_KEYS.OCO_MODEL, CONFIG_KEYS.OCO_MODEL,
[ [...MODEL_LIST.openai, ...MODEL_LIST.anthropic].includes(value),
'gpt-3.5-turbo', `${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'`
'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'`
); );
return value; return value;
}, },
[CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER](value: any) { [CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER](value: any) {
validateConfig( validateConfig(
CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER, CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
@@ -180,7 +204,15 @@ export const configValidators = {
['conventional-commit', '@commitlint'].includes(value), ['conventional-commit', '@commitlint'].includes(value),
`${value} is not supported yet, use '@commitlint' or 'conventional-commit' (default)` `${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; return value;
}, },
@@ -190,9 +222,11 @@ export const configValidators = {
[ [
'', '',
'openai', 'openai',
'ollama' 'anthropic',
'ollama',
'test'
].includes(value), ].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; return value;
}, },
@@ -212,11 +246,20 @@ export type ConfigType = {
[key in CONFIG_KEYS]?: any; [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 = { const configFromEnv = {
OCO_OPENAI_API_KEY: process.env.OCO_OPENAI_API_KEY, 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 OCO_TOKENS_MAX_INPUT: process.env.OCO_TOKENS_MAX_INPUT
? Number(process.env.OCO_TOKENS_MAX_INPUT) ? Number(process.env.OCO_TOKENS_MAX_INPUT)
: undefined, : undefined,
@@ -226,12 +269,13 @@ export const getConfig = (): ConfigType | null => {
OCO_OPENAI_BASE_PATH: process.env.OCO_OPENAI_BASE_PATH, OCO_OPENAI_BASE_PATH: process.env.OCO_OPENAI_BASE_PATH,
OCO_DESCRIPTION: process.env.OCO_DESCRIPTION === 'true' ? true : false, OCO_DESCRIPTION: process.env.OCO_DESCRIPTION === 'true' ? true : false,
OCO_EMOJI: process.env.OCO_EMOJI === '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_LANGUAGE: process.env.OCO_LANGUAGE || 'en',
OCO_MESSAGE_TEMPLATE_PLACEHOLDER: OCO_MESSAGE_TEMPLATE_PLACEHOLDER:
process.env.OCO_MESSAGE_TEMPLATE_PLACEHOLDER || '$msg', process.env.OCO_MESSAGE_TEMPLATE_PLACEHOLDER || '$msg',
OCO_PROMPT_MODULE: process.env.OCO_PROMPT_MODULE || 'conventional-commit', OCO_PROMPT_MODULE: process.env.OCO_PROMPT_MODULE || 'conventional-commit',
OCO_AI_PROVIDER: process.env.OCO_AI_PROVIDER || 'openai', 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 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)) { for (const configKey of Object.keys(config)) {
if ( if (
!config[configKey] ||
['null', 'undefined'].includes(config[configKey]) ['null', 'undefined'].includes(config[configKey])
) { ) {
config[configKey] = undefined; config[configKey] = undefined;
@@ -269,7 +312,7 @@ export const getConfig = (): ConfigType | null => {
return config; return config;
}; };
export const setConfig = (keyValues: [key: string, value: string][]) => { export const setConfig = (keyValues: [key: string, value: string][], configPath: string = defaultConfigPath) => {
const config = getConfig() || {}; const config = getConfig() || {};
for (const [configKey, configValue] of keyValues) { for (const [configKey, configValue] of keyValues) {
+124
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();
+27 -8
View File
@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import chalk from 'chalk'; import chalk from 'chalk';
import { execa } from 'execa'; import { execa } from 'execa';
import { import {
ChatCompletionRequestMessage, ChatCompletionRequestMessage,
Configuration as OpenAiApiConfiguration, Configuration as OpenAiApiConfiguration,
@@ -17,20 +18,28 @@ import {
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff'; import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
import { tokenCount } from '../utils/tokenCount'; import { tokenCount } from '../utils/tokenCount';
import { AiEngine } from './Engine'; import { AiEngine } from './Engine';
import { MODEL_LIST } from '../commands/config';
const config = getConfig(); const config = getConfig();
const MAX_TOKENS_OUTPUT = config?.OCO_TOKENS_MAX_OUTPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT; const MAX_TOKENS_OUTPUT =
const MAX_TOKENS_INPUT = config?.OCO_TOKENS_MAX_INPUT || DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT; 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 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 [command, mode] = process.argv.slice(2);
const isLocalModel = config?.OCO_AI_PROVIDER == 'ollama' const provider = config?.OCO_AI_PROVIDER;
if (
if (!apiKey && command !== 'config' && mode !== CONFIG_MODES.set && !isLocalModel) { provider === 'openai' &&
!apiKey &&
command !== 'config' &&
mode !== CONFIG_MODES.set
) {
intro('opencommit'); intro('opencommit');
outro( outro(
@@ -44,6 +53,18 @@ if (!apiKey && command !== 'config' && mode !== CONFIG_MODES.set && !isLocalMode
} }
const MODEL = config?.OCO_MODEL || 'gpt-3.5-turbo'; 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 { class OpenAi implements AiEngine {
private openAiApiConfiguration = new OpenAiApiConfiguration({ private openAiApiConfiguration = new OpenAiApiConfiguration({
@@ -105,6 +126,4 @@ class OpenAi implements AiEngine {
}; };
} }
export const api = new OpenAi(); export const api = new OpenAi();
+12
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();
+6
View File
@@ -2,11 +2,17 @@ import { AiEngine } from '../engine/Engine';
import { api } from '../engine/openAi'; import { api } from '../engine/openAi';
import { getConfig } from '../commands/config'; import { getConfig } from '../commands/config';
import { ollamaAi } from '../engine/ollama'; import { ollamaAi } from '../engine/ollama';
import { anthropicAi } from '../engine/anthropic'
import { testAi } from '../engine/testAi';
export function getEngine(): AiEngine { export function getEngine(): AiEngine {
const config = getConfig(); const config = getConfig();
if (config?.OCO_AI_PROVIDER == 'ollama') { if (config?.OCO_AI_PROVIDER == 'ollama') {
return ollamaAi; 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 //open ai gpt by default
return api; return api;
+19
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
+13
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
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
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
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
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
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
};
}
+3
View File
@@ -21,6 +21,9 @@
"skipLibCheck": true "skipLibCheck": true
}, },
"include": [
"test/jest-setup.ts"
],
"exclude": ["node_modules"], "exclude": ["node_modules"],
"ts-node": { "ts-node": {
"esm": true, "esm": true,