mirror of
https://github.com/di-sukharev/opencommit.git
synced 2026-01-13 07:38:01 -05:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aebe7e200f | ||
|
|
6f1e4bcec6 | ||
|
|
2059549dce | ||
|
|
8361dc6838 | ||
|
|
73ccae9de3 | ||
|
|
c58e0c62a4 | ||
|
|
a4b4e65011 | ||
|
|
18f52772b3 | ||
|
|
fef25a2d06 | ||
|
|
bf578da16a | ||
|
|
41330d5517 | ||
|
|
a703fde7b2 | ||
|
|
c5ee5cd8df | ||
|
|
312540456a | ||
|
|
7ddbaf477a | ||
|
|
9a0f412fff | ||
|
|
7cd3ef09cb | ||
|
|
f814c6b89d | ||
|
|
74024a4997 | ||
|
|
cb7f5dd44d | ||
|
|
9cf2db84a9 | ||
|
|
ec307d561f | ||
|
|
058bad95cd | ||
|
|
7469633e3d | ||
|
|
278e4cb4c2 | ||
|
|
e19305dee2 | ||
|
|
673eee209d | ||
|
|
91399a0c68 | ||
|
|
a4480893cb | ||
|
|
c410486e30 | ||
|
|
5cda8b1b03 | ||
|
|
0ac7211ff7 | ||
|
|
670a758bee | ||
|
|
bdc98c6fa8 | ||
|
|
f0251d14bb | ||
|
|
61f1a27377 | ||
|
|
c39181e5bd | ||
|
|
45dc2c4535 | ||
|
|
a192441f68 | ||
|
|
744bb9b11d | ||
|
|
f3adc86693 | ||
|
|
714fac0637 |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
46
.github/workflows/test.yml
vendored
Normal file
46
.github/workflows/test.yml
vendored
Normal 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
|
||||
71
README.md
71
README.md
@@ -4,7 +4,7 @@
|
||||
<h1 align="center">OpenCommit</h1>
|
||||
<h4 align="center">Follow the bird <a href="https://twitter.com/_sukharev_"><img src="https://img.shields.io/twitter/follow/_sukharev_?style=flat&label=_sukharev_&logo=twitter&color=0bf&logoColor=fff" align="center"></a>
|
||||
</div>
|
||||
<h2>Auto-generate meaningful commits in 1 second</h2>
|
||||
<h2>Auto-generate meaningful commits in a second</h2>
|
||||
<p>Killing lame commits with AI 🤯🔫</p>
|
||||
<a href="https://www.npmjs.com/package/opencommit"><img src="https://img.shields.io/npm/v/opencommit" alt="Current version"></a>
|
||||
<h4 align="center">🪩 Winner of <a href="https://twitter.com/_sukharev_/status/1683448136973582336">GitHub 2023 hackathon</a> 🪩</h4>
|
||||
@@ -58,7 +58,7 @@ git add <files...>
|
||||
oco
|
||||
```
|
||||
|
||||
Link to the GitMoji specification: https://gitmoji.dev/
|
||||
### Running locally with Ollama
|
||||
|
||||
You can also run it with local model through ollama:
|
||||
|
||||
@@ -68,20 +68,47 @@ You can also run it with local model through ollama:
|
||||
|
||||
```sh
|
||||
git add <files...>
|
||||
AI_PROVIDER='ollama' opencommit
|
||||
OCO_AI_PROVIDER='ollama' opencommit
|
||||
```
|
||||
|
||||
If you want to use a model other than mistral, you can do so by setting the `OCO_AI_PROVIDER` environment variable as follows:
|
||||
|
||||
```sh
|
||||
OCO_AI_PROVIDER='ollama/llama3:8b' opencommit
|
||||
```
|
||||
|
||||
if you have ollama that is set up in docker/ on another machine with GPUs (not locally), you can change the default endpoint url.
|
||||
You can do so by setting the `OCO_OLLAMA_API_URL` environment variable as follows:
|
||||
|
||||
```sh
|
||||
OCO_OLLAMA_API_URL='http://192.168.1.10:11434/api/chat' opencommit
|
||||
```
|
||||
|
||||
where 192.168.1.10 is example of endpoint URL, where you have ollama set up.
|
||||
|
||||
### Flags
|
||||
|
||||
There are multiple optional flags that can be used with the `oco` command:
|
||||
|
||||
#### Use Full GitMoji Specification
|
||||
|
||||
Link to the GitMoji specification: https://gitmoji.dev/
|
||||
|
||||
This flag can only be used if the `OCO_EMOJI` configuration item is set to `true`. This flag allows users to use all emojis in the GitMoji specification, By default, the GitMoji full specification is set to `false`, which only includes 10 emojis (🐛✨📝🚀✅♻️⬆️🔧🌐💡).
|
||||
This is due to limit the number of tokens sent in each request. However, if you would like to use the full GitMoji specification, you can use the `--fgm` flag.
|
||||
|
||||
```
|
||||
oco --fgm
|
||||
```
|
||||
|
||||
|
||||
#### Skip Commit Confirmation
|
||||
|
||||
This flag allows users to automatically commit the changes without having to manually confirm the commit message. This is useful for users who want to streamline the commit process and avoid additional steps. To use this flag, you can run the following command:
|
||||
|
||||
```
|
||||
oco --yes
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Local per repo configuration
|
||||
@@ -95,10 +122,12 @@ 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-16k' (default), 'gpt-3.5-turbo-0613' or 'gpt-3.5-turbo'>
|
||||
OCO_MODEL=<either 'gpt-4o', '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>
|
||||
OCO_ONE_LINE_COMMIT=<one line commit message, default: false>
|
||||
OCO_AI_PROVIDER=<anthropic, azure, ollama or ollama/model default ollama model: mistral>
|
||||
```
|
||||
|
||||
### Global config for all repos
|
||||
@@ -108,7 +137,7 @@ Local config still has more priority than Global config, but you may set `OCO_MO
|
||||
Simply set any of the variables above like this:
|
||||
|
||||
```sh
|
||||
oco config set OCO_MODEL=gpt-4
|
||||
oco config set OCO_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
Configure [GitMoji](https://gitmoji.dev/) to preface a message.
|
||||
@@ -125,7 +154,7 @@ oco config set OCO_EMOJI=false
|
||||
|
||||
### Switch to GPT-4 or other models
|
||||
|
||||
By default, OpenCommit uses `gpt-3.5-turbo-16k` model.
|
||||
By default, OpenCommit uses `gpt-4o` model.
|
||||
|
||||
You may switch to GPT-4 which performs better, but costs ~x15 times more 🤠
|
||||
|
||||
@@ -136,16 +165,22 @@ oco config set OCO_MODEL=gpt-4
|
||||
or for as a cheaper option:
|
||||
|
||||
```sh
|
||||
oco config set OCO_MODEL=gpt-3.5-turbo
|
||||
oco config set OCO_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
or for GPT-4 Turbo (Preview) which is more capable, has knowledge of world events up to April 2023, a 128k context window and 2-3x cheaper vs GPT-4:
|
||||
### Switch to Azure OpenAI
|
||||
|
||||
By default OpenCommit uses [OpenAI](https://openai.com).
|
||||
|
||||
You could switch to [Azure OpenAI Service](https://learn.microsoft.com/azure/cognitive-services/openai/)🚀
|
||||
|
||||
```sh
|
||||
oco config set OCO_MODEL=gpt-4-1106-preview
|
||||
opencommit config set OCO_AI_PROVIDER=azure
|
||||
```
|
||||
|
||||
Make sure that you spell it `gpt-4` (lowercase) and that you have API access to the 4th model. Even if you have ChatGPT+, that doesn't necessarily mean that you have API access to GPT-4.
|
||||
Of course need to set 'OPENAI_API_KEY'. And also need to set the
|
||||
'OPENAI_BASE_PATH' for the endpoint and set the deployment name to
|
||||
'model'.
|
||||
|
||||
### Locale configuration
|
||||
|
||||
@@ -166,6 +201,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
|
||||
oco 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.
|
||||
@@ -180,7 +223,7 @@ Replace `<module>` with either `conventional-commit` or `@commitlint`.
|
||||
|
||||
#### Example:
|
||||
|
||||
To switch to using th` '@commitlint` prompt module, run:
|
||||
To switch to using the `'@commitlint` prompt module, run:
|
||||
|
||||
```sh
|
||||
oco config set OCO_PROMPT_MODULE=@commitlint
|
||||
@@ -348,7 +391,7 @@ jobs:
|
||||
OCO_OPENAI_BASE_PATH: ''
|
||||
OCO_DESCRIPTION: false
|
||||
OCO_EMOJI: false
|
||||
OCO_MODEL: gpt-3.5-turbo-16k
|
||||
OCO_MODEL: gpt-4o
|
||||
OCO_LANGUAGE: en
|
||||
OCO_PROMPT_MODULE: conventional-commit
|
||||
```
|
||||
@@ -365,6 +408,6 @@ You pay for your requests to OpenAI API on your own.
|
||||
|
||||
OpenCommit stores your key locally.
|
||||
|
||||
OpenCommit by default uses 3.5-turbo-16k model, it should not exceed $0.10 per casual working day.
|
||||
OpenCommit by default uses 3.5-turbo model, it should not exceed $0.10 per casual working day.
|
||||
|
||||
You may switch to gpt-4, it's better, but more expensive.
|
||||
|
||||
28
jest.config.ts
Normal file
28
jest.config.ts
Normal 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;
|
||||
24203
out/cli.cjs
24203
out/cli.cjs
File diff suppressed because one or more lines are too long
37922
out/github-action.cjs
37922
out/github-action.cjs
File diff suppressed because one or more lines are too long
Binary file not shown.
6483
package-lock.json
generated
6483
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencommit",
|
||||
"version": "3.0.11",
|
||||
"version": "3.0.17",
|
||||
"description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
|
||||
"keywords": [
|
||||
"git",
|
||||
@@ -43,23 +43,36 @@
|
||||
"start": "node ./out/cli.cjs",
|
||||
"ollama:start": "OCO_AI_PROVIDER='ollama' node ./out/cli.cjs",
|
||||
"dev": "ts-node ./src/cli.ts",
|
||||
"dev:gemini": "OCO_AI_PROVIDER='gemini' ts-node ./src/cli.ts",
|
||||
"build": "rimraf out && node esbuild.config.js",
|
||||
"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": "node --no-warnings --experimental-vm-modules $( [ -f ./node_modules/.bin/jest ] && echo ./node_modules/.bin/jest || which jest ) test/unit",
|
||||
"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": "npm run test:e2e:setup && jest test/e2e",
|
||||
"test:e2e:setup": "sh test/e2e/setup.sh",
|
||||
"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,8 +80,11 @@
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^5.1.1",
|
||||
"@azure/openai": "^1.0.0-beta.12",
|
||||
"@anthropic-ai/sdk": "^0.19.2",
|
||||
"@clack/prompts": "^0.6.1",
|
||||
"@dqbd/tiktoken": "^1.0.2",
|
||||
"@google/generative-ai": "^0.11.4",
|
||||
"@octokit/webhooks-schemas": "^6.11.0",
|
||||
"@octokit/webhooks-types": "^6.11.0",
|
||||
"ai": "^2.2.14",
|
||||
|
||||
10
src/cli.ts
10
src/cli.ts
@@ -18,7 +18,13 @@ cli(
|
||||
name: 'opencommit',
|
||||
commands: [configCommand, hookCommand, commitlintConfigCommand],
|
||||
flags: {
|
||||
fgm: Boolean
|
||||
fgm: Boolean,
|
||||
yes: {
|
||||
type: Boolean,
|
||||
alias: 'y',
|
||||
description: 'Skip commit confirmation prompt',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
ignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',
|
||||
help: { description: packageJSON.description }
|
||||
@@ -29,7 +35,7 @@ cli(
|
||||
if (await isHookCalled()) {
|
||||
prepareCommitMessageHook();
|
||||
} else {
|
||||
commit(extraArgs, flags.fgm);
|
||||
commit(extraArgs, false, flags.fgm, flags.yes);
|
||||
}
|
||||
},
|
||||
extraArgs
|
||||
|
||||
@@ -41,7 +41,8 @@ const checkMessageTemplate = (extraArgs: string[]): string | false => {
|
||||
const generateCommitMessageFromGitDiff = async (
|
||||
diff: string,
|
||||
extraArgs: string[],
|
||||
fullGitMojiSpec: boolean
|
||||
fullGitMojiSpec: boolean,
|
||||
skipCommitConfirmation: boolean
|
||||
): Promise<void> => {
|
||||
await assertGitRepo();
|
||||
const commitSpinner = spinner();
|
||||
@@ -76,7 +77,7 @@ ${commitMessage}
|
||||
${chalk.grey('——————————————————')}`
|
||||
);
|
||||
|
||||
const isCommitConfirmedByUser = await confirm({
|
||||
const isCommitConfirmedByUser = skipCommitConfirmation || await confirm({
|
||||
message: 'Confirm the commit message?'
|
||||
});
|
||||
|
||||
@@ -94,13 +95,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`?'
|
||||
});
|
||||
@@ -150,6 +155,18 @@ ${chalk.grey('——————————————————')}`
|
||||
} else outro(`${chalk.gray('✖')} process cancelled`);
|
||||
}
|
||||
}
|
||||
if (!isCommitConfirmedByUser && !isCancel(isCommitConfirmedByUser)) {
|
||||
const regenerateMessage = await confirm({
|
||||
message: 'Do you want to regenerate the message ?'
|
||||
});
|
||||
if (regenerateMessage && !isCancel(isCommitConfirmedByUser)) {
|
||||
await generateCommitMessageFromGitDiff(
|
||||
diff,
|
||||
extraArgs,
|
||||
fullGitMojiSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
commitSpinner.stop('📝 Commit message generated');
|
||||
|
||||
@@ -162,7 +179,8 @@ ${chalk.grey('——————————————————')}`
|
||||
export async function commit(
|
||||
extraArgs: string[] = [],
|
||||
isStageAllFlag: Boolean = false,
|
||||
fullGitMojiSpec: boolean = false
|
||||
fullGitMojiSpec: boolean = false,
|
||||
skipCommitConfirmation: boolean = false
|
||||
) {
|
||||
if (isStageAllFlag) {
|
||||
const changedFiles = await getChangedFiles();
|
||||
@@ -234,7 +252,8 @@ export async function commit(
|
||||
generateCommitMessageFromGitDiff(
|
||||
await getDiff({ files: stagedFiles }),
|
||||
extraArgs,
|
||||
fullGitMojiSpec
|
||||
fullGitMojiSpec,
|
||||
skipCommitConfirmation
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -4,17 +4,20 @@ 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();
|
||||
import { TEST_MOCK_TYPES } from '../engine/testAi';
|
||||
|
||||
export enum CONFIG_KEYS {
|
||||
OCO_OPENAI_API_KEY = 'OCO_OPENAI_API_KEY',
|
||||
OCO_ANTHROPIC_API_KEY = 'OCO_ANTHROPIC_API_KEY',
|
||||
OCO_AZURE_API_KEY = 'OCO_AZURE_API_KEY',
|
||||
OCO_GEMINI_API_KEY = 'OCO_GEMINI_API_KEY',
|
||||
OCO_GEMINI_BASE_PATH = 'OCO_GEMINI_BASE_PATH',
|
||||
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 +28,12 @@ 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',
|
||||
OCO_AZURE_ENDPOINT = 'OCO_AZURE_ENDPOINT',
|
||||
OCO_TEST_MOCK_TYPE = 'OCO_TEST_MOCK_TYPE',
|
||||
OCO_API_URL = 'OCO_API_URL',
|
||||
OCO_OLLAMA_API_URL = 'OCO_OLLAMA_API_URL'
|
||||
}
|
||||
|
||||
export enum CONFIG_MODES {
|
||||
@@ -32,6 +41,64 @@ export enum CONFIG_MODES {
|
||||
set = 'set'
|
||||
}
|
||||
|
||||
export const MODEL_LIST = {
|
||||
openai: [
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-3.5-turbo-instruct',
|
||||
'gpt-3.5-turbo-0613',
|
||||
'gpt-3.5-turbo-0301',
|
||||
'gpt-3.5-turbo-1106',
|
||||
'gpt-3.5-turbo-0125',
|
||||
'gpt-3.5-turbo-16k',
|
||||
'gpt-3.5-turbo-16k-0613',
|
||||
'gpt-3.5-turbo-16k-0301',
|
||||
'gpt-4',
|
||||
'gpt-4-0314',
|
||||
'gpt-4-0613',
|
||||
'gpt-4-1106-preview',
|
||||
'gpt-4-0125-preview',
|
||||
'gpt-4-turbo-preview',
|
||||
'gpt-4-vision-preview',
|
||||
'gpt-4-1106-vision-preview',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4-turbo-2024-04-09',
|
||||
'gpt-4-32k',
|
||||
'gpt-4-32k-0314',
|
||||
'gpt-4-32k-0613',
|
||||
'gpt-4o',
|
||||
'gpt-4o-2024-05-13',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4o-mini-2024-07-18'
|
||||
],
|
||||
|
||||
anthropic: [
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-opus-20240229'
|
||||
],
|
||||
|
||||
gemini: [
|
||||
'gemini-1.5-flash',
|
||||
'gemini-1.5-pro',
|
||||
'gemini-1.0-pro',
|
||||
'gemini-pro-vision',
|
||||
'text-embedding-004'
|
||||
]
|
||||
};
|
||||
|
||||
const getDefaultModel = (provider: string | undefined): string => {
|
||||
switch (provider) {
|
||||
case 'ollama':
|
||||
return '';
|
||||
case 'anthropic':
|
||||
return MODEL_LIST.anthropic[0];
|
||||
case 'gemini':
|
||||
return MODEL_LIST.gemini[0];
|
||||
default:
|
||||
return MODEL_LIST.openai[0];
|
||||
}
|
||||
};
|
||||
|
||||
export enum DEFAULT_TOKEN_LIMITS {
|
||||
DEFAULT_MAX_TOKENS_INPUT = 4096,
|
||||
DEFAULT_MAX_TOKENS_OUTPUT = 500
|
||||
@@ -53,17 +120,62 @@ const validateConfig = (
|
||||
|
||||
export const configValidators = {
|
||||
[CONFIG_KEYS.OCO_OPENAI_API_KEY](value: any, config: any = {}) {
|
||||
if (config.OCO_AI_PROVIDER == 'gemini') return value;
|
||||
|
||||
//need api key unless running locally with ollama
|
||||
validateConfig('API_KEY', value || config.OCO_AI_PROVIDER == 'ollama', 'You need to provide an API key');
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_OPENAI_API_KEY,
|
||||
value.startsWith('sk-'),
|
||||
'Must start with "sk-"'
|
||||
'OpenAI API_KEY',
|
||||
value ||
|
||||
config.OCO_ANTHROPIC_API_KEY ||
|
||||
config.OCO_AI_PROVIDER.startsWith('ollama') ||
|
||||
config.OCO_AZURE_API_KEY ||
|
||||
config.OCO_AI_PROVIDER == 'test',
|
||||
'You need to provide an OpenAI/Anthropic/Azure API key'
|
||||
);
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_OPENAI_API_KEY,
|
||||
config[CONFIG_KEYS.OCO_OPENAI_BASE_PATH] || value.length === 51,
|
||||
'Must be 51 characters long'
|
||||
value.startsWith('sk-') || config.OCO_AI_PROVIDER != 'openai',
|
||||
'Must start with "sk-" for openai provider'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_AZURE_API_KEY](value: any, config: any = {}) {
|
||||
validateConfig(
|
||||
'ANTHROPIC_API_KEY',
|
||||
value ||
|
||||
config.OCO_OPENAI_API_KEY ||
|
||||
config.OCO_AZURE_API_KEY ||
|
||||
config.OCO_AI_PROVIDER == 'ollama' ||
|
||||
config.OCO_AI_PROVIDER == 'test',
|
||||
'You need to provide an OpenAI/Anthropic/Azure API key'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_GEMINI_API_KEY](value: any, config: any = {}) {
|
||||
// only need to check for gemini api key if using gemini
|
||||
if (config.OCO_AI_PROVIDER != 'gemini') return value;
|
||||
|
||||
validateConfig(
|
||||
'Gemini API Key',
|
||||
value || config.OCO_GEMINI_API_KEY || config.OCO_AI_PROVIDER == 'test',
|
||||
'You need to provide an Gemini API key'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_ANTHROPIC_API_KEY](value: any, config: any = {}) {
|
||||
validateConfig(
|
||||
'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;
|
||||
@@ -145,20 +257,26 @@ export const configValidators = {
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_MODEL](value: any) {
|
||||
[CONFIG_KEYS.OCO_MODEL](value: any, config: any = {}) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_MODEL,
|
||||
[
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo-16k',
|
||||
'gpt-3.5-turbo-0613',
|
||||
'gpt-4-1106-preview'
|
||||
].includes(value),
|
||||
`${value} is not supported yet, use 'gpt-4', 'gpt-3.5-turbo-16k' (default), 'gpt-3.5-turbo-0613', 'gpt-3.5-turbo' or 'gpt-4-1106-preview'`
|
||||
...MODEL_LIST.openai,
|
||||
...MODEL_LIST.anthropic,
|
||||
...MODEL_LIST.gemini
|
||||
].includes(value) ||
|
||||
config.OCO_AI_PROVIDER == 'ollama' ||
|
||||
config.OCO_AI_PROVIDER == 'azure' ||
|
||||
config.OCO_AI_PROVIDER == 'test',
|
||||
`${value} is not supported yet, use:\n\n ${[
|
||||
...MODEL_LIST.openai,
|
||||
...MODEL_LIST.anthropic,
|
||||
...MODEL_LIST.gemini
|
||||
].join('\n')}`
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
|
||||
@@ -174,33 +292,88 @@ 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;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_AI_PROVIDER](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_AI_PROVIDER,
|
||||
[
|
||||
'',
|
||||
'openai',
|
||||
'ollama'
|
||||
].includes(value),
|
||||
`${value} is not supported yet, use 'ollama' or 'openai' (default)`
|
||||
['', 'openai', 'anthropic', 'gemini', 'azure', 'test'].includes(value) ||
|
||||
value.startsWith('ollama'),
|
||||
`${value} is not supported yet, use 'ollama', 'anthropic', 'azure', 'gemini' or 'openai' (default)`
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_ONE_LINE_COMMIT](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_ONE_LINE_COMMIT,
|
||||
typeof value === 'boolean',
|
||||
'Must be true or false'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
[CONFIG_KEYS.OCO_AZURE_ENDPOINT](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_AZURE_ENDPOINT,
|
||||
value.includes('openai.azure.com'),
|
||||
'Must be in format "https://<resource name>.openai.azure.com/"'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
[CONFIG_KEYS.OCO_TEST_MOCK_TYPE](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_TEST_MOCK_TYPE,
|
||||
TEST_MOCK_TYPES.includes(value),
|
||||
`${value} is not supported yet, use ${TEST_MOCK_TYPES.map(
|
||||
(t) => `'${t}'`
|
||||
).join(', ')}`
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_OLLAMA_API_URL](value: any) {
|
||||
// add simple api validator
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_API_URL,
|
||||
typeof value === 'string' && value.startsWith('http'),
|
||||
`${value} is not a valid URL`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
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_AZURE_API_KEY: process.env.OCO_AZURE_API_KEY,
|
||||
OCO_GEMINI_API_KEY: process.env.OCO_GEMINI_API_KEY,
|
||||
OCO_TOKENS_MAX_INPUT: process.env.OCO_TOKENS_MAX_INPUT
|
||||
? Number(process.env.OCO_TOKENS_MAX_INPUT)
|
||||
: undefined,
|
||||
@@ -208,14 +381,21 @@ export const getConfig = (): ConfigType | null => {
|
||||
? Number(process.env.OCO_TOKENS_MAX_OUTPUT)
|
||||
: undefined,
|
||||
OCO_OPENAI_BASE_PATH: process.env.OCO_OPENAI_BASE_PATH,
|
||||
OCO_GEMINI_BASE_PATH: process.env.OCO_GEMINI_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-16k',
|
||||
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_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_AZURE_ENDPOINT: process.env.OCO_AZURE_ENDPOINT || '',
|
||||
OCO_TEST_MOCK_TYPE: process.env.OCO_TEST_MOCK_TYPE || 'commit-message'
|
||||
};
|
||||
|
||||
const configExists = existsSync(configPath);
|
||||
@@ -225,10 +405,7 @@ export const getConfig = (): ConfigType | null => {
|
||||
const config = iniParse(configFile);
|
||||
|
||||
for (const configKey of Object.keys(config)) {
|
||||
if (
|
||||
!config[configKey] ||
|
||||
['null', 'undefined'].includes(config[configKey])
|
||||
) {
|
||||
if (['null', 'undefined'].includes(config[configKey])) {
|
||||
config[configKey] = undefined;
|
||||
continue;
|
||||
}
|
||||
@@ -241,7 +418,7 @@ export const getConfig = (): ConfigType | null => {
|
||||
|
||||
config[configKey] = validValue;
|
||||
} catch (error) {
|
||||
outro(`Unknown '${configKey}' config option.`);
|
||||
outro(`Unknown '${configKey}' config option or missing validator.`);
|
||||
outro(
|
||||
`Manually fix the '.env' file or global '~/.opencommit' config file.`
|
||||
);
|
||||
@@ -252,7 +429,10 @@ 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) {
|
||||
|
||||
@@ -39,9 +39,9 @@ export const prepareCommitMessageHook = async (
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
if (!config?.OCO_OPENAI_API_KEY) {
|
||||
if (!config?.OCO_OPENAI_API_KEY && !config?.OCO_ANTHROPIC_API_KEY && !config?.OCO_AZURE_API_KEY) {
|
||||
throw new Error(
|
||||
'No OPEN_AI_API exists. Set your OPEN_AI_API=<key> in ~/.opencommit'
|
||||
'No OPEN_AI_API or OCO_ANTHROPIC_API_KEY or OCO_AZURE_API_KEY exists. Set your key in ~/.opencommit'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
122
src/engine/anthropic.ts
Normal file
122
src/engine/anthropic.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
109
src/engine/azure.ts
Normal file
109
src/engine/azure.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import axios from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { execa } from 'execa';
|
||||
import {
|
||||
ChatCompletionRequestMessage,
|
||||
} from 'openai';
|
||||
|
||||
import { OpenAIClient, AzureKeyCredential } from '@azure/openai';
|
||||
|
||||
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';
|
||||
|
||||
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 basePath = config?.OCO_OPENAI_BASE_PATH;
|
||||
let apiKey = config?.OCO_AZURE_API_KEY;
|
||||
let apiEndpoint = config?.OCO_AZURE_ENDPOINT;
|
||||
|
||||
const [command, mode] = process.argv.slice(2);
|
||||
|
||||
const provider = config?.OCO_AI_PROVIDER;
|
||||
|
||||
if (
|
||||
provider === 'azure' &&
|
||||
!apiKey &&
|
||||
!apiEndpoint &&
|
||||
command !== 'config' &&
|
||||
mode !== CONFIG_MODES.set
|
||||
) {
|
||||
intro('opencommit');
|
||||
|
||||
outro(
|
||||
'OCO_AZURE_API_KEY or OCO_AZURE_ENDPOINT are not set, please run `oco config set OCO_AZURE_API_KEY=<your token> . If you are using GPT, 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 || 'gpt-3.5-turbo';
|
||||
|
||||
export class Azure implements AiEngine {
|
||||
private openAI!: OpenAIClient;
|
||||
|
||||
constructor() {
|
||||
if (provider === 'azure') {
|
||||
this.openAI = new OpenAIClient(apiEndpoint, new AzureKeyCredential(apiKey));
|
||||
}
|
||||
}
|
||||
|
||||
public generateCommitMessage = async (
|
||||
messages: Array<ChatCompletionRequestMessage>
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const REQUEST_TOKENS = messages
|
||||
.map((msg) => tokenCount(msg.content) + 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.openAI.getChatCompletions(MODEL, messages);
|
||||
|
||||
const message = data.choices[0].message;
|
||||
|
||||
if (message?.content === null) {
|
||||
return undefined;
|
||||
}
|
||||
return message?.content;
|
||||
} catch (error) {
|
||||
outro(`${chalk.red('✖')} ${MODEL}`);
|
||||
|
||||
const err = error as Error;
|
||||
outro(`${chalk.red('✖')} ${err?.message || err}`);
|
||||
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const openAiError = error.response.data.error;
|
||||
|
||||
if (openAiError?.message) outro(openAiError.message);
|
||||
outro(
|
||||
'For help look into README https://github.com/di-sukharev/opencommit#setup'
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const azure = new Azure();
|
||||
133
src/engine/gemini.ts
Normal file
133
src/engine/gemini.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { ChatCompletionRequestMessage } from 'openai';
|
||||
import { AiEngine } from './Engine';
|
||||
import { Content, GenerativeModel, GoogleGenerativeAI, HarmBlockThreshold, HarmCategory, Part } from '@google/generative-ai';
|
||||
import { CONFIG_MODES, ConfigType, DEFAULT_TOKEN_LIMITS, getConfig, MODEL_LIST } from '../commands/config';
|
||||
import { intro, outro } from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
export class Gemini implements AiEngine {
|
||||
|
||||
private readonly config: ConfigType;
|
||||
private readonly googleGenerativeAi: GoogleGenerativeAI;
|
||||
private ai: GenerativeModel;
|
||||
|
||||
// vars
|
||||
private maxTokens = {
|
||||
input: DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT,
|
||||
output: DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT
|
||||
};
|
||||
private basePath: string;
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
|
||||
constructor() {
|
||||
this.config = getConfig() as ConfigType;
|
||||
this.googleGenerativeAi = new GoogleGenerativeAI(this.config.OCO_GEMINI_API_KEY);
|
||||
|
||||
this.warmup();
|
||||
}
|
||||
|
||||
async generateCommitMessage(messages: ChatCompletionRequestMessage[]): Promise<string | undefined> {
|
||||
const systemInstruction = messages.filter(m => m.role === 'system')
|
||||
.map(m => m.content)
|
||||
.join('\n');
|
||||
|
||||
this.ai = this.googleGenerativeAi.getGenerativeModel({
|
||||
model: this.model,
|
||||
systemInstruction,
|
||||
});
|
||||
|
||||
const contents = messages.filter(m => m.role !== 'system')
|
||||
.map(m => ({ parts: [{ text: m.content } as Part], role: m.role == 'user' ? m.role : 'model', } as Content));
|
||||
|
||||
try {
|
||||
const result = await this.ai.generateContent({
|
||||
contents,
|
||||
safetySettings: [
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: this.maxTokens.output,
|
||||
temperature: 0,
|
||||
topP: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
return result.response.text();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
outro(`${chalk.red('✖')} ${err?.message || err}`);
|
||||
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const geminiError = error.response.data.error;
|
||||
|
||||
if (geminiError?.message) outro(geminiError.message);
|
||||
outro(
|
||||
'For help look into README https://github.com/di-sukharev/opencommit#setup'
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private warmup(): void {
|
||||
if (this.config.OCO_TOKENS_MAX_INPUT !== undefined) this.maxTokens.input = this.config.OCO_TOKENS_MAX_INPUT;
|
||||
if (this.config.OCO_TOKENS_MAX_OUTPUT !== undefined) this.maxTokens.output = this.config.OCO_TOKENS_MAX_OUTPUT;
|
||||
this.basePath = this.config.OCO_GEMINI_BASE_PATH;
|
||||
this.apiKey = this.config.OCO_GEMINI_API_KEY;
|
||||
|
||||
const [command, mode] = process.argv.slice(2);
|
||||
|
||||
const provider = this.config.OCO_AI_PROVIDER;
|
||||
|
||||
if (provider === 'gemini' && !this.apiKey &&
|
||||
command !== 'config' && mode !== 'set') {
|
||||
intro('opencommit');
|
||||
|
||||
outro('OCO_GEMINI_API_KEY is not set, please run `oco config set OCO_GEMINI_API_KEY=<your token> . If you are using GPT, 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);
|
||||
}
|
||||
|
||||
this.model = this.config.OCO_MODEL || MODEL_LIST.gemini[0];
|
||||
|
||||
if (provider === 'gemini' &&
|
||||
!MODEL_LIST.gemini.includes(this.model) &&
|
||||
command !== 'config' &&
|
||||
mode !== CONFIG_MODES.set) {
|
||||
outro(
|
||||
`${chalk.red('✖')} Unsupported model ${this.model} for Gemini. Supported models are: ${MODEL_LIST.gemini.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,20 +2,36 @@ import axios, { AxiosError } from 'axios';
|
||||
import { ChatCompletionRequestMessage } from 'openai';
|
||||
import { AiEngine } from './Engine';
|
||||
|
||||
import {
|
||||
getConfig
|
||||
} from '../commands/config';
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
export class OllamaAi implements AiEngine {
|
||||
private model = "mistral"; // as default model of Ollama
|
||||
private url = "http://localhost:11434/api/chat"; // default URL of Ollama API
|
||||
|
||||
setModel(model: string) {
|
||||
this.model = model ?? config?.OCO_MODEL ?? 'mistral';
|
||||
}
|
||||
|
||||
setUrl(url: string) {
|
||||
this.url = url ?? config?.OCO_OLLAMA_API_URL ?? 'http://localhost:11434/api/chat';
|
||||
}
|
||||
async generateCommitMessage(
|
||||
messages: Array<ChatCompletionRequestMessage>
|
||||
): Promise<string | undefined> {
|
||||
const model = 'mistral'; // todo: allow other models
|
||||
const model = this.model;
|
||||
|
||||
let prompt = messages.map((x) => x.content).join('\n');
|
||||
//hoftix: local models are not so clever so im changing the prompt a bit...
|
||||
prompt += 'Summarize above git diff in 10 words or less';
|
||||
//console.log(messages);
|
||||
//process.exit()
|
||||
|
||||
const url = 'http://localhost:11434/api/generate';
|
||||
const url = this.url;
|
||||
const p = {
|
||||
model,
|
||||
prompt,
|
||||
messages,
|
||||
options: { temperature: 0, top_p: 0.1 },
|
||||
stream: false
|
||||
};
|
||||
try {
|
||||
@@ -24,13 +40,13 @@ export class OllamaAi implements AiEngine {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const answer = response.data?.response;
|
||||
return answer;
|
||||
|
||||
const message = response.data.message;
|
||||
|
||||
return message?.content;
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error ?? err.message;
|
||||
throw new Error('local model issues. details: ' + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ollamaAi = new OllamaAi();
|
||||
|
||||
@@ -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,8 +53,21 @@ 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(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
|
||||
class OpenAi implements AiEngine {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export class OpenAi implements AiEngine {
|
||||
|
||||
private openAiApiConfiguration = new OpenAiApiConfiguration({
|
||||
apiKey: apiKey
|
||||
});
|
||||
@@ -70,7 +92,7 @@ class OpenAi implements AiEngine {
|
||||
};
|
||||
try {
|
||||
const REQUEST_TOKENS = messages
|
||||
.map((msg) => tokenCount(msg.content) + 4)
|
||||
.map((msg) => tokenCount(msg.content as string) + 4)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (REQUEST_TOKENS > MAX_TOKENS_INPUT - MAX_TOKENS_OUTPUT) {
|
||||
@@ -103,8 +125,6 @@ class OpenAi implements AiEngine {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const api = new OpenAi();
|
||||
|
||||
31
src/engine/testAi.ts
Normal file
31
src/engine/testAi.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ChatCompletionRequestMessage } from 'openai';
|
||||
import { AiEngine } from './Engine';
|
||||
import { getConfig } from '../commands/config';
|
||||
|
||||
export const TEST_MOCK_TYPES = [
|
||||
'commit-message',
|
||||
'prompt-module-commitlint-config',
|
||||
] as const
|
||||
type TestMockType = typeof TEST_MOCK_TYPES[number];
|
||||
|
||||
export class TestAi implements AiEngine {
|
||||
async generateCommitMessage(
|
||||
_messages: Array<ChatCompletionRequestMessage>
|
||||
): Promise<string | undefined> {
|
||||
const config = getConfig();
|
||||
switch (config?.OCO_TEST_MOCK_TYPE as TestMockType | undefined) {
|
||||
case 'commit-message':
|
||||
return 'fix(testAi.ts): test commit message';
|
||||
case 'prompt-module-commitlint-config':
|
||||
return `{\n` +
|
||||
` "localLanguage": "english",\n` +
|
||||
` "commitFix": "fix(server): Change 'port' variable to uppercase 'PORT'",\n` +
|
||||
` "commitFeat": "feat(server): Allow server to listen on a port specified through environment variable",\n` +
|
||||
` "commitDescription": "Change 'port' variable to uppercase 'PORT'. Allow server to listen on a port specified through environment variable."\n` +
|
||||
`}`
|
||||
default:
|
||||
throw Error('unsupported test mock type')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ import { tokenCount } from './utils/tokenCount';
|
||||
import { getEngine } from './utils/engine';
|
||||
|
||||
const config = getConfig();
|
||||
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;
|
||||
const MAX_TOKENS_OUTPUT =
|
||||
config?.OCO_TOKENS_MAX_OUTPUT ||
|
||||
DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT;
|
||||
|
||||
const generateCommitMessageChatCompletionPrompt = async (
|
||||
diff: string,
|
||||
@@ -46,7 +49,7 @@ export const generateCommitMessageByDiff = async (
|
||||
const INIT_MESSAGES_PROMPT = await getMainCommitPrompt(fullGitMojiSpec);
|
||||
|
||||
const INIT_MESSAGES_PROMPT_LENGTH = INIT_MESSAGES_PROMPT.map(
|
||||
(msg) => tokenCount(msg.content) + 4
|
||||
(msg) => tokenCount(msg.content as string) + 4
|
||||
).reduce((a, b) => a + b, 0);
|
||||
|
||||
const MAX_REQUEST_TOKENS =
|
||||
@@ -62,18 +65,21 @@ export const generateCommitMessageByDiff = async (
|
||||
fullGitMojiSpec
|
||||
);
|
||||
|
||||
const commitMessages = [];
|
||||
const commitMessages = [] as string[];
|
||||
for (const promise of commitMessagePromises) {
|
||||
commitMessages.push(await promise);
|
||||
commitMessages.push((await promise) as string);
|
||||
await delay(2000);
|
||||
}
|
||||
|
||||
return commitMessages.join('\n\n');
|
||||
}
|
||||
|
||||
const messages = await generateCommitMessageChatCompletionPrompt(diff, fullGitMojiSpec);
|
||||
const messages = await generateCommitMessageChatCompletionPrompt(
|
||||
diff,
|
||||
fullGitMojiSpec
|
||||
);
|
||||
|
||||
const engine = getEngine()
|
||||
const engine = getEngine();
|
||||
const commitMessage = await engine.generateCommitMessage(messages);
|
||||
|
||||
if (!commitMessage)
|
||||
@@ -100,7 +106,7 @@ function getMessagesPromisesByChangesInFile(
|
||||
maxChangeLength
|
||||
);
|
||||
|
||||
const lineDiffsWithHeader = [];
|
||||
const lineDiffsWithHeader = [] as string[];
|
||||
for (const change of mergedChanges) {
|
||||
const totalChange = fileHeader + change;
|
||||
if (tokenCount(totalChange) > maxChangeLength) {
|
||||
@@ -112,7 +118,7 @@ function getMessagesPromisesByChangesInFile(
|
||||
}
|
||||
}
|
||||
|
||||
const engine = getEngine()
|
||||
const engine = getEngine();
|
||||
const commitMsgsFromFileLineDiffs = lineDiffsWithHeader.map(
|
||||
async (lineDiff) => {
|
||||
const messages = await generateCommitMessageChatCompletionPrompt(
|
||||
@@ -129,7 +135,7 @@ function getMessagesPromisesByChangesInFile(
|
||||
|
||||
function splitDiff(diff: string, maxChangeLength: number) {
|
||||
const lines = diff.split('\n');
|
||||
const splitDiffs = [];
|
||||
const splitDiffs = [] as string[];
|
||||
let currentDiff = '';
|
||||
|
||||
if (maxChangeLength <= 0) {
|
||||
@@ -175,7 +181,7 @@ export const getCommitMsgsPromisesFromFileDiffs = async (
|
||||
// merge multiple files-diffs into 1 prompt to save tokens
|
||||
const mergedFilesDiffs = mergeDiffs(diffByFiles, maxDiffLength);
|
||||
|
||||
const commitMessagePromises = [];
|
||||
const commitMessagePromises = [] as Promise<string | undefined>[];
|
||||
|
||||
for (const fileDiff of mergedFilesDiffs) {
|
||||
if (tokenCount(fileDiff) >= maxDiffLength) {
|
||||
@@ -194,7 +200,7 @@ export const getCommitMsgsPromisesFromFileDiffs = async (
|
||||
fullGitMojiSpec
|
||||
);
|
||||
|
||||
const engine = getEngine()
|
||||
const engine = getEngine();
|
||||
commitMessagePromises.push(engine.generateCommitMessage(messages));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,16 @@ export const configureCommitlintIntegration = async (force = false) => {
|
||||
|
||||
const fileExists = await utils.commitlintLLMConfigExists();
|
||||
|
||||
let commitLintConfig = await getCommitLintPWDConfig();
|
||||
const commitLintConfig = await getCommitLintPWDConfig();
|
||||
if (commitLintConfig === null) {
|
||||
throw new Error(
|
||||
`Failed to load @commitlint config. Please check the following:
|
||||
* @commitlint >= 9.0.0 is installed in the local directory.
|
||||
* 'node_modules/@commitlint/load' package exists.
|
||||
* A valid @commitlint configuration exists.
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// debug complete @commitlint configuration
|
||||
// await fs.writeFile(
|
||||
@@ -55,7 +64,7 @@ export const configureCommitlintIntegration = async (force = false) => {
|
||||
// consistencyPrompts.map((p) => p.content)
|
||||
// );
|
||||
|
||||
const engine = getEngine()
|
||||
const engine = getEngine();
|
||||
let consistency =
|
||||
(await engine.generateCommitMessage(consistencyPrompts)) || '{}';
|
||||
|
||||
@@ -64,7 +73,7 @@ export const configureCommitlintIntegration = async (force = false) => {
|
||||
|
||||
// sometimes consistency is preceded by explanatory text like "Here is your JSON:"
|
||||
consistency = utils.getJSONBlock(consistency);
|
||||
|
||||
|
||||
// ... remaining might be extra set of "\n"
|
||||
consistency = utils.removeDoubleNewlines(consistency);
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ const INIT_MAIN_PROMPT = (
|
||||
${config?.OCO_EMOJI ? 'Use GitMoji convention to preface the commit.' : 'Do not preface the commit with anything.'}
|
||||
${config?.OCO_DESCRIPTION ? 'Add a short description of WHY the changes are done after the commit message. Don\'t start it with "This commit", just describe the changes.' : "Don't add any descriptions to the commit, only commit message."}
|
||||
Use the present tense. Use ${language} to answer.
|
||||
${ config?.OCO_ONE_LINE_COMMIT ? 'Craft a concise commit message that encapsulates all changes made, with an emphasis on the primary updates. If the modifications share a common theme or scope, mention it succinctly; otherwise, leave the scope out to maintain focus. The goal is to provide a clear and unified overview of the changes in a one single message, without diverging into a list of commit per file change.' : ""}
|
||||
|
||||
You will strictly follow the following conventions to generate the content of the commit message:
|
||||
- ${prompts.join('\n- ')}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const nodeModulesPath = path.join(
|
||||
process.env.PWD || process.cwd(),
|
||||
'node_modules',
|
||||
'@commitlint',
|
||||
'load'
|
||||
);
|
||||
const getCommitLintModuleType = async (): Promise<'cjs' | 'esm'> => {
|
||||
const packageFile = 'node_modules/@commitlint/load/package.json';
|
||||
const packageJsonPath = path.join(
|
||||
process.env.PWD || process.cwd(),
|
||||
packageFile,
|
||||
);
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
if (!packageJson) {
|
||||
throw new Error(`Failed to parse ${packageFile}`);
|
||||
}
|
||||
|
||||
return packageJson.type === 'module' ? 'esm' : 'cjs';
|
||||
};
|
||||
|
||||
/**
|
||||
* QualifiedConfig from any version of @commitlint/types
|
||||
* @see https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/types/src/load.ts
|
||||
*/
|
||||
type QualifiedConfigOnAnyVersion = { [key:string]: unknown };
|
||||
|
||||
/**
|
||||
* This code is loading the configuration for the `@commitlint` package from the current working
|
||||
@@ -13,8 +27,31 @@ const nodeModulesPath = path.join(
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export const getCommitLintPWDConfig = async () => {
|
||||
const load = require(nodeModulesPath).default;
|
||||
export const getCommitLintPWDConfig = async (): Promise<QualifiedConfigOnAnyVersion | null> => {
|
||||
let load, nodeModulesPath;
|
||||
switch (await getCommitLintModuleType()) {
|
||||
case 'cjs':
|
||||
/**
|
||||
* CommonJS (<= commitlint@v18.x.x.)
|
||||
*/
|
||||
nodeModulesPath = path.join(
|
||||
process.env.PWD || process.cwd(),
|
||||
'node_modules/@commitlint/load',
|
||||
);
|
||||
load = require(nodeModulesPath).default;
|
||||
break;
|
||||
case 'esm':
|
||||
/**
|
||||
* ES Module (commitlint@v19.x.x. <= )
|
||||
* Directory import is not supported in ES Module resolution, so import the file directly
|
||||
*/
|
||||
nodeModulesPath = path.join(
|
||||
process.env.PWD || process.cwd(),
|
||||
'node_modules/@commitlint/load/lib/load.js',
|
||||
);
|
||||
load = (await import(nodeModulesPath)).default;
|
||||
break;
|
||||
}
|
||||
|
||||
if (load && typeof load === 'function') {
|
||||
return await load();
|
||||
|
||||
@@ -18,14 +18,14 @@ export const removeDoubleNewlines = (input: string): string => {
|
||||
|
||||
export const getJSONBlock = (input: string): string => {
|
||||
const jsonIndex = input.search('```json');
|
||||
if(jsonIndex > -1) {
|
||||
if (jsonIndex > -1) {
|
||||
input = input.slice(jsonIndex + 8);
|
||||
const endJsonIndex = consistency.search('```');
|
||||
const endJsonIndex = input.search('```');
|
||||
input = input.slice(0, endJsonIndex);
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
|
||||
export const commitlintLLMConfigExists = async (): Promise<boolean> => {
|
||||
let exists;
|
||||
try {
|
||||
@@ -54,4 +54,4 @@ export const getCommitlintLLMConfig =
|
||||
content.toString()
|
||||
) as CommitlintLLMConfig;
|
||||
return commitLintLLMConfig;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -115,6 +115,11 @@ const INIT_MAIN_PROMPT = (
|
||||
? 'Add a short description of WHY the changes are done after the commit message. Don\'t start it with "This commit", just describe the changes.'
|
||||
: "Don't add any descriptions to the commit, only commit message."
|
||||
}
|
||||
${
|
||||
config?.OCO_ONE_LINE_COMMIT
|
||||
? 'Craft a concise commit message that encapsulates all changes made, with an emphasis on the primary updates. If the modifications share a common theme or scope, mention it succinctly; otherwise, leave the scope out to maintain focus. The goal is to provide a clear and unified overview of the changes in a one single message, without diverging into a list of commit per file change.'
|
||||
: ''
|
||||
}
|
||||
Use the present tense. Lines must not be longer than 74 characters. Use ${language} for the commit message.`
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { AiEngine } from '../engine/Engine';
|
||||
import { api } from '../engine/openAi';
|
||||
import { OpenAi } from '../engine/openAi';
|
||||
import { Gemini } from '../engine/gemini';
|
||||
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';
|
||||
import { Azure } from '../engine/azure';
|
||||
|
||||
export function getEngine(): AiEngine {
|
||||
const config = getConfig();
|
||||
if (config?.OCO_AI_PROVIDER == 'ollama') {
|
||||
const provider = config?.OCO_AI_PROVIDER;
|
||||
|
||||
if (provider?.startsWith('ollama')) {
|
||||
const ollamaAi = new OllamaAi();
|
||||
const model = provider.split('/')[1];
|
||||
if (model) ollamaAi.setModel(model);
|
||||
|
||||
return ollamaAi;
|
||||
} else if (provider == 'anthropic') {
|
||||
return new AnthropicAi();
|
||||
} else if (provider == 'test') {
|
||||
return new TestAi();
|
||||
} else if (provider == 'gemini') {
|
||||
return new Gemini();
|
||||
} else if (provider == 'azure') {
|
||||
return new Azure();
|
||||
}
|
||||
|
||||
//open ai gpt by default
|
||||
return api;
|
||||
return new OpenAi();
|
||||
}
|
||||
|
||||
@@ -75,8 +75,11 @@ export const getChangedFiles = async (): Promise<string[]> => {
|
||||
|
||||
export const gitAdd = async ({ files }: { files: string[] }) => {
|
||||
const gitAddSpinner = spinner();
|
||||
|
||||
gitAddSpinner.start('Adding files to commit');
|
||||
|
||||
await execa('git', ['add', ...files]);
|
||||
|
||||
gitAddSpinner.stop('Done');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { outro } from "@clack/prompts";
|
||||
import { execa } from "execa";
|
||||
import { outro } from '@clack/prompts';
|
||||
import { execa } from 'execa';
|
||||
|
||||
export const getOpenCommitLatestVersion = async (): Promise<
|
||||
string | undefined
|
||||
@@ -11,4 +11,4 @@ export const getOpenCommitLatestVersion = async (): Promise<
|
||||
outro('Error while getting the latest version of opencommit');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
22
test/Dockerfile
Normal file
22
test/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
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"
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json /app/
|
||||
COPY package-lock.json /app/
|
||||
|
||||
RUN ls -la
|
||||
|
||||
RUN npm ci
|
||||
COPY . /app
|
||||
RUN ls -la
|
||||
RUN npm run build
|
||||
12
test/e2e/noChanges.test.ts
Normal file
12
test/e2e/noChanges.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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();
|
||||
});
|
||||
55
test/e2e/oneFile.test.ts
Normal file
55
test/e2e/oneFile.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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();
|
||||
});
|
||||
224
test/e2e/prompt-module/commitlint.test.ts
Normal file
224
test/e2e/prompt-module/commitlint.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { resolve } from 'path';
|
||||
import { render } from 'cli-testing-library';
|
||||
import 'cli-testing-library/extend-expect';
|
||||
import { prepareEnvironment, wait } from '../utils';
|
||||
import path from 'path';
|
||||
|
||||
function getAbsolutePath(relativePath: string) {
|
||||
const scriptDir = path.dirname(__filename);
|
||||
return path.resolve(scriptDir, relativePath);
|
||||
}
|
||||
async function setupCommitlint(dir: string, ver: 9 | 18 | 19) {
|
||||
let packagePath, packageJsonPath, configPath;
|
||||
switch (ver) {
|
||||
case 9:
|
||||
packagePath = getAbsolutePath('./data/commitlint_9/node_modules');
|
||||
packageJsonPath = getAbsolutePath('./data/commitlint_9/package.json');
|
||||
configPath = getAbsolutePath('./data/commitlint_9/commitlint.config.js');
|
||||
break;
|
||||
case 18:
|
||||
packagePath = getAbsolutePath('./data/commitlint_18/node_modules');
|
||||
packageJsonPath = getAbsolutePath('./data/commitlint_18/package.json');
|
||||
configPath = getAbsolutePath('./data/commitlint_18/commitlint.config.js');
|
||||
break;
|
||||
case 19:
|
||||
packagePath = getAbsolutePath('./data/commitlint_19/node_modules');
|
||||
packageJsonPath = getAbsolutePath('./data/commitlint_19/package.json');
|
||||
configPath = getAbsolutePath('./data/commitlint_19/commitlint.config.js');
|
||||
break;
|
||||
}
|
||||
await render('cp', ['-r', packagePath, '.'], { cwd: dir });
|
||||
await render('cp', [packageJsonPath, '.'], { cwd: dir });
|
||||
await render('cp', [configPath, '.'], { cwd: dir });
|
||||
await wait(3000); // Avoid flakiness by waiting
|
||||
}
|
||||
|
||||
describe('cli flow to run "oco commitlint force"', () => {
|
||||
it('on commitlint@9 using CJS', async () => {
|
||||
const { gitDir, cleanup } = await prepareEnvironment();
|
||||
|
||||
await setupCommitlint(gitDir, 9);
|
||||
const npmList = await render('npm', ['list', '@commitlint/load'], {
|
||||
cwd: gitDir
|
||||
});
|
||||
expect(await npmList.findByText('@commitlint/load@9')).toBeInTheConsole();
|
||||
|
||||
const { findByText } = await render(
|
||||
`
|
||||
OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
|
||||
OCO_PROMPT_MODULE='@commitlint' \
|
||||
OCO_AI_PROVIDER='test' \
|
||||
node ${resolve('./out/cli.cjs')} commitlint force \
|
||||
`,
|
||||
[],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
|
||||
expect(
|
||||
await findByText('opencommit — configure @commitlint')
|
||||
).toBeInTheConsole();
|
||||
expect(
|
||||
await findByText('Read @commitlint configuration')
|
||||
).toBeInTheConsole();
|
||||
|
||||
expect(
|
||||
await findByText('Generating consistency with given @commitlint rules')
|
||||
).toBeInTheConsole();
|
||||
expect(
|
||||
await findByText('Done - please review contents of')
|
||||
).toBeInTheConsole();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
it('on commitlint@18 using CJS', async () => {
|
||||
const { gitDir, cleanup } = await prepareEnvironment();
|
||||
|
||||
await setupCommitlint(gitDir, 18);
|
||||
const npmList = await render('npm', ['list', '@commitlint/load'], {
|
||||
cwd: gitDir
|
||||
});
|
||||
expect(await npmList.findByText('@commitlint/load@18')).toBeInTheConsole();
|
||||
|
||||
const { findByText } = await render(
|
||||
`
|
||||
OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
|
||||
OCO_PROMPT_MODULE='@commitlint' \
|
||||
OCO_AI_PROVIDER='test' \
|
||||
node ${resolve('./out/cli.cjs')} commitlint force \
|
||||
`,
|
||||
[],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
|
||||
expect(
|
||||
await findByText('opencommit — configure @commitlint')
|
||||
).toBeInTheConsole();
|
||||
expect(
|
||||
await findByText('Read @commitlint configuration')
|
||||
).toBeInTheConsole();
|
||||
|
||||
expect(
|
||||
await findByText('Generating consistency with given @commitlint rules')
|
||||
).toBeInTheConsole();
|
||||
expect(
|
||||
await findByText('Done - please review contents of')
|
||||
).toBeInTheConsole();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
it('on commitlint@19 using ESM', async () => {
|
||||
const { gitDir, cleanup } = await prepareEnvironment();
|
||||
|
||||
await setupCommitlint(gitDir, 19);
|
||||
const npmList = await render('npm', ['list', '@commitlint/load'], {
|
||||
cwd: gitDir
|
||||
});
|
||||
expect(await npmList.findByText('@commitlint/load@19')).toBeInTheConsole();
|
||||
|
||||
const { findByText } = await render(
|
||||
`
|
||||
OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
|
||||
OCO_PROMPT_MODULE='@commitlint' \
|
||||
OCO_AI_PROVIDER='test' \
|
||||
node ${resolve('./out/cli.cjs')} commitlint force \
|
||||
`,
|
||||
[],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
|
||||
expect(
|
||||
await findByText('opencommit — configure @commitlint')
|
||||
).toBeInTheConsole();
|
||||
expect(
|
||||
await findByText('Read @commitlint configuration')
|
||||
).toBeInTheConsole();
|
||||
|
||||
expect(
|
||||
await findByText('Generating consistency with given @commitlint rules')
|
||||
).toBeInTheConsole();
|
||||
expect(
|
||||
await findByText('Done - please review contents of')
|
||||
).toBeInTheConsole();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cli flow to generate commit message using @commitlint prompt-module', () => {
|
||||
it('on commitlint@19 using ESM', async () => {
|
||||
const { gitDir, cleanup } = await prepareEnvironment();
|
||||
|
||||
// Setup commitlint@19
|
||||
await setupCommitlint(gitDir, 19);
|
||||
const npmList = await render('npm', ['list', '@commitlint/load'], {
|
||||
cwd: gitDir
|
||||
});
|
||||
expect(await npmList.findByText('@commitlint/load@19')).toBeInTheConsole();
|
||||
|
||||
// Run `oco commitlint force`
|
||||
const commitlintForce = await render(
|
||||
`
|
||||
OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
|
||||
OCO_PROMPT_MODULE='@commitlint' \
|
||||
OCO_AI_PROVIDER='test' \
|
||||
node ${resolve('./out/cli.cjs')} commitlint force \
|
||||
`,
|
||||
[],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
expect(
|
||||
await commitlintForce.findByText('Done - please review contents of')
|
||||
).toBeInTheConsole();
|
||||
|
||||
// Run `oco commitlint get`
|
||||
const commitlintGet = await render(
|
||||
`
|
||||
OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
|
||||
OCO_PROMPT_MODULE='@commitlint' \
|
||||
OCO_AI_PROVIDER='test' \
|
||||
node ${resolve('./out/cli.cjs')} commitlint get \
|
||||
`,
|
||||
[],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
expect(
|
||||
await commitlintGet.findByText('[object Object]')
|
||||
).toBeInTheConsole();
|
||||
|
||||
// Run 'oco' using .opencommit-commitlint
|
||||
await render('echo', [`'console.log("Hello World");' > index.ts`], {
|
||||
cwd: gitDir
|
||||
});
|
||||
await render('git', ['add index.ts'], { cwd: gitDir });
|
||||
|
||||
const oco = await render(
|
||||
`
|
||||
OCO_TEST_MOCK_TYPE='commit-message' \
|
||||
OCO_PROMPT_MODULE='@commitlint' \
|
||||
OCO_AI_PROVIDER='test' \
|
||||
node ${resolve('./out/cli.cjs')} \
|
||||
`,
|
||||
[],
|
||||
{ cwd: gitDir }
|
||||
);
|
||||
|
||||
expect(
|
||||
await oco.findByText('Generating the commit message')
|
||||
).toBeInTheConsole();
|
||||
expect(
|
||||
await oco.findByText('Confirm the commit message?')
|
||||
).toBeInTheConsole();
|
||||
oco.userEvent.keyboard('[Enter]');
|
||||
|
||||
expect(
|
||||
await oco.findByText('Choose a remote to push to')
|
||||
).toBeInTheConsole();
|
||||
oco.userEvent.keyboard('[Enter]');
|
||||
|
||||
expect(
|
||||
await oco.findByText('Successfully pushed all commits to origin')
|
||||
).toBeInTheConsole();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional']
|
||||
};
|
||||
2029
test/e2e/prompt-module/data/commitlint_18/package-lock.json
generated
Normal file
2029
test/e2e/prompt-module/data/commitlint_18/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
test/e2e/prompt-module/data/commitlint_18/package.json
Normal file
15
test/e2e/prompt-module/data/commitlint_18/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "commitlint-test",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^18.0.0",
|
||||
"@commitlint/config-conventional": "^18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
extends: ['@commitlint/config-conventional']
|
||||
};
|
||||
1453
test/e2e/prompt-module/data/commitlint_19/package-lock.json
generated
Normal file
1453
test/e2e/prompt-module/data/commitlint_19/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
test/e2e/prompt-module/data/commitlint_19/package.json
Normal file
16
test/e2e/prompt-module/data/commitlint_19/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "commitlint-test",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.0.0",
|
||||
"@commitlint/config-conventional": "^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional']
|
||||
};
|
||||
1671
test/e2e/prompt-module/data/commitlint_9/package-lock.json
generated
Normal file
1671
test/e2e/prompt-module/data/commitlint_9/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
test/e2e/prompt-module/data/commitlint_9/package.json
Normal file
15
test/e2e/prompt-module/data/commitlint_9/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "commitlint-test",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^9.0.0",
|
||||
"@commitlint/config-conventional": "^9.0.0"
|
||||
}
|
||||
}
|
||||
11
test/e2e/setup.sh
Executable file
11
test/e2e/setup.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
current_dir=$(pwd)
|
||||
setup_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Set up for prompt-module/commitlint
|
||||
cd $setup_dir && cd prompt-module/data/commitlint_9 && npm ci
|
||||
cd $setup_dir && cd prompt-module/data/commitlint_18 && npm ci
|
||||
cd $setup_dir && cd prompt-module/data/commitlint_19 && npm ci
|
||||
|
||||
cd $current_dir
|
||||
33
test/e2e/utils.ts
Normal file
33
test/e2e/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
10
test/jest-setup.ts
Normal file
10
test/jest-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'cli-testing-library/extend-expect'
|
||||
import { configure } from 'cli-testing-library'
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
global.jest = jest;
|
||||
|
||||
/**
|
||||
* 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
105
test/unit/config.test.ts
Normal 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(() => ['ollama', 'gemini'].includes(config!['OCO_AI_PROVIDER'])).toBeTruthy();
|
||||
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(() => ['ollama', 'gemini'].includes(config!['OCO_AI_PROVIDER'])).toBeTruthy();
|
||||
expect(config!['OCO_GITPUSH']).toEqual(false);
|
||||
expect(config!['OCO_ONE_LINE_COMMIT']).toEqual(true);
|
||||
|
||||
await envFile.cleanup();
|
||||
});
|
||||
});
|
||||
105
test/unit/gemini.test.ts
Normal file
105
test/unit/gemini.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Gemini } from '../../src/engine/gemini';
|
||||
import { ChatCompletionRequestMessage } from 'openai';
|
||||
import { GenerativeModel, GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { ConfigType, getConfig } from '../../src/commands/config';
|
||||
|
||||
describe('Gemini', () => {
|
||||
let gemini: Gemini;
|
||||
let mockConfig: ConfigType;
|
||||
let mockGoogleGenerativeAi: GoogleGenerativeAI;
|
||||
let mockGenerativeModel: GenerativeModel;
|
||||
let mockExit: jest.SpyInstance<never, [code?: number | undefined], any>;
|
||||
let mockWarmup: jest.SpyInstance<any, unknown[], any>;
|
||||
|
||||
const noop: (code?: number | undefined) => never = (code?: number | undefined) => {};
|
||||
|
||||
const mockGemini = () => {
|
||||
gemini = new Gemini();
|
||||
}
|
||||
|
||||
const oldEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
process.env = { ...oldEnv };
|
||||
|
||||
jest.mock('@google/generative-ai');
|
||||
jest.mock('../src/commands/config');
|
||||
|
||||
jest.mock('@clack/prompts', () => ({
|
||||
intro: jest.fn(),
|
||||
outro: jest.fn(),
|
||||
}));
|
||||
|
||||
if (mockWarmup) mockWarmup.mockRestore();
|
||||
|
||||
mockExit = jest.spyOn(process, 'exit').mockImplementation();
|
||||
mockConfig = getConfig() as ConfigType;
|
||||
|
||||
mockConfig.OCO_AI_PROVIDER = 'gemini';
|
||||
mockConfig.OCO_GEMINI_API_KEY = 'mock-api-key';
|
||||
mockConfig.OCO_MODEL = 'gemini-1.5-flash';
|
||||
|
||||
mockGoogleGenerativeAi = new GoogleGenerativeAI(mockConfig.OCO_GEMINI_API_KEY);
|
||||
mockGenerativeModel = mockGoogleGenerativeAi.getGenerativeModel({ model: mockConfig.OCO_MODEL, });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gemini = undefined as any;
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
mockExit.mockRestore();
|
||||
process.env = oldEnv;
|
||||
});
|
||||
|
||||
it('should initialize with correct config', () => {
|
||||
mockGemini();
|
||||
// gemini = new Gemini();
|
||||
expect(gemini).toBeDefined();
|
||||
});
|
||||
|
||||
it('should warmup correctly', () => {
|
||||
mockWarmup = jest.spyOn(Gemini.prototype as any, 'warmup').mockImplementation(noop);
|
||||
mockGemini();
|
||||
expect(gemini).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exit process if OCO_GEMINI_API_KEY is not set and command is not config', () => {
|
||||
process.env.OCO_GEMINI_API_KEY = undefined;
|
||||
process.env.OCO_AI_PROVIDER = 'gemini';
|
||||
|
||||
mockGemini();
|
||||
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit process if model is not supported and command is not config', () => {
|
||||
process.env.OCO_GEMINI_API_KEY = undefined;
|
||||
process.env.OCO_AI_PROVIDER = 'gemini';
|
||||
|
||||
mockGemini();
|
||||
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should generate commit message', async () => {
|
||||
const mockGenerateContent = jest.fn().mockResolvedValue({ response: { text: () => 'generated content' } });
|
||||
mockGenerativeModel.generateContent = mockGenerateContent;
|
||||
|
||||
mockWarmup = jest.spyOn(Gemini.prototype as any, 'warmup').mockImplementation(noop);
|
||||
mockGemini();
|
||||
|
||||
const messages: ChatCompletionRequestMessage[] = [
|
||||
{ role: 'system', content: 'system message' },
|
||||
{ role: 'assistant', content: 'assistant message' },
|
||||
];
|
||||
|
||||
jest.spyOn(gemini, 'generateCommitMessage').mockImplementation(async () => 'generated content');
|
||||
const result = await gemini.generateCommitMessage(messages);
|
||||
|
||||
expect(result).toEqual('generated content');
|
||||
expect(mockWarmup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
29
test/unit/utils.ts
Normal file
29
test/unit/utils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ES5", "ES6"],
|
||||
"target": "ES2020",
|
||||
"lib": ["ES6", "ES2020"],
|
||||
|
||||
"module": "ESNext",
|
||||
// "rootDir": "./src",
|
||||
"module": "CommonJS",
|
||||
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "Node",
|
||||
|
||||
"allowJs": true,
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"test/jest-setup.ts"
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
"ts-node": {
|
||||
"esm": true,
|
||||
|
||||
Reference in New Issue
Block a user