mirror of
https://github.com/di-sukharev/opencommit.git
synced 2026-01-13 15:47:58 -05:00
Compare commits
1 Commits
oco_find_v
...
revert-70-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78456c4431 |
@@ -1 +0,0 @@
|
||||
.env
|
||||
@@ -21,8 +21,8 @@
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"no-console": "error",
|
||||
"import/order": "off",
|
||||
"sort-imports": "off",
|
||||
"import/order": "off",
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"import/first": "error",
|
||||
|
||||
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
@@ -9,7 +9,7 @@ Thanks for considering contributing to the project.
|
||||
3. Create a new branch for your changes.
|
||||
4. Make your changes and commit them with descriptive commit messages.
|
||||
5. Push your changes to your forked repository.
|
||||
6. Create a pull request from your branch to the `dev` branch. Not `master` branch, PR to `dev` branch, please.
|
||||
6. Create a pull request from your branch to the `dev` branch.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
BIN
.github/github-mark-white.png
vendored
BIN
.github/github-mark-white.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB |
BIN
.github/opencommit-example.png
vendored
BIN
.github/opencommit-example.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 318 KiB |
28
.github/workflows/stale.yml
vendored
Normal file
28
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
|
||||
#
|
||||
# You can adjust the behavior by modifying this file.
|
||||
# For more information, see:
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '27 21 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 40
|
||||
stale-issue-message: 'Stale issue message'
|
||||
stale-pr-message: 'Stale pull request message'
|
||||
stale-issue-label: 'no-issue-activity'
|
||||
stale-pr-label: 'no-pr-activity'
|
||||
46
.github/workflows/test.yml
vendored
46
.github/workflows/test.yml
vendored
@@ -1,46 +0,0 @@
|
||||
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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
out/
|
||||
temp/
|
||||
build/
|
||||
dist/
|
||||
application.log
|
||||
.DS_Store
|
||||
/*.env
|
||||
@@ -10,7 +12,4 @@ uncaughtExceptions.log
|
||||
.vscode
|
||||
src/*.json
|
||||
.idea
|
||||
test.ts
|
||||
notes.md
|
||||
*.excalidraw
|
||||
*.tldr
|
||||
test.ts
|
||||
@@ -1 +0,0 @@
|
||||
out/github-action.cjs
|
||||
@@ -1 +0,0 @@
|
||||
out
|
||||
@@ -1,3 +1,2 @@
|
||||
/build
|
||||
/dist
|
||||
/out
|
||||
/dist
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Dima Sukharev, https://github.com/di-sukharev
|
||||
Copyright (c) Dima Sukharev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
353
README.md
353
README.md
@@ -2,12 +2,12 @@
|
||||
<div>
|
||||
<img src=".github/logo-grad.svg" alt="OpenCommit logo"/>
|
||||
<h1 align="center">OpenCommit</h1>
|
||||
<h4 align="center">Author <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>
|
||||
<h4 align="center">Follow the bird <a href="https://twitter.com/io_Y_oi"><img src="https://img.shields.io/twitter/follow/io_Y_oi?style=flat&label=io_Y_oi&logo=twitter&color=0bf&logoColor=fff" align="center"></a>
|
||||
</h4>
|
||||
</div>
|
||||
<h2>Auto-generate meaningful commits in a second</h2>
|
||||
<h2>GPT CLI to auto-generate impressive commits in 1 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>
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -16,11 +16,9 @@
|
||||
<img src=".github/opencommit-example.png" alt="OpenCommit example"/>
|
||||
</div>
|
||||
|
||||
All the commits in this repo are authored by OpenCommit — look at [the commits](https://github.com/di-sukharev/opencommit/commit/eae7618d575ee8d2e9fff5de56da79d40c4bc5fc) to see how OpenCommit works. Emojis and long commit descriptions are configurable, basically everything is.
|
||||
All the commits in this repo are done with OpenCommit — look into [the commits](https://github.com/di-sukharev/opencommit/commit/eae7618d575ee8d2e9fff5de56da79d40c4bc5fc) to see how OpenCommit works. Emoji and long commit description text is configurable.
|
||||
|
||||
## Setup OpenCommit as a CLI tool
|
||||
|
||||
You can use OpenCommit by simply running it via the CLI like this `oco`. 2 seconds and your staged changes are committed with a meaningful message.
|
||||
## Setup
|
||||
|
||||
1. Install OpenCommit globally to use in any repository:
|
||||
|
||||
@@ -28,17 +26,15 @@ You can use OpenCommit by simply running it via the CLI like this `oco`. 2 secon
|
||||
npm install -g opencommit
|
||||
```
|
||||
|
||||
Alternatively run it via `npx opencommit` or `bunx opencommit`, but you need to create ~/.opencommit config file in place.
|
||||
|
||||
2. Get your API key from [OpenAI](https://platform.openai.com/account/api-keys). Make sure that you add your payment details, so the API works.
|
||||
2. Get your API key from [OpenAI](https://platform.openai.com/account/api-keys). Make sure you add payment details, so API works.
|
||||
|
||||
3. Set the key to OpenCommit config:
|
||||
|
||||
```sh
|
||||
oco config set OCO_OPENAI_API_KEY=<your_api_key>
|
||||
opencommit config set OPENAI_API_KEY=<your_api_key>
|
||||
```
|
||||
|
||||
Your API key is stored locally in the `~/.opencommit` config file.
|
||||
Your api key is stored locally in `~/.opencommit` config file.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -49,238 +45,70 @@ git add <files...>
|
||||
opencommit
|
||||
```
|
||||
|
||||
You can also use the `oco` shortcut:
|
||||
You can also use the `oc` shortcut:
|
||||
|
||||
```sh
|
||||
git add <files...>
|
||||
oco
|
||||
oc
|
||||
```
|
||||
|
||||
Running `git add` is optional, `oco` will do it for you.
|
||||
## Features
|
||||
|
||||
### Running locally with Ollama
|
||||
### Preface commits with emoji 🤠
|
||||
|
||||
You can also run it with local model through ollama:
|
||||
[GitMoji](https://gitmoji.dev/) convention is used.
|
||||
|
||||
- install and start ollama
|
||||
- run `ollama run mistral` (do this only once, to pull model)
|
||||
- run (in your project directory):
|
||||
To add emoji:
|
||||
|
||||
```sh
|
||||
git add <files...>
|
||||
oco config set OCO_AI_PROVIDER='ollama'
|
||||
oc config set emoji=true
|
||||
```
|
||||
|
||||
If you want to use a model other than mistral (default), you can do so by setting the `OCO_AI_PROVIDER` environment variable as follows:
|
||||
To remove emoji:
|
||||
|
||||
```sh
|
||||
oco config set OCO_AI_PROVIDER='ollama/llama3:8b'
|
||||
oc config set emoji=false
|
||||
```
|
||||
|
||||
If you have ollama that is set up in docker/ on another machine with GPUs (not locally), you can change the default endpoint url.
|
||||
### Postface commits with descriptions of changes
|
||||
|
||||
You can do so by setting the `OCO_OLLAMA_API_URL` environment variable as follows:
|
||||
To add descriptions:
|
||||
|
||||
```sh
|
||||
oco config set OCO_OLLAMA_API_URL='http://192.168.1.10:11434/api/chat'
|
||||
oc config set description=true
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Create a `.env` file and add OpenCommit config variables there like this:
|
||||
|
||||
```env
|
||||
...
|
||||
OCO_OPENAI_API_KEY=<your OpenAI API token>
|
||||
OCO_TOKENS_MAX_INPUT=<max model token limit (default: 4096)>
|
||||
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-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' or any string basically, but it should be a valid model name>
|
||||
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=<openai (default), anthropic, azure, ollama or ollama/model>
|
||||
...
|
||||
```
|
||||
|
||||
This are not all the config options, but you get the point.
|
||||
|
||||
### Global config for all repos
|
||||
|
||||
Local config still has more priority than Global config, but you may set `OCO_MODEL` and `OCO_LOCALE` globally and set local configs for `OCO_EMOJI` and `OCO_DESCRIPTION` per repo which is more convenient.
|
||||
|
||||
Simply set any of the variables above like this:
|
||||
To remove description:
|
||||
|
||||
```sh
|
||||
oco config set OCO_MODEL=gpt-4o-mini
|
||||
oc config set description=false
|
||||
```
|
||||
|
||||
Configure [GitMoji](https://gitmoji.dev/) to preface a message.
|
||||
### Internationalization support
|
||||
|
||||
```sh
|
||||
oco config set OCO_EMOJI=true
|
||||
```
|
||||
|
||||
To remove preface emojis:
|
||||
|
||||
```sh
|
||||
oco config set OCO_EMOJI=false
|
||||
```
|
||||
|
||||
Other config options are behaving the same.
|
||||
|
||||
### Output WHY the changes were done (WIP)
|
||||
|
||||
You can set the `OCO_WHY` config to `true` to have OpenCommit output a short description of WHY the changes were done after the commit message. Default is `false`.
|
||||
|
||||
To make this perform accurate we must store 'what files do' in some kind of an index or embedding and perform a lookup (kinda RAG) for the accurate git commit message. If you feel like building this comment on this ticket https://github.com/di-sukharev/opencommit/issues/398 and let's go from there together.
|
||||
|
||||
```sh
|
||||
oco config set OCO_WHY=true
|
||||
```
|
||||
|
||||
### Switch to GPT-4 or other models
|
||||
|
||||
By default, OpenCommit uses `gpt-4o-mini` model.
|
||||
|
||||
You may switch to gpt-4o which performs better, but costs more 🤠
|
||||
|
||||
```sh
|
||||
oco config set OCO_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
or for as a cheaper option:
|
||||
|
||||
```sh
|
||||
oco config set OCO_MODEL=gpt-3.5-turbo
|
||||
```
|
||||
|
||||
### 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
|
||||
opencommit config set OCO_AI_PROVIDER=azure
|
||||
```
|
||||
|
||||
Of course need to set 'OCO_OPENAI_API_KEY'. And also need to set the
|
||||
'OPENAI_BASE_PATH' for the endpoint and set the deployment name to
|
||||
'model'.
|
||||
|
||||
### Locale configuration
|
||||
|
||||
To globally specify the language used to generate commit messages:
|
||||
To specify the language used to generate commit messages:
|
||||
|
||||
```sh
|
||||
# de, German ,Deutsch
|
||||
oco config set OCO_LANGUAGE=de
|
||||
oco config set OCO_LANGUAGE=German
|
||||
oco config set OCO_LANGUAGE=Deutsch
|
||||
oc config set language=de
|
||||
oc config set language=German
|
||||
oc config set language=Deutsch
|
||||
|
||||
# fr, French, française
|
||||
oco config set OCO_LANGUAGE=fr
|
||||
oco config set OCO_LANGUAGE=French
|
||||
oco config set OCO_LANGUAGE=française
|
||||
oc config set language=fr
|
||||
oc config set language=French
|
||||
oc config set language=française
|
||||
```
|
||||
|
||||
The default language setting is **English**
|
||||
The default language set 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 (gonna be deprecated)
|
||||
### Git flags
|
||||
|
||||
A prompt to ushing to git is on by default but if you would like to turn it off just use:
|
||||
The `opencommit` or `oc` commands can be used in place of the `git commit -m "${generatedMessage}"` command. This means that any regular flags that are used with the `git commit` command will also be applied when using `opencommit` or `oc`.
|
||||
|
||||
```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.
|
||||
|
||||
You can set this option by running the following command:
|
||||
|
||||
```sh
|
||||
oco config set OCO_PROMPT_MODULE=<module>
|
||||
```
|
||||
|
||||
Replace `<module>` with either `conventional-commit` or `@commitlint`.
|
||||
|
||||
#### Example:
|
||||
|
||||
To switch to using the `'@commitlint` prompt module, run:
|
||||
|
||||
```sh
|
||||
oco config set OCO_PROMPT_MODULE=@commitlint
|
||||
```
|
||||
|
||||
To switch back to the default conventional-commit message generator, run:
|
||||
|
||||
```sh
|
||||
oco config set OCO_PROMPT_MODULE=conventional-commit
|
||||
```
|
||||
|
||||
#### Integrating with `@commitlint`
|
||||
|
||||
The integration between `@commitlint` and OpenCommit is done automatically the first time OpenCommit is run with `OCO_PROMPT_MODULE` set to `@commitlint`. However, if you need to force set or reset the configuration for `@commitlint`, you can run the following command:
|
||||
|
||||
```sh
|
||||
oco commitlint force
|
||||
```
|
||||
|
||||
To view the generated configuration for `@commitlint`, you can use this command:
|
||||
|
||||
```sh
|
||||
oco commitlint get
|
||||
```
|
||||
|
||||
This allows you to ensure that the configuration is set up as desired.
|
||||
|
||||
Additionally, the integration creates a file named `.opencommit-commitlint` which contains the prompts used for the local `@commitlint` configuration. You can modify this file to fine-tune the example commit message generated by OpenAI. This gives you the flexibility to make adjustments based on your preferences or project guidelines.
|
||||
|
||||
OpenCommit generates a file named `.opencommit-commitlint` in your project directory which contains the prompts used for the local `@commitlint` configuration. You can modify this file to fine-tune the example commit message generated by OpenAI. If the local `@commitlint` configuration changes, this file will be updated the next time OpenCommit is run.
|
||||
|
||||
This offers you greater control over the generated commit messages, allowing for customization that aligns with your project's conventions.
|
||||
|
||||
## Git flags
|
||||
|
||||
The `opencommit` or `oco` commands can be used in place of the `git commit -m "${generatedMessage}"` command. This means that any regular flags that are used with the `git commit` command will also be applied when using `opencommit` or `oco`.
|
||||
|
||||
```sh
|
||||
oco --no-verify
|
||||
oc --no-verify
|
||||
```
|
||||
|
||||
is translated to :
|
||||
@@ -289,68 +117,33 @@ is translated to :
|
||||
git commit -m "${generatedMessage}" --no-verify
|
||||
```
|
||||
|
||||
To include a message in the generated message, you can utilize the template function, for instance:
|
||||
|
||||
```sh
|
||||
oco '#205: $msg’
|
||||
```
|
||||
|
||||
> opencommit examines placeholders in the parameters, allowing you to append additional information before and after the placeholders, such as the relevant Issue or Pull Request. Similarly, you have the option to customize the OCO_MESSAGE_TEMPLATE_PLACEHOLDER configuration item, for example, simplifying it to $m!"
|
||||
|
||||
### Message Template Placeholder Config
|
||||
|
||||
#### Overview
|
||||
|
||||
The `OCO_MESSAGE_TEMPLATE_PLACEHOLDER` feature in the `opencommit` tool allows users to embed a custom message within the generated commit message using a template function. This configuration is designed to enhance the flexibility and customizability of commit messages, making it easier for users to include relevant information directly within their commits.
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
In our codebase, the implementation of this feature can be found in the following segment:
|
||||
|
||||
```javascript
|
||||
commitMessage = messageTemplate.replace(
|
||||
config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
|
||||
commitMessage
|
||||
);
|
||||
```
|
||||
|
||||
This line is responsible for replacing the placeholder in the `messageTemplate` with the actual `commitMessage`.
|
||||
|
||||
#### Usage
|
||||
|
||||
For instance, using the command `oco '$msg #205’`, users can leverage this feature. The provided code represents the backend mechanics of such commands, ensuring that the placeholder is replaced with the appropriate commit message.
|
||||
|
||||
#### Committing with the Message
|
||||
|
||||
Once users have generated their desired commit message, they can proceed to commit using the generated message. By understanding the feature's full potential and its implementation details, users can confidently use the generated messages for their commits.
|
||||
|
||||
### Ignore files
|
||||
|
||||
You can remove files from being sent to OpenAI by creating a `.opencommitignore` file. For example:
|
||||
You can ignore files from submission to OpenAI by creating a `.opencommitignore` file. For example:
|
||||
|
||||
```ignorelang
|
||||
path/to/large-asset.zip
|
||||
**/*.jpg
|
||||
```
|
||||
|
||||
This helps prevent opencommit from uploading artifacts and large files.
|
||||
This is useful for preventing opencommit from uploading artifacts and large files.
|
||||
|
||||
By default, opencommit ignores files matching: `*-lock.*` and `*.lock`
|
||||
|
||||
## Git hook (KILLER FEATURE)
|
||||
## Git hook
|
||||
|
||||
You can set OpenCommit as Git [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) hook. Hook integrates with your IDE Source Control and allows you to edit the message before committing.
|
||||
You can set OpenCommit as Git [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) hook. Hook integrates with you IDE Source Control and allows you edit the message before commit.
|
||||
|
||||
To set the hook:
|
||||
|
||||
```sh
|
||||
oco hook set
|
||||
oc hook set
|
||||
```
|
||||
|
||||
To unset the hook:
|
||||
|
||||
```sh
|
||||
oco hook unset
|
||||
oc hook unset
|
||||
```
|
||||
|
||||
To use the hook:
|
||||
@@ -362,70 +155,6 @@ git commit
|
||||
|
||||
Or follow the process of your IDE Source Control feature, when it calls `git commit` command — OpenCommit will integrate into the flow.
|
||||
|
||||
## Setup OpenCommit as a GitHub Action (BETA) 🔥
|
||||
|
||||
OpenCommit is now available as a GitHub Action which automatically improves all new commits messages when you push to remote!
|
||||
|
||||
This is great if you want to make sure all commits in all of your repository branches are meaningful and not lame like `fix1` or `done2`.
|
||||
|
||||
Create a file `.github/workflows/opencommit.yml` with the contents below:
|
||||
|
||||
```yml
|
||||
name: 'OpenCommit Action'
|
||||
|
||||
on:
|
||||
push:
|
||||
# this list of branches is often enough,
|
||||
# but you may still ignore other public branches
|
||||
branches-ignore: [main master dev development release]
|
||||
|
||||
jobs:
|
||||
opencommit:
|
||||
timeout-minutes: 10
|
||||
name: OpenCommit
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Setup Node.js Environment
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: di-sukharev/opencommit@github-action-v1.0.4
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
env:
|
||||
# set openAI api key in repo actions secrets,
|
||||
# for openAI keys go to: https://platform.openai.com/account/api-keys
|
||||
# for repo secret go to: <your_repo_url>/settings/secrets/actions
|
||||
OCO_OPENAI_API_KEY: ${{ secrets.OCO_OPENAI_API_KEY }}
|
||||
|
||||
# customization
|
||||
OCO_TOKENS_MAX_INPUT: 4096
|
||||
OCO_TOKENS_MAX_OUTPUT: 500
|
||||
OCO_OPENAI_BASE_PATH: ''
|
||||
OCO_DESCRIPTION: false
|
||||
OCO_EMOJI: false
|
||||
OCO_MODEL: gpt-4o
|
||||
OCO_LANGUAGE: en
|
||||
OCO_PROMPT_MODULE: conventional-commit
|
||||
```
|
||||
|
||||
That is it. Now when you push to any branch in your repo — all NEW commits are being improved by your never-tired AI.
|
||||
|
||||
Make sure you exclude public collaboration branches (`main`, `dev`, `etc`) in `branches-ignore`, so OpenCommit does not rebase commits there while improving the messages.
|
||||
|
||||
Interactive rebase (`rebase -i`) changes commits' SHA, so the commit history in remote becomes different from your local branch history. This is okay if you work on the branch alone, but may be inconvenient for other collaborators.
|
||||
|
||||
## Payments
|
||||
|
||||
You pay for your requests to OpenAI API on your own.
|
||||
|
||||
OpenCommit stores your key locally.
|
||||
|
||||
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.
|
||||
You pay for your own requests to OpenAI API. OpenCommit uses ChatGPT (3.5-turbo) official model, that is ~15x times cheaper than GPT-4.
|
||||
|
||||
29
action.yml
29
action.yml
@@ -1,29 +0,0 @@
|
||||
name: 'OpenCommit — improve commits with AI 🧙'
|
||||
description: 'Replaces lame commit messages with meaningful AI-generated messages when you push to remote'
|
||||
author: 'https://github.com/di-sukharev'
|
||||
repo: 'https://github.com/di-sukharev/opencommit/tree/github-action'
|
||||
branding:
|
||||
icon: 'git-commit'
|
||||
color: 'green'
|
||||
keywords:
|
||||
[
|
||||
'git',
|
||||
'chatgpt',
|
||||
'gpt',
|
||||
'ai',
|
||||
'openai',
|
||||
'opencommit',
|
||||
'aicommit',
|
||||
'aicommits',
|
||||
'gptcommit',
|
||||
'commit'
|
||||
]
|
||||
|
||||
inputs:
|
||||
GITHUB_TOKEN:
|
||||
description: 'GitHub token'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'node16'
|
||||
main: 'out/github-action.cjs'
|
||||
@@ -1,24 +1,14 @@
|
||||
import { build } from 'esbuild';
|
||||
import fs from 'fs';
|
||||
import { build } from 'esbuild'
|
||||
import fs from 'fs'
|
||||
|
||||
await build({
|
||||
entryPoints: ['./src/cli.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile: './out/cli.cjs'
|
||||
entryPoints: ['./src/cli.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile: './out/cli.cjs',
|
||||
});
|
||||
|
||||
await build({
|
||||
entryPoints: ['./src/github-action.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile: './out/github-action.cjs'
|
||||
});
|
||||
const wasmFile = fs.readFileSync('./node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm')
|
||||
|
||||
const wasmFile = fs.readFileSync(
|
||||
'./node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm'
|
||||
);
|
||||
|
||||
fs.writeFileSync('./out/tiktoken_bg.wasm', wasmFile);
|
||||
fs.writeFileSync('./out/tiktoken_bg.wasm', wasmFile)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
43887
out/cli.cjs
43887
out/cli.cjs
File diff suppressed because one or more lines are too long
62074
out/github-action.cjs
62074
out/github-action.cjs
File diff suppressed because one or more lines are too long
Binary file not shown.
7128
package-lock.json
generated
7128
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opencommit",
|
||||
"version": "3.1.1",
|
||||
"description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
|
||||
"version": "2.0.1",
|
||||
"description": "GPT CLI to auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
|
||||
"keywords": [
|
||||
"git",
|
||||
"chatgpt",
|
||||
@@ -12,12 +12,12 @@
|
||||
"aicommit",
|
||||
"aicommits",
|
||||
"gptcommit",
|
||||
"commit",
|
||||
"ollama"
|
||||
"commit"
|
||||
],
|
||||
"main": "cli.js",
|
||||
"bin": {
|
||||
"opencommit": "./out/cli.cjs",
|
||||
"oc": "./out/cli.cjs",
|
||||
"oco": "./out/cli.cjs"
|
||||
},
|
||||
"repository": {
|
||||
@@ -27,8 +27,7 @@
|
||||
"author": "https://github.com/di-sukharev",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"out/cli.cjs",
|
||||
"out/tiktoken_bg.wasm"
|
||||
"out/**/*"
|
||||
],
|
||||
"release": {
|
||||
"branches": [
|
||||
@@ -41,62 +40,35 @@
|
||||
"scripts": {
|
||||
"watch": "npm run -S build -- --sourcemap --watch",
|
||||
"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 run build:push && git push --tags && npm publish --tag latest",
|
||||
"deploy:patch": "npm version patch && npm run deploy",
|
||||
"deploy": "npm run build && npm version patch && npm publish --tag latest",
|
||||
"lint": "eslint src --ext ts && tsc --noEmit",
|
||||
"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"
|
||||
"format": "prettier --write src"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^5.1.1",
|
||||
"@anthropic-ai/sdk": "^0.19.2",
|
||||
"@azure/openai": "^1.0.0-beta.12",
|
||||
"@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",
|
||||
"axios": "^1.3.4",
|
||||
"chalk": "^5.2.0",
|
||||
"cleye": "^1.3.2",
|
||||
"crypto": "^1.0.1",
|
||||
"execa": "^7.0.0",
|
||||
"ignore": "^5.2.4",
|
||||
"ini": "^3.0.1",
|
||||
"inquirer": "^9.1.4",
|
||||
"openai": "^4.56.0"
|
||||
"openai": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
4
src/CommandsEnum.ts
Normal file
4
src/CommandsEnum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum COMMANDS {
|
||||
hook = 'hook',
|
||||
config = 'config'
|
||||
}
|
||||
94
src/api.ts
Normal file
94
src/api.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { intro, outro } from '@clack/prompts';
|
||||
import axios from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
ChatCompletionRequestMessage,
|
||||
Configuration as OpenAiApiConfiguration,
|
||||
OpenAIApi
|
||||
} from 'openai';
|
||||
|
||||
import { CONFIG_MODES, getConfig } from './commands/config';
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
let apiKey = config?.OPENAI_API_KEY;
|
||||
let basePath = config?.OPENAI_BASE_PATH;
|
||||
|
||||
const [command, mode] = process.argv.slice(2);
|
||||
|
||||
if (!apiKey && command !== 'config' && mode !== CONFIG_MODES.set) {
|
||||
intro('opencommit');
|
||||
|
||||
outro(
|
||||
'OPENAI_API_KEY is not set, please run `oc config set OPENAI_API_KEY=<your token>. 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);
|
||||
}
|
||||
|
||||
class OpenAi {
|
||||
private openAiApiConfiguration = new OpenAiApiConfiguration({
|
||||
apiKey: apiKey
|
||||
});
|
||||
private openAI!: OpenAIApi;
|
||||
|
||||
constructor() {
|
||||
if (basePath) {
|
||||
this.openAiApiConfiguration.basePath = basePath;
|
||||
}
|
||||
this.openAI = new OpenAIApi(this.openAiApiConfiguration);
|
||||
}
|
||||
|
||||
public generateCommitMessage = async (
|
||||
messages: Array<ChatCompletionRequestMessage>
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await this.openAI.createChatCompletion({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages,
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: 196
|
||||
});
|
||||
|
||||
const message = data.choices[0].message;
|
||||
|
||||
return message?.content;
|
||||
} catch (error: unknown) {
|
||||
outro(`${chalk.red('✖')} ${error}`);
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getOpenCommitLatestVersion = async (): Promise<
|
||||
string | undefined
|
||||
> => {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
'https://unpkg.com/opencommit/package.json'
|
||||
);
|
||||
return data.version;
|
||||
} catch (_) {
|
||||
outro('Error while getting the latest version of opencommit');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const api = new OpenAi();
|
||||
31
src/cli.ts
31
src/cli.ts
@@ -1,15 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { cli } from 'cleye';
|
||||
import packageJSON from '../package.json' assert { type: 'json' };
|
||||
|
||||
import packageJSON from '../package.json';
|
||||
import { commit } from './commands/commit';
|
||||
import { commitlintConfigCommand } from './commands/commitlint';
|
||||
import { configCommand } from './commands/config';
|
||||
import { hookCommand, isHookCalled } from './commands/githook.js';
|
||||
import { prepareCommitMessageHook } from './commands/prepare-commit-msg-hook';
|
||||
import { checkIsLatestVersion } from './utils/checkIsLatestVersion';
|
||||
import { findCommand } from './commands/find';
|
||||
import { commit } from './commands/commit';
|
||||
// import { checkIsLatestVersion } from './utils/checkIsLatestVersion';
|
||||
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
@@ -17,31 +15,18 @@ cli(
|
||||
{
|
||||
version: packageJSON.version,
|
||||
name: 'opencommit',
|
||||
commands: [
|
||||
configCommand,
|
||||
hookCommand,
|
||||
commitlintConfigCommand,
|
||||
findCommand
|
||||
],
|
||||
flags: {
|
||||
fgm: Boolean,
|
||||
yes: {
|
||||
type: Boolean,
|
||||
alias: 'y',
|
||||
description: 'Skip commit confirmation prompt',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
commands: [configCommand, hookCommand],
|
||||
flags: {},
|
||||
ignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',
|
||||
help: { description: packageJSON.description }
|
||||
},
|
||||
async ({ flags }) => {
|
||||
await checkIsLatestVersion();
|
||||
async () => {
|
||||
// await checkIsLatestVersion();
|
||||
|
||||
if (await isHookCalled()) {
|
||||
prepareCommitMessageHook();
|
||||
} else {
|
||||
commit(extraArgs, false, flags.fgm, flags.yes);
|
||||
commit(extraArgs);
|
||||
}
|
||||
},
|
||||
extraArgs
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum COMMANDS {
|
||||
config = 'config',
|
||||
hook = 'hook',
|
||||
commitlint = 'commitlint',
|
||||
find = 'find'
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# @commitlint Module for opencommit
|
||||
|
||||
1. Load commitlint configuration within tree.
|
||||
2. Generate a commit with commitlint prompt:
|
||||
- Will not run if hash is the same.
|
||||
- Infer a prompt for each commitlint rule.
|
||||
- Ask OpenAI to generate consistency with embedded commitlint rules.
|
||||
- Store configuration close to commitlint configuration.
|
||||
3. Replace conventional-commit prompt with commitlint prompt.
|
||||
@@ -1,15 +1,8 @@
|
||||
import {
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
multiselect,
|
||||
outro,
|
||||
select,
|
||||
spinner
|
||||
} from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { execa } from 'execa';
|
||||
import { generateCommitMessageByDiff } from '../generateCommitMessageFromGitDiff';
|
||||
import {
|
||||
GenerateCommitMessageErrorEnum,
|
||||
generateCommitMessageWithChatCompletion
|
||||
} from '../generateCommitMessageFromGitDiff';
|
||||
import {
|
||||
assertGitRepo,
|
||||
getChangedFiles,
|
||||
@@ -17,189 +10,141 @@ import {
|
||||
getStagedFiles,
|
||||
gitAdd
|
||||
} from '../utils/git';
|
||||
import {
|
||||
spinner,
|
||||
confirm,
|
||||
outro,
|
||||
isCancel,
|
||||
intro,
|
||||
multiselect,
|
||||
select
|
||||
} from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { trytm } from '../utils/trytm';
|
||||
import { getConfig } from './config';
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
const getGitRemotes = async () => {
|
||||
const { stdout } = await execa('git', ['remote']);
|
||||
return stdout.split('\n').filter((remote) => Boolean(remote.trim()));
|
||||
};
|
||||
|
||||
// Check for the presence of message templates
|
||||
const checkMessageTemplate = (extraArgs: string[]): string | false => {
|
||||
for (const key in extraArgs) {
|
||||
if (extraArgs[key].includes(config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER))
|
||||
return extraArgs[key];
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
interface GenerateCommitMessageFromGitDiffParams {
|
||||
diff: string;
|
||||
extraArgs: string[];
|
||||
fullGitMojiSpec?: boolean;
|
||||
skipCommitConfirmation?: boolean;
|
||||
}
|
||||
|
||||
const generateCommitMessageFromGitDiff = async ({
|
||||
diff,
|
||||
extraArgs,
|
||||
fullGitMojiSpec = false,
|
||||
skipCommitConfirmation = false
|
||||
}: GenerateCommitMessageFromGitDiffParams): Promise<void> => {
|
||||
const generateCommitMessageFromGitDiff = async (
|
||||
diff: string,
|
||||
extraArgs: string[]
|
||||
): Promise<void> => {
|
||||
await assertGitRepo();
|
||||
const commitGenerationSpinner = spinner();
|
||||
commitGenerationSpinner.start('Generating the commit message');
|
||||
|
||||
try {
|
||||
let commitMessage = await generateCommitMessageByDiff(
|
||||
diff,
|
||||
fullGitMojiSpec
|
||||
);
|
||||
const commitSpinner = spinner();
|
||||
commitSpinner.start('Generating the commit message');
|
||||
const commitMessage = await generateCommitMessageWithChatCompletion(diff);
|
||||
|
||||
const messageTemplate = checkMessageTemplate(extraArgs);
|
||||
if (
|
||||
config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER &&
|
||||
typeof messageTemplate === 'string'
|
||||
) {
|
||||
const messageTemplateIndex = extraArgs.indexOf(messageTemplate);
|
||||
extraArgs.splice(messageTemplateIndex, 1);
|
||||
// TODO: show proper error messages
|
||||
if (typeof commitMessage !== 'string') {
|
||||
const errorMessages = {
|
||||
[GenerateCommitMessageErrorEnum.emptyMessage]:
|
||||
'empty openAI response, weird, try again',
|
||||
[GenerateCommitMessageErrorEnum.internalError]:
|
||||
'internal error, try again',
|
||||
[GenerateCommitMessageErrorEnum.tooMuchTokens]:
|
||||
'too much tokens in git diff, stage and commit files in parts'
|
||||
};
|
||||
|
||||
commitMessage = messageTemplate.replace(
|
||||
config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
|
||||
commitMessage
|
||||
);
|
||||
}
|
||||
outro(`${chalk.red('✖')} ${errorMessages[commitMessage.error]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
commitGenerationSpinner.stop('📝 Commit message generated');
|
||||
commitSpinner.stop('📝 Commit message generated');
|
||||
|
||||
outro(
|
||||
`Generated commit message:
|
||||
outro(
|
||||
`Commit message:
|
||||
${chalk.grey('——————————————————')}
|
||||
${commitMessage}
|
||||
${chalk.grey('——————————————————')}`
|
||||
);
|
||||
);
|
||||
|
||||
const isCommitConfirmedByUser =
|
||||
skipCommitConfirmation ||
|
||||
(await confirm({
|
||||
message: 'Confirm the commit message?'
|
||||
}));
|
||||
const isCommitConfirmedByUser = await confirm({
|
||||
message: 'Confirm the commit message?'
|
||||
});
|
||||
|
||||
if (isCancel(isCommitConfirmedByUser)) process.exit(1);
|
||||
if (isCommitConfirmedByUser && !isCancel(isCommitConfirmedByUser)) {
|
||||
const { stdout } = await execa('git', [
|
||||
'commit',
|
||||
'-m',
|
||||
commitMessage,
|
||||
...extraArgs
|
||||
]);
|
||||
|
||||
if (isCommitConfirmedByUser) {
|
||||
const committingChangesSpinner = spinner();
|
||||
committingChangesSpinner.start('Committing the changes');
|
||||
const { stdout } = await execa('git', [
|
||||
'commit',
|
||||
'-m',
|
||||
commitMessage,
|
||||
...extraArgs
|
||||
]);
|
||||
committingChangesSpinner.stop(
|
||||
`${chalk.green('✔')} Successfully committed`
|
||||
);
|
||||
outro(`${chalk.green('✔')} successfully committed`);
|
||||
|
||||
outro(stdout);
|
||||
outro(stdout);
|
||||
|
||||
const remotes = await getGitRemotes();
|
||||
const remotes = await getGitRemotes();
|
||||
|
||||
if (!remotes.length) {
|
||||
const { stdout } = await execa('git', ['push']);
|
||||
if (stdout) outro(stdout);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!remotes.length) {
|
||||
const { stdout } = await execa('git', ['push']);
|
||||
if (stdout) outro(stdout);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (remotes.length === 1 && config.OCO_GITPUSH !== true) {
|
||||
const isPushConfirmedByUser = await confirm({
|
||||
message: 'Do you want to run `git push`?'
|
||||
});
|
||||
|
||||
if (isCancel(isPushConfirmedByUser)) process.exit(1);
|
||||
|
||||
if (isPushConfirmedByUser) {
|
||||
const pushSpinner = spinner();
|
||||
|
||||
pushSpinner.start(`Running 'git push ${remotes[0]}'`);
|
||||
|
||||
const { stdout } = await execa('git', [
|
||||
'push',
|
||||
'--verbose',
|
||||
remotes[0]
|
||||
]);
|
||||
|
||||
pushSpinner.stop(
|
||||
`${chalk.green('✔')} Successfully pushed all commits to ${
|
||||
remotes[0]
|
||||
}`
|
||||
);
|
||||
|
||||
if (stdout) outro(stdout);
|
||||
} else {
|
||||
outro('`git push` aborted');
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
const selectedRemote = (await select({
|
||||
message: 'Choose a remote to push to',
|
||||
options: remotes.map((remote) => ({ value: remote, label: remote }))
|
||||
})) as string;
|
||||
|
||||
if (isCancel(selectedRemote)) process.exit(1);
|
||||
if (remotes.length === 1) {
|
||||
const isPushConfirmedByUser = await confirm({
|
||||
message: 'Do you want to run `git push`?'
|
||||
});
|
||||
|
||||
if (isPushConfirmedByUser && !isCancel(isPushConfirmedByUser)) {
|
||||
const pushSpinner = spinner();
|
||||
|
||||
pushSpinner.start(`Running 'git push ${selectedRemote}'`);
|
||||
pushSpinner.start(`Running \`git push ${remotes[0]}\``);
|
||||
|
||||
const { stdout } = await execa('git', [
|
||||
'push',
|
||||
'--verbose',
|
||||
remotes[0]
|
||||
]);
|
||||
|
||||
pushSpinner.stop(
|
||||
`${chalk.green('✔')} successfully pushed all commits to ${remotes[0]}`
|
||||
);
|
||||
|
||||
if (stdout) outro(stdout);
|
||||
} else {
|
||||
outro('`git push` aborted');
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
const selectedRemote = (await select({
|
||||
message: 'Choose a remote to push to',
|
||||
options: remotes.map((remote) => ({ value: remote, label: remote }))
|
||||
})) as string;
|
||||
|
||||
if (!isCancel(selectedRemote)) {
|
||||
const pushSpinner = spinner();
|
||||
|
||||
pushSpinner.start(`Running \`git push ${selectedRemote}\``);
|
||||
|
||||
const { stdout } = await execa('git', ['push', selectedRemote]);
|
||||
|
||||
pushSpinner.stop(
|
||||
`${chalk.green(
|
||||
'✔'
|
||||
)} Successfully pushed all commits to ${selectedRemote}`
|
||||
)} successfully pushed all commits to ${selectedRemote}`
|
||||
);
|
||||
|
||||
if (stdout) outro(stdout);
|
||||
}
|
||||
} else {
|
||||
const regenerateMessage = await confirm({
|
||||
message: 'Do you want to regenerate the message?'
|
||||
});
|
||||
|
||||
if (isCancel(regenerateMessage)) process.exit(1);
|
||||
|
||||
if (regenerateMessage) {
|
||||
await generateCommitMessageFromGitDiff({
|
||||
diff,
|
||||
extraArgs,
|
||||
fullGitMojiSpec
|
||||
});
|
||||
}
|
||||
} else outro(`${chalk.gray('✖')} process cancelled`);
|
||||
}
|
||||
} catch (error) {
|
||||
commitGenerationSpinner.stop('📝 Commit message generated');
|
||||
|
||||
const err = error as Error;
|
||||
outro(`${chalk.red('✖')} ${err?.message || err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
export async function commit(
|
||||
extraArgs: string[] = [],
|
||||
isStageAllFlag: Boolean = false,
|
||||
fullGitMojiSpec: boolean = false,
|
||||
skipCommitConfirmation: boolean = false
|
||||
isStageAllFlag: Boolean = false
|
||||
) {
|
||||
if (isStageAllFlag) {
|
||||
const changedFiles = await getChangedFiles();
|
||||
|
||||
if (changedFiles) await gitAdd({ files: changedFiles });
|
||||
else {
|
||||
outro('No changes detected, write some code and run `oco` again');
|
||||
outro('No changes detected, write some code and run `oc` again');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -228,10 +173,11 @@ export async function commit(
|
||||
message: 'Do you want to stage all files and generate commit message?'
|
||||
});
|
||||
|
||||
if (isCancel(isStageAllAndCommitConfirmedByUser)) process.exit(1);
|
||||
|
||||
if (isStageAllAndCommitConfirmedByUser) {
|
||||
await commit(extraArgs, true, fullGitMojiSpec);
|
||||
if (
|
||||
isStageAllAndCommitConfirmedByUser &&
|
||||
!isCancel(isStageAllAndCommitConfirmedByUser)
|
||||
) {
|
||||
await commit(extraArgs, true);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -249,7 +195,7 @@ export async function commit(
|
||||
await gitAdd({ files });
|
||||
}
|
||||
|
||||
await commit(extraArgs, false, fullGitMojiSpec);
|
||||
await commit(extraArgs, false);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -260,12 +206,10 @@ export async function commit(
|
||||
);
|
||||
|
||||
const [, generateCommitError] = await trytm(
|
||||
generateCommitMessageFromGitDiff({
|
||||
diff: await getDiff({ files: stagedFiles }),
|
||||
extraArgs,
|
||||
fullGitMojiSpec,
|
||||
skipCommitConfirmation
|
||||
})
|
||||
generateCommitMessageFromGitDiff(
|
||||
await getDiff({ files: stagedFiles }),
|
||||
extraArgs
|
||||
)
|
||||
);
|
||||
|
||||
if (generateCommitError) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { intro, outro } from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { command } from 'cleye';
|
||||
import { configureCommitlintIntegration } from '../modules/commitlint/config';
|
||||
import { getCommitlintLLMConfig } from '../modules/commitlint/utils';
|
||||
import { COMMANDS } from './ENUMS';
|
||||
|
||||
export enum CONFIG_MODES {
|
||||
get = 'get',
|
||||
force = 'force'
|
||||
}
|
||||
|
||||
export const commitlintConfigCommand = command(
|
||||
{
|
||||
name: COMMANDS.commitlint,
|
||||
parameters: ['<mode>']
|
||||
},
|
||||
async (argv) => {
|
||||
intro('OpenCommit — configure @commitlint');
|
||||
try {
|
||||
const { mode } = argv._;
|
||||
|
||||
if (mode === CONFIG_MODES.get) {
|
||||
const commitLintConfig = await getCommitlintLLMConfig();
|
||||
|
||||
outro(JSON.stringify(commitLintConfig, null, 2));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === CONFIG_MODES.force) {
|
||||
await configureCommitlintIntegration(true);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported mode: ${mode}. Valid modes are: "force" and "get"`
|
||||
);
|
||||
} catch (error) {
|
||||
outro(`${chalk.red('✖')} ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,40 +1,19 @@
|
||||
import { command } from 'cleye';
|
||||
import { join as pathJoin } from 'path';
|
||||
import { parse as iniParse, stringify as iniStringify } from 'ini';
|
||||
import { existsSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { intro, outro } from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { command } from 'cleye';
|
||||
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, resolve as pathResolve } from 'path';
|
||||
import { COMMANDS } from './ENUMS';
|
||||
import { TEST_MOCK_TYPES } from '../engine/testAi';
|
||||
import { getI18nLocal, i18n } from '../i18n';
|
||||
import { COMMANDS } from '../CommandsEnum';
|
||||
import { getI18nLocal } from '../i18n';
|
||||
|
||||
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',
|
||||
OCO_DESCRIPTION = 'OCO_DESCRIPTION',
|
||||
OCO_EMOJI = 'OCO_EMOJI',
|
||||
OCO_MODEL = 'OCO_MODEL',
|
||||
OCO_LANGUAGE = 'OCO_LANGUAGE',
|
||||
OCO_WHY = 'OCO_WHY',
|
||||
OCO_MESSAGE_TEMPLATE_PLACEHOLDER = 'OCO_MESSAGE_TEMPLATE_PLACEHOLDER',
|
||||
OCO_PROMPT_MODULE = 'OCO_PROMPT_MODULE',
|
||||
OCO_AI_PROVIDER = 'OCO_AI_PROVIDER',
|
||||
OCO_GITPUSH = 'OCO_GITPUSH', // todo: deprecate
|
||||
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',
|
||||
OCO_FLOWISE_ENDPOINT = 'OCO_FLOWISE_ENDPOINT',
|
||||
OCO_FLOWISE_API_KEY = 'OCO_FLOWISE_API_KEY'
|
||||
OPENAI_API_KEY = 'OPENAI_API_KEY',
|
||||
OPENAI_BASE_PATH = 'OPENAI_BASE_PATH',
|
||||
description = 'description',
|
||||
emoji = 'emoji',
|
||||
language = 'language'
|
||||
}
|
||||
|
||||
export enum CONFIG_MODES {
|
||||
@@ -42,80 +21,14 @@ export enum CONFIG_MODES {
|
||||
set = 'set'
|
||||
}
|
||||
|
||||
export const MODEL_LIST = {
|
||||
openai: [
|
||||
'gpt-4o-mini',
|
||||
'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-2024-07-18'
|
||||
],
|
||||
|
||||
anthropic: [
|
||||
'claude-3-5-sonnet-20240620',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-haiku-20240307'
|
||||
],
|
||||
|
||||
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 = 40960,
|
||||
DEFAULT_MAX_TOKENS_OUTPUT = 4096
|
||||
}
|
||||
|
||||
const validateConfig = (
|
||||
key: string,
|
||||
condition: any,
|
||||
validationMessage: string
|
||||
) => {
|
||||
if (!condition) {
|
||||
outro(`${chalk.red('✖')} wrong value for ${key}: ${validationMessage}.`);
|
||||
|
||||
outro(
|
||||
'For more help refer to docs https://github.com/di-sukharev/opencommit'
|
||||
`${chalk.red('✖')} Unsupported config key ${key}: ${validationMessage}`
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
@@ -123,450 +36,107 @@ const validateConfig = (
|
||||
};
|
||||
|
||||
export const configValidators = {
|
||||
[CONFIG_KEYS.OCO_OPENAI_API_KEY](value: any, config: any = {}) {
|
||||
if (config.OCO_AI_PROVIDER !== 'openai') return value;
|
||||
|
||||
[CONFIG_KEYS.OPENAI_API_KEY](value: any) {
|
||||
validateConfig(CONFIG_KEYS.OPENAI_API_KEY, value, 'Cannot be empty');
|
||||
validateConfig(
|
||||
'OCO_OPENAI_API_KEY',
|
||||
typeof value === 'string' && value.length > 0,
|
||||
'Empty value is not allowed'
|
||||
CONFIG_KEYS.OPENAI_API_KEY,
|
||||
value.startsWith('sk-'),
|
||||
'Must start with "sk-"'
|
||||
);
|
||||
|
||||
validateConfig(
|
||||
'OCO_OPENAI_API_KEY',
|
||||
value,
|
||||
'You need to provide the OCO_OPENAI_API_KEY when OCO_AI_PROVIDER is set to "openai" (default). Run `oco config set OCO_OPENAI_API_KEY=your_key`'
|
||||
CONFIG_KEYS.OPENAI_API_KEY,
|
||||
value.length === 51,
|
||||
'Must be 51 characters long'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_AZURE_API_KEY](value: any, config: any = {}) {
|
||||
if (config.OCO_AI_PROVIDER !== 'azure') return value;
|
||||
|
||||
[CONFIG_KEYS.description](value: any) {
|
||||
validateConfig(
|
||||
'OCO_AZURE_API_KEY',
|
||||
!!value,
|
||||
'You need to provide the OCO_AZURE_API_KEY when OCO_AI_PROVIDER is set to "azure". Run: `oco config set OCO_AZURE_API_KEY=your_key`'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_GEMINI_API_KEY](value: any, config: any = {}) {
|
||||
if (config.OCO_AI_PROVIDER !== 'gemini') return value;
|
||||
|
||||
validateConfig(
|
||||
'OCO_GEMINI_API_KEY',
|
||||
value || config.OCO_GEMINI_API_KEY || config.OCO_AI_PROVIDER === 'test',
|
||||
'You need to provide the OCO_GEMINI_API_KEY when OCO_AI_PROVIDER is set to "gemini". Run: `oco config set OCO_GEMINI_API_KEY=your_key`'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_ANTHROPIC_API_KEY](value: any, config: any = {}) {
|
||||
if (config.OCO_AI_PROVIDER !== 'anthropic') return value;
|
||||
|
||||
validateConfig(
|
||||
'ANTHROPIC_API_KEY',
|
||||
!!value,
|
||||
'You need to provide the OCO_ANTHROPIC_API_KEY key when OCO_AI_PROVIDER is set to "anthropic". Run: `oco config set OCO_ANTHROPIC_API_KEY=your_key`'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_FLOWISE_API_KEY](value: any, config: any = {}) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_FLOWISE_API_KEY,
|
||||
value || config.OCO_AI_PROVIDER !== 'flowise',
|
||||
'You need to provide the OCO_FLOWISE_API_KEY when OCO_AI_PROVIDER is set to "flowise". Run: `oco config set OCO_FLOWISE_API_KEY=your_key`'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_DESCRIPTION](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_DESCRIPTION,
|
||||
CONFIG_KEYS.description,
|
||||
typeof value === 'boolean',
|
||||
'Must be boolean: true or false'
|
||||
'Must be true or false'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_TOKENS_MAX_INPUT](value: any) {
|
||||
value = parseInt(value);
|
||||
[CONFIG_KEYS.emoji](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_TOKENS_MAX_INPUT,
|
||||
!isNaN(value),
|
||||
'Must be a number'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_TOKENS_MAX_OUTPUT](value: any) {
|
||||
value = parseInt(value);
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_TOKENS_MAX_OUTPUT,
|
||||
!isNaN(value),
|
||||
'Must be a number'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_EMOJI](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_EMOJI,
|
||||
CONFIG_KEYS.emoji,
|
||||
typeof value === 'boolean',
|
||||
'Must be boolean: true or false'
|
||||
'Must be true or false'
|
||||
);
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_LANGUAGE](value: any) {
|
||||
const supportedLanguages = Object.keys(i18n);
|
||||
|
||||
[CONFIG_KEYS.language](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_LANGUAGE,
|
||||
CONFIG_KEYS.language,
|
||||
getI18nLocal(value),
|
||||
`${value} is not supported yet. Supported languages: ${supportedLanguages}`
|
||||
`${value} is not supported yet`
|
||||
);
|
||||
|
||||
return getI18nLocal(value);
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_OPENAI_BASE_PATH](value: any) {
|
||||
[CONFIG_KEYS.OPENAI_BASE_PATH](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_OPENAI_BASE_PATH,
|
||||
typeof value === 'string',
|
||||
'Must be string'
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_MODEL](value: any, config: any = {}) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_MODEL,
|
||||
typeof value === 'string',
|
||||
`${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,
|
||||
value.startsWith('$'),
|
||||
`${value} must start with $, for example: '$msg'`
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
[CONFIG_KEYS.OCO_PROMPT_MODULE](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_PROMPT_MODULE,
|
||||
['conventional-commit', '@commitlint'].includes(value),
|
||||
`${value} is not supported yet, use '@commitlint' or 'conventional-commit' (default)`
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
// todo: deprecate
|
||||
[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) {
|
||||
if (!value) value = 'openai';
|
||||
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_AI_PROVIDER,
|
||||
['openai', 'anthropic', 'gemini', 'azure', 'test', 'flowise'].includes(
|
||||
value
|
||||
) || value.startsWith('ollama'),
|
||||
`${value} is not supported yet, use 'ollama', 'anthropic', 'azure', 'gemini', 'flowise' 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_FLOWISE_ENDPOINT](value: any) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_FLOWISE_ENDPOINT,
|
||||
typeof value === 'string' && value.includes(':'),
|
||||
'Value must be string and should include both I.P. and port number' // Considering the possibility of DNS lookup or feeding the I.P. explicitly, there is no pattern to verify, except a column for the port number
|
||||
);
|
||||
|
||||
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) {
|
||||
validateConfig(
|
||||
CONFIG_KEYS.OCO_OLLAMA_API_URL,
|
||||
typeof value === 'string' && value.startsWith('http'),
|
||||
`${value} is not a valid URL. It should start with 'http://' or 'https://'.`
|
||||
CONFIG_KEYS.OPENAI_BASE_PATH,
|
||||
typeof value == 'string',
|
||||
`${value} is not supported yet`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export enum OCO_AI_PROVIDER_ENUM {
|
||||
OLLAMA = 'ollama',
|
||||
OPENAI = 'openai',
|
||||
ANTHROPIC = 'anthropic',
|
||||
GEMINI = 'gemini',
|
||||
AZURE = 'azure',
|
||||
TEST = 'test',
|
||||
FLOWISE = 'flowise'
|
||||
}
|
||||
|
||||
export type ConfigType = {
|
||||
[CONFIG_KEYS.OCO_OPENAI_API_KEY]?: string;
|
||||
[CONFIG_KEYS.OCO_ANTHROPIC_API_KEY]?: string;
|
||||
[CONFIG_KEYS.OCO_AZURE_API_KEY]?: string;
|
||||
[CONFIG_KEYS.OCO_GEMINI_API_KEY]?: string;
|
||||
[CONFIG_KEYS.OCO_GEMINI_BASE_PATH]?: string;
|
||||
[CONFIG_KEYS.OCO_TOKENS_MAX_INPUT]: number;
|
||||
[CONFIG_KEYS.OCO_TOKENS_MAX_OUTPUT]: number;
|
||||
[CONFIG_KEYS.OCO_OPENAI_BASE_PATH]?: string;
|
||||
[CONFIG_KEYS.OCO_DESCRIPTION]: boolean;
|
||||
[CONFIG_KEYS.OCO_EMOJI]: boolean;
|
||||
[CONFIG_KEYS.OCO_WHY]: boolean;
|
||||
[CONFIG_KEYS.OCO_MODEL]: string;
|
||||
[CONFIG_KEYS.OCO_LANGUAGE]: string;
|
||||
[CONFIG_KEYS.OCO_MESSAGE_TEMPLATE_PLACEHOLDER]: string;
|
||||
[CONFIG_KEYS.OCO_PROMPT_MODULE]: OCO_PROMPT_MODULE_ENUM;
|
||||
[CONFIG_KEYS.OCO_AI_PROVIDER]: OCO_AI_PROVIDER_ENUM;
|
||||
[CONFIG_KEYS.OCO_GITPUSH]: boolean;
|
||||
[CONFIG_KEYS.OCO_ONE_LINE_COMMIT]: boolean;
|
||||
[CONFIG_KEYS.OCO_AZURE_ENDPOINT]?: string;
|
||||
[CONFIG_KEYS.OCO_TEST_MOCK_TYPE]: string;
|
||||
[CONFIG_KEYS.OCO_API_URL]?: string;
|
||||
[CONFIG_KEYS.OCO_OLLAMA_API_URL]?: string;
|
||||
[CONFIG_KEYS.OCO_FLOWISE_ENDPOINT]: string;
|
||||
[CONFIG_KEYS.OCO_FLOWISE_API_KEY]?: string;
|
||||
[key in CONFIG_KEYS]?: any;
|
||||
};
|
||||
|
||||
const defaultConfigPath = pathJoin(homedir(), '.opencommit');
|
||||
const defaultEnvPath = pathResolve(process.cwd(), '.env');
|
||||
const configPath = pathJoin(homedir(), '.opencommit');
|
||||
|
||||
const assertConfigsAreValid = (config: Record<string, any>) => {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (!value) continue;
|
||||
export const getConfig = (): ConfigType | null => {
|
||||
const configExists = existsSync(configPath);
|
||||
if (!configExists) return null;
|
||||
|
||||
if (typeof value === 'string' && ['null', 'undefined'].includes(value)) {
|
||||
config[key] = undefined;
|
||||
continue;
|
||||
}
|
||||
const configFile = readFileSync(configPath, 'utf8');
|
||||
const config = iniParse(configFile);
|
||||
|
||||
try {
|
||||
const validate = configValidators[key as CONFIG_KEYS];
|
||||
validate(value, config);
|
||||
} catch (error) {
|
||||
outro(`Unknown '${key}' config option or missing validator.`);
|
||||
outro(
|
||||
`Manually fix the '.env' file or global '~/.opencommit' config file.`
|
||||
);
|
||||
for (const configKey of Object.keys(config)) {
|
||||
const validValue = configValidators[configKey as CONFIG_KEYS](
|
||||
config[configKey]
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
config[configKey] = validValue;
|
||||
}
|
||||
};
|
||||
|
||||
enum OCO_PROMPT_MODULE_ENUM {
|
||||
CONVENTIONAL_COMMIT = 'conventional-commit',
|
||||
COMMITLINT = '@commitlint'
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
OCO_TOKENS_MAX_INPUT: DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_INPUT,
|
||||
OCO_TOKENS_MAX_OUTPUT: DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT,
|
||||
OCO_DESCRIPTION: false,
|
||||
OCO_EMOJI: false,
|
||||
OCO_MODEL: getDefaultModel('openai'),
|
||||
OCO_LANGUAGE: 'en',
|
||||
OCO_MESSAGE_TEMPLATE_PLACEHOLDER: '$msg',
|
||||
OCO_PROMPT_MODULE: OCO_PROMPT_MODULE_ENUM.CONVENTIONAL_COMMIT,
|
||||
OCO_AI_PROVIDER: OCO_AI_PROVIDER_ENUM.OPENAI,
|
||||
OCO_ONE_LINE_COMMIT: false,
|
||||
OCO_TEST_MOCK_TYPE: 'commit-message',
|
||||
OCO_FLOWISE_ENDPOINT: ':',
|
||||
OCO_WHY: false,
|
||||
OCO_GITPUSH: true // todo: deprecate
|
||||
};
|
||||
|
||||
const initGlobalConfig = (configPath: string = defaultConfigPath) => {
|
||||
writeFileSync(configPath, iniStringify(DEFAULT_CONFIG), 'utf8');
|
||||
return DEFAULT_CONFIG;
|
||||
};
|
||||
|
||||
const parseEnvVarValue = (value?: any) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getEnvConfig = (envPath: string) => {
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
return {
|
||||
OCO_MODEL: process.env.OCO_MODEL,
|
||||
|
||||
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_FLOWISE_API_KEY: process.env.OCO_FLOWISE_API_KEY,
|
||||
|
||||
OCO_TOKENS_MAX_INPUT: parseEnvVarValue(process.env.OCO_TOKENS_MAX_INPUT),
|
||||
OCO_TOKENS_MAX_OUTPUT: parseEnvVarValue(process.env.OCO_TOKENS_MAX_OUTPUT),
|
||||
|
||||
OCO_OPENAI_BASE_PATH: process.env.OCO_OPENAI_BASE_PATH,
|
||||
OCO_GEMINI_BASE_PATH: process.env.OCO_GEMINI_BASE_PATH,
|
||||
|
||||
OCO_AZURE_ENDPOINT: process.env.OCO_AZURE_ENDPOINT,
|
||||
OCO_FLOWISE_ENDPOINT: process.env.OCO_FLOWISE_ENDPOINT,
|
||||
OCO_OLLAMA_API_URL: process.env.OCO_OLLAMA_API_URL,
|
||||
|
||||
OCO_DESCRIPTION: parseEnvVarValue(process.env.OCO_DESCRIPTION),
|
||||
OCO_EMOJI: parseEnvVarValue(process.env.OCO_EMOJI),
|
||||
OCO_LANGUAGE: process.env.OCO_LANGUAGE,
|
||||
OCO_MESSAGE_TEMPLATE_PLACEHOLDER:
|
||||
process.env.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
|
||||
OCO_PROMPT_MODULE: process.env.OCO_PROMPT_MODULE as OCO_PROMPT_MODULE_ENUM,
|
||||
OCO_AI_PROVIDER: process.env.OCO_AI_PROVIDER as OCO_AI_PROVIDER_ENUM,
|
||||
OCO_ONE_LINE_COMMIT: parseEnvVarValue(process.env.OCO_ONE_LINE_COMMIT),
|
||||
OCO_TEST_MOCK_TYPE: process.env.OCO_TEST_MOCK_TYPE,
|
||||
|
||||
OCO_GITPUSH: parseEnvVarValue(process.env.OCO_GITPUSH) // todo: deprecate
|
||||
};
|
||||
};
|
||||
|
||||
const getGlobalConfig = (configPath: string) => {
|
||||
let globalConfig: ConfigType;
|
||||
|
||||
const isGlobalConfigFileExist = existsSync(configPath);
|
||||
if (!isGlobalConfigFileExist) globalConfig = initGlobalConfig(configPath);
|
||||
else {
|
||||
const configFile = readFileSync(configPath, 'utf8');
|
||||
globalConfig = iniParse(configFile) as ConfigType;
|
||||
}
|
||||
|
||||
return globalConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges two configs.
|
||||
* Env config takes precedence over global ~/.opencommit config file
|
||||
* @param main - env config
|
||||
* @param fallback - global ~/.opencommit config file
|
||||
* @returns merged config
|
||||
*/
|
||||
const mergeConfigs = (main: Partial<ConfigType>, fallback: ConfigType) =>
|
||||
Object.keys(CONFIG_KEYS).reduce((acc, key) => {
|
||||
acc[key] = parseEnvVarValue(main[key] ?? fallback[key]);
|
||||
|
||||
return acc;
|
||||
}, {} as ConfigType);
|
||||
|
||||
interface GetConfigOptions {
|
||||
globalPath?: string;
|
||||
envPath?: string;
|
||||
}
|
||||
|
||||
export const getConfig = ({
|
||||
envPath = defaultEnvPath,
|
||||
globalPath = defaultConfigPath
|
||||
}: GetConfigOptions = {}): ConfigType => {
|
||||
const envConfig = getEnvConfig(envPath);
|
||||
const globalConfig = getGlobalConfig(globalPath);
|
||||
|
||||
const config = mergeConfigs(envConfig, globalConfig);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
export const setConfig = (
|
||||
keyValues: [key: string, value: string][],
|
||||
globalConfigPath: string = defaultConfigPath
|
||||
) => {
|
||||
const config = getConfig({
|
||||
globalPath: globalConfigPath
|
||||
});
|
||||
export const setConfig = (keyValues: [key: string, value: string][]) => {
|
||||
const config = getConfig() || {};
|
||||
|
||||
for (let [key, value] of keyValues) {
|
||||
if (!configValidators.hasOwnProperty(key)) {
|
||||
const supportedKeys = Object.keys(configValidators).join('\n');
|
||||
throw new Error(
|
||||
`Unsupported config key: ${key}. Expected keys are:\n\n${supportedKeys}.\n\nFor more help refer to our docs: https://github.com/di-sukharev/opencommit`
|
||||
);
|
||||
for (const [configKey, configValue] of keyValues) {
|
||||
if (!configValidators.hasOwnProperty(configKey)) {
|
||||
throw new Error(`Unsupported config key: ${configKey}`);
|
||||
}
|
||||
|
||||
let parsedConfigValue;
|
||||
|
||||
try {
|
||||
parsedConfigValue = JSON.parse(value);
|
||||
parsedConfigValue = JSON.parse(configValue);
|
||||
} catch (error) {
|
||||
parsedConfigValue = value;
|
||||
parsedConfigValue = configValue;
|
||||
}
|
||||
|
||||
const validValue = configValidators[key as CONFIG_KEYS](
|
||||
parsedConfigValue,
|
||||
config
|
||||
);
|
||||
|
||||
config[key] = validValue;
|
||||
const validValue =
|
||||
configValidators[configKey as CONFIG_KEYS](parsedConfigValue);
|
||||
config[configKey as CONFIG_KEYS] = validValue;
|
||||
}
|
||||
|
||||
writeFileSync(globalConfigPath, iniStringify(config), 'utf8');
|
||||
writeFileSync(configPath, iniStringify(config), 'utf8');
|
||||
|
||||
outro(`${chalk.green('✔')} config successfully set`);
|
||||
};
|
||||
@@ -577,9 +147,9 @@ export const configCommand = command(
|
||||
parameters: ['<mode>', '<key=values...>']
|
||||
},
|
||||
async (argv) => {
|
||||
intro('opencommit — config');
|
||||
try {
|
||||
const { mode, keyValues } = argv._;
|
||||
intro(`COMMAND: config ${mode} ${keyValues}`);
|
||||
|
||||
if (mode === CONFIG_MODES.get) {
|
||||
const config = getConfig() || {};
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import {
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
note,
|
||||
outro,
|
||||
select,
|
||||
spinner
|
||||
} from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { command } from 'cleye';
|
||||
import { execa } from 'execa';
|
||||
import { getIgnoredFolders } from '../utils/git';
|
||||
import { COMMANDS } from './ENUMS';
|
||||
import { OpenAiEngine } from '../engine/openAi';
|
||||
import { getConfig } from './config';
|
||||
|
||||
type Occurrence = {
|
||||
fileName: string;
|
||||
context: {
|
||||
number: number;
|
||||
content: string;
|
||||
};
|
||||
matches: {
|
||||
number: number;
|
||||
content: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
/*
|
||||
TODO:
|
||||
- [ ] format declarations as file:line => context -> declaration
|
||||
- [ ] format usages as file:line => context -> usage
|
||||
- [ ] expand on usage to see it's call hierarchy
|
||||
- [ ] generate Mermaid diagram
|
||||
*/
|
||||
|
||||
const generateMermaid = async (stdout: string) => {
|
||||
const config = getConfig();
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
model: config.OCO_MODEL!,
|
||||
maxTokensOutput: config.OCO_TOKENS_MAX_OUTPUT!,
|
||||
maxTokensInput: config.OCO_TOKENS_MAX_INPUT!,
|
||||
baseURL: config.OCO_OPENAI_BASE_PATH!
|
||||
};
|
||||
const engine = new OpenAiEngine({
|
||||
...DEFAULT_CONFIG,
|
||||
apiKey: config.OCO_OPENAI_API_KEY!
|
||||
});
|
||||
|
||||
const diagram = await engine.generateCommitMessage([
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are to generate a mermaid diagram from the given function. Strictly answer in this json format: { "mermaid": "<mermaid diagram>" }. Where <mermaid diagram> is a valid mermaid diagram, e.g:
|
||||
graph TD
|
||||
A[Start] --> B[Generate Commit Message]
|
||||
B --> C{Token count >= Max?}
|
||||
C -->|Yes| D[Process file diffs]
|
||||
C -->|No| E[Generate single message]
|
||||
D --> F[Join messages]
|
||||
E --> G[Generate message]
|
||||
F --> H[End]
|
||||
G --> H
|
||||
B --> I{Error occurred?}
|
||||
I -->|Yes| J[Handle error]
|
||||
J --> H
|
||||
I -->|No| H
|
||||
`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: stdout
|
||||
}
|
||||
]);
|
||||
|
||||
return JSON.parse(diagram as string);
|
||||
};
|
||||
|
||||
export function extractFuncName(line: string) {
|
||||
const regex =
|
||||
/(?:function|export\s+const|const|let|var)?\s*(?:async\s+)?(\w+)\s*(?:=\s*(?:async\s*)?\(|\()/;
|
||||
const match = line.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function extractSingle(lineContent: string): string | null {
|
||||
const match = lineContent.match(/\s*(?:public\s+)?(?:async\s+)?(\w+)\s*=/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function mapLinesToOccurrences(input: string[], step: number = 3) {
|
||||
const occurrences: Occurrence[] = [];
|
||||
let single;
|
||||
|
||||
for (let i = 0; i < input.length; i += step) {
|
||||
if (i + 1 >= input.length) break;
|
||||
|
||||
const [fileName, callerLineNumber, ...callerLineContent] =
|
||||
input[i].split(/[=:]/);
|
||||
const [, definitionLineNumber, ...definitionLineContent] =
|
||||
input[i + 1].split(/[:]/);
|
||||
|
||||
if (!single) single = extractSingle(definitionLineContent.join(':'));
|
||||
|
||||
occurrences.push({
|
||||
fileName,
|
||||
context: {
|
||||
number: parseInt(callerLineNumber, 10),
|
||||
content: callerLineContent.join('=').trim()
|
||||
},
|
||||
matches: [
|
||||
{
|
||||
number: parseInt(definitionLineNumber, 10),
|
||||
content: definitionLineContent.join(':').trim()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return { occurrences, single };
|
||||
}
|
||||
|
||||
const findDeclarations = async (query: string[], ignoredFolders: string[]) => {
|
||||
const searchQuery = `(async|function|public).*${query.join('[^ \\n]*')}`;
|
||||
|
||||
outro(`Searching: ${searchQuery}`);
|
||||
|
||||
const occurrences = await findInFiles({ query: searchQuery, ignoredFolders });
|
||||
|
||||
if (!occurrences) return null;
|
||||
|
||||
const declarations = mapLinesToOccurrences(occurrences.split('\n'));
|
||||
|
||||
return declarations;
|
||||
};
|
||||
|
||||
const findUsagesByDeclaration = async (
|
||||
declaration: string,
|
||||
ignoredFolders: string[]
|
||||
) => {
|
||||
const searchQuery = `${declaration}\\(.*\\)`;
|
||||
|
||||
const occurrences = await findInFiles({
|
||||
query: searchQuery,
|
||||
ignoredFolders
|
||||
// grepOptions: ['--function-context']
|
||||
});
|
||||
|
||||
if (!occurrences) return null;
|
||||
|
||||
const usages = mapLinesToOccurrences(
|
||||
occurrences.split('\n').filter(Boolean),
|
||||
2
|
||||
);
|
||||
|
||||
return usages;
|
||||
};
|
||||
|
||||
const buildCallHierarchy = async (
|
||||
query: string[],
|
||||
ignoredFolders: string[]
|
||||
) => {};
|
||||
|
||||
const findInFiles = async ({
|
||||
query,
|
||||
ignoredFolders,
|
||||
grepOptions = []
|
||||
}: {
|
||||
query: string;
|
||||
ignoredFolders: string[];
|
||||
grepOptions?: string[];
|
||||
}): Promise<string | null> => {
|
||||
const withIgnoredFolders =
|
||||
ignoredFolders.length > 0
|
||||
? [
|
||||
'--',
|
||||
' ',
|
||||
'.',
|
||||
' ',
|
||||
ignoredFolders.map((folder) => `:^${folder}`).join(' ')
|
||||
]
|
||||
: [];
|
||||
|
||||
const params = [
|
||||
'--no-pager',
|
||||
'grep',
|
||||
'--show-function', // show function caller
|
||||
'-n',
|
||||
'-i',
|
||||
...grepOptions,
|
||||
'--break',
|
||||
'--color=never',
|
||||
|
||||
// '-C',
|
||||
// '1',
|
||||
|
||||
// '--full-name',
|
||||
// '--heading',
|
||||
'--threads',
|
||||
'10',
|
||||
'-E',
|
||||
query,
|
||||
...withIgnoredFolders
|
||||
];
|
||||
|
||||
try {
|
||||
const { stdout } = await execa('git', params);
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const generatePermutations = (arr: string[]): string[][] => {
|
||||
const n = arr.length;
|
||||
const result: string[][] = [];
|
||||
const indices = new Int32Array(n);
|
||||
|
||||
const current = new Array(n);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
indices[i] = i;
|
||||
current[i] = arr[i];
|
||||
}
|
||||
result.push([...current]);
|
||||
|
||||
let i = 1;
|
||||
while (i < n) {
|
||||
if (indices[i] > 0) {
|
||||
const j = indices[i] % 2 === 1 ? 0 : indices[i];
|
||||
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
result.push([...current]);
|
||||
indices[i]--;
|
||||
i = 1;
|
||||
} else {
|
||||
indices[i] = i;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const shuffleQuery = (query: string[]): string[][] => {
|
||||
return generatePermutations(query);
|
||||
};
|
||||
|
||||
export const findCommand = command(
|
||||
{
|
||||
name: COMMANDS.find,
|
||||
parameters: ['<query...>']
|
||||
},
|
||||
async (argv) => {
|
||||
const query = argv._;
|
||||
|
||||
intro(`OpenCommit — 🔦 find`);
|
||||
const ignoredFolders = getIgnoredFolders();
|
||||
|
||||
const searchSpinner = spinner();
|
||||
let declarations = await findDeclarations(query, ignoredFolders);
|
||||
|
||||
outro(`No matches found. Searching semantically similar queries.`);
|
||||
|
||||
searchSpinner.start(`Searching for matches...`);
|
||||
|
||||
if (!declarations?.occurrences.length) {
|
||||
const allPossibleQueries = shuffleQuery(query).reverse();
|
||||
for (const possibleQuery of allPossibleQueries) {
|
||||
declarations = await findDeclarations(possibleQuery, ignoredFolders);
|
||||
if (declarations?.occurrences.length) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!declarations?.occurrences.length) {
|
||||
searchSpinner.stop(`${chalk.red('✘')} No function declarations found.`);
|
||||
return process.exit(1);
|
||||
}
|
||||
|
||||
const usages = await findUsagesByDeclaration(
|
||||
declarations.single,
|
||||
ignoredFolders
|
||||
);
|
||||
|
||||
searchSpinner.stop(
|
||||
`${chalk.green('✔')} Found ${chalk.green(
|
||||
declarations.single
|
||||
)} definition and ${usages?.occurrences.length} usages.`
|
||||
);
|
||||
|
||||
note(
|
||||
declarations.occurrences
|
||||
.map((o) =>
|
||||
o.matches
|
||||
.map(
|
||||
(m) =>
|
||||
`${o.fileName}:${m.number} ${chalk.cyan(
|
||||
'==>'
|
||||
)} ${m.content.replace(
|
||||
declarations.single,
|
||||
chalk.green(declarations.single)
|
||||
)}`
|
||||
)
|
||||
.join('\n')
|
||||
)
|
||||
.join('\n'),
|
||||
'⍜ DECLARATIONS ⍜'
|
||||
);
|
||||
|
||||
note(
|
||||
usages?.occurrences
|
||||
.map((o) =>
|
||||
o.matches.map(
|
||||
(m) =>
|
||||
`${o.fileName}:${m.number} ${chalk.cyan(
|
||||
'==>'
|
||||
)} ${m.content.replace(
|
||||
declarations.single,
|
||||
chalk.green(declarations.single)
|
||||
)}`
|
||||
)
|
||||
)
|
||||
.join('\n'),
|
||||
'⌾ USAGES ⌾'
|
||||
);
|
||||
|
||||
const usage = (await select({
|
||||
message: chalk.cyan('Expand usage:'),
|
||||
options: usages!.occurrences
|
||||
.map((o) =>
|
||||
o.matches.map((m) => ({
|
||||
value: { o, m },
|
||||
label: `${chalk.yellow(`${o.fileName}:${m.number}`)} ${chalk.cyan(
|
||||
'==>'
|
||||
)} ${m.content.replace(
|
||||
declarations.single,
|
||||
chalk.green(declarations.single)
|
||||
)}`,
|
||||
hint: `parent: ${extractFuncName(o.context.content) ?? '404'}`
|
||||
}))
|
||||
)
|
||||
.flat()
|
||||
})) as { o: Occurrence; m: any };
|
||||
|
||||
if (isCancel(usage)) process.exit(1);
|
||||
|
||||
const { stdout } = await execa('git', [
|
||||
'--no-pager',
|
||||
'grep',
|
||||
'--function-context',
|
||||
'--heading',
|
||||
'-E',
|
||||
usage.m.content.replace('(', '\\(').replace(')', '\\)'),
|
||||
usage.o.fileName
|
||||
]);
|
||||
|
||||
const mermaidSpinner = spinner();
|
||||
mermaidSpinner.start('Generating mermaid diagram...');
|
||||
const mermaid: any = await generateMermaid(stdout);
|
||||
mermaidSpinner.stop();
|
||||
if (mermaid) console.log(mermaid.mermaid);
|
||||
else note('No mermaid diagram found.');
|
||||
|
||||
const isCommitConfirmedByUser = await confirm({
|
||||
message: 'Create Excalidraw file?'
|
||||
});
|
||||
|
||||
if (isCommitConfirmedByUser) outro('created diagram.excalidraw');
|
||||
else outro('Excalidraw file not created.');
|
||||
}
|
||||
);
|
||||
@@ -1,11 +1,11 @@
|
||||
import { intro, outro } from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { command } from 'cleye';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { command } from 'cleye';
|
||||
import { assertGitRepo, getCoreHooksPath } from '../utils/git.js';
|
||||
import { COMMANDS } from './ENUMS';
|
||||
import { existsSync } from 'fs';
|
||||
import chalk from 'chalk';
|
||||
import { intro, outro } from '@clack/prompts';
|
||||
import { COMMANDS } from '../CommandsEnum.js';
|
||||
|
||||
const HOOK_NAME = 'prepare-commit-msg';
|
||||
const DEFAULT_SYMLINK_URL = path.join('.git', 'hooks', HOOK_NAME);
|
||||
@@ -92,7 +92,7 @@ export const hookCommand = command(
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported mode: ${mode}. Supported modes are: 'set' or 'unset'. Run: \`oco hook set\``
|
||||
`unsupported mode: ${mode}. Supported modes are: 'set' or 'unset'`
|
||||
);
|
||||
} catch (error) {
|
||||
outro(`${chalk.red('✖')} ${error}`);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { intro, outro, spinner } from '@clack/prompts';
|
||||
|
||||
import { generateCommitMessageByDiff } from '../generateCommitMessageFromGitDiff';
|
||||
import { getChangedFiles, getDiff, getStagedFiles, gitAdd } from '../utils/git';
|
||||
import { getConfig } from './config';
|
||||
import { generateCommitMessageWithChatCompletion } from '../generateCommitMessageFromGitDiff';
|
||||
|
||||
const [messageFilePath, commitSource] = process.argv.slice(2);
|
||||
|
||||
@@ -26,7 +24,7 @@ export const prepareCommitMessageHook = async (
|
||||
|
||||
if (changedFiles) await gitAdd({ files: changedFiles });
|
||||
else {
|
||||
outro('No changes detected, write some code and run `oco` again');
|
||||
outro('No changes detected, write some code and run `oc` again');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -35,27 +33,25 @@ export const prepareCommitMessageHook = async (
|
||||
|
||||
if (!staged) return;
|
||||
|
||||
intro('OpenCommit');
|
||||
intro('opencommit');
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
if (
|
||||
!config.OCO_OPENAI_API_KEY &&
|
||||
!config.OCO_ANTHROPIC_API_KEY &&
|
||||
!config.OCO_AZURE_API_KEY
|
||||
) {
|
||||
if (!config?.OPENAI_API_KEY) {
|
||||
throw new Error(
|
||||
'No OPEN_AI_API or OCO_ANTHROPIC_API_KEY or OCO_AZURE_API_KEY exists. Set your key in ~/.opencommit'
|
||||
'No OPEN_AI_API exists. Set your OPEN_AI_API=<key> in ~/.opencommit'
|
||||
);
|
||||
}
|
||||
|
||||
const spin = spinner();
|
||||
spin.start('Generating commit message');
|
||||
|
||||
const commitMessage = await generateCommitMessageByDiff(
|
||||
const commitMessage = await generateCommitMessageWithChatCompletion(
|
||||
await getDiff({ files: staged })
|
||||
);
|
||||
spin.stop('Done');
|
||||
if (typeof commitMessage !== 'string') {
|
||||
spin.stop('Error');
|
||||
throw new Error(commitMessage.error);
|
||||
} else spin.stop('Done');
|
||||
|
||||
const fileContent = await fs.readFile(messageFilePath);
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import AnthropicClient from '@anthropic-ai/sdk';
|
||||
import { OpenAIClient as AzureOpenAIClient } from '@azure/openai';
|
||||
import { GoogleGenerativeAI as GeminiClient } from '@google/generative-ai';
|
||||
import { AxiosInstance as RawAxiosClient } from 'axios';
|
||||
import { OpenAI as OpenAIClient } from 'openai';
|
||||
|
||||
export interface AiEngineConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
maxTokensOutput: number;
|
||||
maxTokensInput: number;
|
||||
baseURL?: string;
|
||||
}
|
||||
|
||||
type Client =
|
||||
| OpenAIClient
|
||||
| AzureOpenAIClient
|
||||
| AnthropicClient
|
||||
| RawAxiosClient
|
||||
| GeminiClient;
|
||||
|
||||
export interface AiEngine {
|
||||
config: AiEngineConfig;
|
||||
client: Client;
|
||||
generateCommitMessage(
|
||||
messages: Array<OpenAIClient.Chat.Completions.ChatCompletionMessageParam>
|
||||
): Promise<string | null | undefined>;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import AnthropicClient from '@anthropic-ai/sdk';
|
||||
import {
|
||||
MessageCreateParamsNonStreaming,
|
||||
MessageParam
|
||||
} from '@anthropic-ai/sdk/resources/messages.mjs';
|
||||
import { outro } from '@clack/prompts';
|
||||
import axios from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { OpenAI } from 'openai';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface AnthropicConfig extends AiEngineConfig {}
|
||||
|
||||
export class AnthropicEngine implements AiEngine {
|
||||
config: AnthropicConfig;
|
||||
client: AnthropicClient;
|
||||
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.client = new AnthropicClient({ apiKey: this.config.apiKey });
|
||||
}
|
||||
|
||||
public generateCommitMessage = async (
|
||||
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
|
||||
): 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: this.config.model,
|
||||
system: systemMessage,
|
||||
messages: restMessages,
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: this.config.maxTokensOutput
|
||||
};
|
||||
try {
|
||||
const REQUEST_TOKENS = messages
|
||||
.map((msg) => tokenCount(msg.content as string) + 4)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (
|
||||
REQUEST_TOKENS >
|
||||
this.config.maxTokensInput - this.config.maxTokensOutput
|
||||
) {
|
||||
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
|
||||
}
|
||||
|
||||
const data = await this.client.messages.create(params);
|
||||
|
||||
const message = data?.content[0].text;
|
||||
|
||||
return message;
|
||||
} 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 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
AzureKeyCredential,
|
||||
OpenAIClient as AzureOpenAIClient
|
||||
} from '@azure/openai';
|
||||
import { outro } from '@clack/prompts';
|
||||
import axios from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { OpenAI } from 'openai';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface AzureAiEngineConfig extends AiEngineConfig {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export class AzureEngine implements AiEngine {
|
||||
config: AzureAiEngineConfig;
|
||||
client: AzureOpenAIClient;
|
||||
|
||||
constructor(config: AzureAiEngineConfig) {
|
||||
this.config = config;
|
||||
this.client = new AzureOpenAIClient(
|
||||
this.config.baseURL,
|
||||
new AzureKeyCredential(this.config.apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
async generateCommitMessage(
|
||||
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const REQUEST_TOKENS = messages
|
||||
.map((msg) => tokenCount(msg.content as string) + 4)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (
|
||||
REQUEST_TOKENS >
|
||||
this.config.maxTokensInput - this.config.maxTokensOutput
|
||||
) {
|
||||
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
|
||||
}
|
||||
|
||||
const data = await this.client.getChatCompletions(
|
||||
this.config.model,
|
||||
messages
|
||||
);
|
||||
|
||||
const message = data.choices[0].message;
|
||||
|
||||
if (message?.content === null) {
|
||||
return undefined;
|
||||
}
|
||||
return message?.content;
|
||||
} catch (error) {
|
||||
outro(`${chalk.red('✖')} ${this.config.model}`);
|
||||
|
||||
const err = error as Error;
|
||||
outro(`${chalk.red('✖')} ${JSON.stringify(error)}`);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface FlowiseAiConfig extends AiEngineConfig {}
|
||||
|
||||
export class FlowiseAi implements AiEngine {
|
||||
config: FlowiseAiConfig;
|
||||
client: AxiosInstance;
|
||||
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.client = axios.create({
|
||||
url: `${config.baseURL}/${config.apiKey}`,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
async generateCommitMessage(
|
||||
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
|
||||
): Promise<string | undefined> {
|
||||
const gitDiff = (messages[messages.length - 1]?.content as string)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t');
|
||||
|
||||
const payload = {
|
||||
question: gitDiff,
|
||||
overrideConfig: {
|
||||
systemMessagePrompt: messages[0]?.content
|
||||
},
|
||||
history: messages.slice(1, -1)
|
||||
};
|
||||
try {
|
||||
const response = await this.client.post('', payload);
|
||||
const message = response.data;
|
||||
return message?.text;
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error ?? err.message;
|
||||
throw new Error('local model issues. details: ' + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import {
|
||||
Content,
|
||||
GoogleGenerativeAI,
|
||||
HarmBlockThreshold,
|
||||
HarmCategory,
|
||||
Part
|
||||
} from '@google/generative-ai';
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface GeminiConfig extends AiEngineConfig {}
|
||||
|
||||
export class Gemini implements AiEngine {
|
||||
config: GeminiConfig;
|
||||
client: GoogleGenerativeAI;
|
||||
|
||||
constructor(config) {
|
||||
this.client = new GoogleGenerativeAI(config.apiKey);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async generateCommitMessage(
|
||||
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
|
||||
): Promise<string | undefined> {
|
||||
const systemInstruction = messages
|
||||
.filter((m) => m.role === 'system')
|
||||
.map((m) => m.content)
|
||||
.join('\n');
|
||||
|
||||
const gemini = this.client.getGenerativeModel({
|
||||
model: this.config.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 gemini.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.config.maxTokensOutput,
|
||||
temperature: 0,
|
||||
topP: 0.1
|
||||
}
|
||||
});
|
||||
|
||||
return result.response.text();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const geminiError = error.response.data.error;
|
||||
if (geminiError) throw new Error(geminiError?.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface OllamaConfig extends AiEngineConfig {}
|
||||
|
||||
export class OllamaAi implements AiEngine {
|
||||
config: OllamaConfig;
|
||||
client: AxiosInstance;
|
||||
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.client = axios.create({
|
||||
url: config.baseURL
|
||||
? `${config.baseURL}/${config.apiKey}`
|
||||
: 'http://localhost:11434/api/chat',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
async generateCommitMessage(
|
||||
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
|
||||
): Promise<string | undefined> {
|
||||
const params = {
|
||||
model: this.config.model ?? 'mistral',
|
||||
messages,
|
||||
options: { temperature: 0, top_p: 0.1 },
|
||||
stream: false
|
||||
};
|
||||
try {
|
||||
const response = await this.client.post('', params);
|
||||
|
||||
const message = response.data.message;
|
||||
|
||||
return message?.content;
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error ?? err.message;
|
||||
throw new Error(`Ollama provider error: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
|
||||
import { tokenCount } from '../utils/tokenCount';
|
||||
import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface OpenAiConfig extends AiEngineConfig {}
|
||||
|
||||
export class OpenAiEngine implements AiEngine {
|
||||
config: OpenAiConfig;
|
||||
client: OpenAI;
|
||||
|
||||
constructor(config: OpenAiConfig) {
|
||||
this.config = config;
|
||||
this.client = new OpenAI({ apiKey: config.apiKey });
|
||||
}
|
||||
|
||||
public generateCommitMessage = async (
|
||||
messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
|
||||
): Promise<string | null> => {
|
||||
const params = {
|
||||
model: this.config.model,
|
||||
messages,
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: this.config.maxTokensOutput
|
||||
};
|
||||
|
||||
try {
|
||||
const REQUEST_TOKENS = messages
|
||||
.map((msg) => tokenCount(msg.content as string) + 4)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (
|
||||
REQUEST_TOKENS >
|
||||
this.config.maxTokensInput - this.config.maxTokensOutput
|
||||
)
|
||||
throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
|
||||
|
||||
const completion = await this.client.chat.completions.create(params);
|
||||
|
||||
const message = completion.choices[0].message;
|
||||
|
||||
return message?.content;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (
|
||||
axios.isAxiosError<{ error?: { message: string } }>(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
const openAiError = error.response.data.error;
|
||||
|
||||
if (openAiError) throw new Error(openAiError.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { OpenAI } from 'openai';
|
||||
|
||||
import { AiEngine } from './Engine';
|
||||
|
||||
export const TEST_MOCK_TYPES = [
|
||||
'commit-message',
|
||||
'prompt-module-commitlint-config'
|
||||
] as const;
|
||||
|
||||
export type TestMockType = (typeof TEST_MOCK_TYPES)[number];
|
||||
|
||||
type TestAiEngine = Partial<AiEngine> & {
|
||||
mockType: TestMockType;
|
||||
};
|
||||
|
||||
export class TestAi implements TestAiEngine {
|
||||
mockType: TestMockType;
|
||||
|
||||
// those are not used in the test engine
|
||||
config: any;
|
||||
client: any;
|
||||
// ---
|
||||
|
||||
constructor(mockType: TestMockType) {
|
||||
this.mockType = mockType;
|
||||
}
|
||||
|
||||
async generateCommitMessage(
|
||||
_messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
|
||||
): Promise<string | undefined> {
|
||||
switch (this.mockType) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,67 @@
|
||||
import { OpenAI } from 'openai';
|
||||
import { DEFAULT_TOKEN_LIMITS, getConfig } from './commands/config';
|
||||
import { getMainCommitPrompt } from './prompts';
|
||||
import { getEngine } from './utils/engine';
|
||||
import {
|
||||
ChatCompletionRequestMessage,
|
||||
ChatCompletionRequestMessageRoleEnum
|
||||
} from 'openai';
|
||||
import { api } from './api';
|
||||
import { getConfig } from './commands/config';
|
||||
import { mergeDiffs } from './utils/mergeDiffs';
|
||||
import { i18n, I18nLocals } from './i18n';
|
||||
import { tokenCount } from './utils/tokenCount';
|
||||
|
||||
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 translation = i18n[(config?.language as I18nLocals) || 'en'];
|
||||
|
||||
const generateCommitMessageChatCompletionPrompt = async (
|
||||
diff: string,
|
||||
fullGitMojiSpec: boolean
|
||||
): Promise<Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>> => {
|
||||
const INIT_MESSAGES_PROMPT = await getMainCommitPrompt(fullGitMojiSpec);
|
||||
const INIT_MESSAGES_PROMPT: Array<ChatCompletionRequestMessage> = [
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.System,
|
||||
// prettier-ignore
|
||||
content: `You are to act as the author of a commit message in git. Your mission is to create clean and comprehensive commit messages in the conventional commit convention and explain why a change was done. I'll send you an output of 'git diff --staged' command, and you convert it into a commit message.
|
||||
${config?.emoji? 'Use GitMoji convention to preface the commit.': 'Do not preface the commit with anything.'}
|
||||
${config?.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. Lines must not be longer than 74 characters. Use ${translation.localLanguage} to answer.`
|
||||
},
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.User,
|
||||
content: `diff --git a/src/server.ts b/src/server.ts
|
||||
index ad4db42..f3b18a9 100644
|
||||
--- a/src/server.ts
|
||||
+++ b/src/server.ts
|
||||
@@ -10,7 +10,7 @@
|
||||
import {
|
||||
initWinstonLogger();
|
||||
|
||||
const app = express();
|
||||
-const port = 7799;
|
||||
+const PORT = 7799;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
app.use((_, res, next) => {
|
||||
// ROUTES
|
||||
app.use(PROTECTED_ROUTER_URL, protectedRouter);
|
||||
|
||||
-app.listen(port, () => {
|
||||
- console.log(\`Server listening on port \${port}\`);
|
||||
+app.listen(process.env.PORT || PORT, () => {
|
||||
+ console.log(\`Server listening on port \${PORT}\`);
|
||||
});`
|
||||
},
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.Assistant,
|
||||
content: `${config?.emoji ? '🐛 ' : ''}${translation.commitFix}
|
||||
${config?.emoji ? '✨ ' : ''}${translation.commitFeat}
|
||||
${config?.description ? translation.commitDescription : ''}`
|
||||
}
|
||||
];
|
||||
|
||||
const generateCommitMessageChatCompletionPrompt = (
|
||||
diff: string
|
||||
): Array<ChatCompletionRequestMessage> => {
|
||||
const chatContextAsCompletionRequest = [...INIT_MESSAGES_PROMPT];
|
||||
|
||||
chatContextAsCompletionRequest.push({
|
||||
role: 'user',
|
||||
role: ChatCompletionRequestMessageRoleEnum.User,
|
||||
content: diff
|
||||
});
|
||||
|
||||
@@ -31,67 +71,51 @@ const generateCommitMessageChatCompletionPrompt = async (
|
||||
export enum GenerateCommitMessageErrorEnum {
|
||||
tooMuchTokens = 'TOO_MUCH_TOKENS',
|
||||
internalError = 'INTERNAL_ERROR',
|
||||
emptyMessage = 'EMPTY_MESSAGE',
|
||||
outputTokensTooHigh = `Token limit exceeded, OCO_TOKENS_MAX_OUTPUT must not be much higher than the default ${DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT} tokens.`
|
||||
emptyMessage = 'EMPTY_MESSAGE'
|
||||
}
|
||||
|
||||
const ADJUSTMENT_FACTOR = 20;
|
||||
interface GenerateCommitMessageError {
|
||||
error: GenerateCommitMessageErrorEnum;
|
||||
}
|
||||
|
||||
export const generateCommitMessageByDiff = async (
|
||||
diff: string,
|
||||
fullGitMojiSpec: boolean = false
|
||||
): Promise<string> => {
|
||||
const INIT_MESSAGES_PROMPT_LENGTH = INIT_MESSAGES_PROMPT.map(
|
||||
(msg) => tokenCount(msg.content) + 4
|
||||
).reduce((a, b) => a + b, 0);
|
||||
|
||||
const MAX_REQ_TOKENS = 3900 - INIT_MESSAGES_PROMPT_LENGTH;
|
||||
|
||||
export const generateCommitMessageWithChatCompletion = async (
|
||||
diff: string
|
||||
): Promise<string | GenerateCommitMessageError> => {
|
||||
try {
|
||||
const INIT_MESSAGES_PROMPT = await getMainCommitPrompt(fullGitMojiSpec);
|
||||
|
||||
const INIT_MESSAGES_PROMPT_LENGTH = INIT_MESSAGES_PROMPT.map(
|
||||
(msg) => tokenCount(msg.content as string) + 4
|
||||
).reduce((a, b) => a + b, 0);
|
||||
|
||||
const MAX_REQUEST_TOKENS =
|
||||
MAX_TOKENS_INPUT -
|
||||
ADJUSTMENT_FACTOR -
|
||||
INIT_MESSAGES_PROMPT_LENGTH -
|
||||
MAX_TOKENS_OUTPUT;
|
||||
|
||||
if (tokenCount(diff) >= MAX_REQUEST_TOKENS) {
|
||||
const commitMessagePromises = await getCommitMsgsPromisesFromFileDiffs(
|
||||
if (tokenCount(diff) >= MAX_REQ_TOKENS) {
|
||||
const commitMessagePromises = getCommitMsgsPromisesFromFileDiffs(
|
||||
diff,
|
||||
MAX_REQUEST_TOKENS,
|
||||
fullGitMojiSpec
|
||||
MAX_REQ_TOKENS
|
||||
);
|
||||
|
||||
const commitMessages = [] as string[];
|
||||
for (const promise of commitMessagePromises) {
|
||||
commitMessages.push((await promise) as string);
|
||||
await delay(2000);
|
||||
}
|
||||
const commitMessages = await Promise.all(commitMessagePromises);
|
||||
|
||||
return commitMessages.join('\n\n');
|
||||
} else {
|
||||
const messages = generateCommitMessageChatCompletionPrompt(diff);
|
||||
|
||||
const commitMessage = await api.generateCommitMessage(messages);
|
||||
|
||||
if (!commitMessage)
|
||||
return { error: GenerateCommitMessageErrorEnum.emptyMessage };
|
||||
|
||||
return commitMessage;
|
||||
}
|
||||
|
||||
const messages = await generateCommitMessageChatCompletionPrompt(
|
||||
diff,
|
||||
fullGitMojiSpec
|
||||
);
|
||||
|
||||
const engine = getEngine();
|
||||
const commitMessage = await engine.generateCommitMessage(messages);
|
||||
|
||||
if (!commitMessage)
|
||||
throw new Error(GenerateCommitMessageErrorEnum.emptyMessage);
|
||||
|
||||
return commitMessage;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
return { error: GenerateCommitMessageErrorEnum.internalError };
|
||||
}
|
||||
};
|
||||
|
||||
function getMessagesPromisesByChangesInFile(
|
||||
fileDiff: string,
|
||||
separator: string,
|
||||
maxChangeLength: number,
|
||||
fullGitMojiSpec: boolean
|
||||
maxChangeLength: number
|
||||
) {
|
||||
const hunkHeaderSeparator = '@@ ';
|
||||
const [fileHeader, ...fileDiffByLines] = fileDiff.split(hunkHeaderSeparator);
|
||||
@@ -102,74 +126,25 @@ function getMessagesPromisesByChangesInFile(
|
||||
maxChangeLength
|
||||
);
|
||||
|
||||
const lineDiffsWithHeader = [] as string[];
|
||||
for (const change of mergedChanges) {
|
||||
const totalChange = fileHeader + change;
|
||||
if (tokenCount(totalChange) > maxChangeLength) {
|
||||
// If the totalChange is too large, split it into smaller pieces
|
||||
const splitChanges = splitDiff(totalChange, maxChangeLength);
|
||||
lineDiffsWithHeader.push(...splitChanges);
|
||||
} else {
|
||||
lineDiffsWithHeader.push(totalChange);
|
||||
}
|
||||
}
|
||||
|
||||
const engine = getEngine();
|
||||
const commitMsgsFromFileLineDiffs = lineDiffsWithHeader.map(
|
||||
async (lineDiff) => {
|
||||
const messages = await generateCommitMessageChatCompletionPrompt(
|
||||
separator + lineDiff,
|
||||
fullGitMojiSpec
|
||||
);
|
||||
|
||||
return engine.generateCommitMessage(messages);
|
||||
}
|
||||
const lineDiffsWithHeader = mergedChanges.map(
|
||||
(change) => fileHeader + change
|
||||
);
|
||||
|
||||
const commitMsgsFromFileLineDiffs = lineDiffsWithHeader.map((lineDiff) => {
|
||||
const messages = generateCommitMessageChatCompletionPrompt(
|
||||
separator + lineDiff
|
||||
);
|
||||
|
||||
return api.generateCommitMessage(messages);
|
||||
});
|
||||
|
||||
return commitMsgsFromFileLineDiffs;
|
||||
}
|
||||
|
||||
function splitDiff(diff: string, maxChangeLength: number) {
|
||||
const lines = diff.split('\n');
|
||||
const splitDiffs = [] as string[];
|
||||
let currentDiff = '';
|
||||
|
||||
if (maxChangeLength <= 0) {
|
||||
throw new Error(GenerateCommitMessageErrorEnum.outputTokensTooHigh);
|
||||
}
|
||||
|
||||
for (let line of lines) {
|
||||
// If a single line exceeds maxChangeLength, split it into multiple lines
|
||||
while (tokenCount(line) > maxChangeLength) {
|
||||
const subLine = line.substring(0, maxChangeLength);
|
||||
line = line.substring(maxChangeLength);
|
||||
splitDiffs.push(subLine);
|
||||
}
|
||||
|
||||
// Check the tokenCount of the currentDiff and the line separately
|
||||
if (tokenCount(currentDiff) + tokenCount('\n' + line) > maxChangeLength) {
|
||||
// If adding the next line would exceed the maxChangeLength, start a new diff
|
||||
splitDiffs.push(currentDiff);
|
||||
currentDiff = line;
|
||||
} else {
|
||||
// Otherwise, add the line to the current diff
|
||||
currentDiff += '\n' + line;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last diff
|
||||
if (currentDiff) {
|
||||
splitDiffs.push(currentDiff);
|
||||
}
|
||||
|
||||
return splitDiffs;
|
||||
}
|
||||
|
||||
export const getCommitMsgsPromisesFromFileDiffs = async (
|
||||
export function getCommitMsgsPromisesFromFileDiffs(
|
||||
diff: string,
|
||||
maxDiffLength: number,
|
||||
fullGitMojiSpec: boolean
|
||||
) => {
|
||||
maxDiffLength: number
|
||||
) {
|
||||
const separator = 'diff --git ';
|
||||
|
||||
const diffByFiles = diff.split(separator).slice(1);
|
||||
@@ -177,7 +152,7 @@ export const getCommitMsgsPromisesFromFileDiffs = async (
|
||||
// merge multiple files-diffs into 1 prompt to save tokens
|
||||
const mergedFilesDiffs = mergeDiffs(diffByFiles, maxDiffLength);
|
||||
|
||||
const commitMessagePromises = [] as Promise<string | null | undefined>[];
|
||||
const commitMessagePromises = [];
|
||||
|
||||
for (const fileDiff of mergedFilesDiffs) {
|
||||
if (tokenCount(fileDiff) >= maxDiffLength) {
|
||||
@@ -185,25 +160,17 @@ export const getCommitMsgsPromisesFromFileDiffs = async (
|
||||
const messagesPromises = getMessagesPromisesByChangesInFile(
|
||||
fileDiff,
|
||||
separator,
|
||||
maxDiffLength,
|
||||
fullGitMojiSpec
|
||||
maxDiffLength
|
||||
);
|
||||
|
||||
commitMessagePromises.push(...messagesPromises);
|
||||
} else {
|
||||
const messages = await generateCommitMessageChatCompletionPrompt(
|
||||
separator + fileDiff,
|
||||
fullGitMojiSpec
|
||||
const messages = generateCommitMessageChatCompletionPrompt(
|
||||
separator + fileDiff
|
||||
);
|
||||
|
||||
const engine = getEngine();
|
||||
commitMessagePromises.push(engine.generateCommitMessage(messages));
|
||||
commitMessagePromises.push(api.generateCommitMessage(messages));
|
||||
}
|
||||
}
|
||||
|
||||
return commitMessagePromises;
|
||||
};
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import core from '@actions/core';
|
||||
import exec from '@actions/exec';
|
||||
import github from '@actions/github';
|
||||
import { intro, outro } from '@clack/prompts';
|
||||
import { PushEvent } from '@octokit/webhooks-types';
|
||||
import { unlinkSync, writeFileSync } from 'fs';
|
||||
import { generateCommitMessageByDiff } from './generateCommitMessageFromGitDiff';
|
||||
import { randomIntFromInterval } from './utils/randomIntFromInterval';
|
||||
import { sleep } from './utils/sleep';
|
||||
|
||||
// This should be a token with access to your repository scoped in as a secret.
|
||||
// The YML workflow will need to set GITHUB_TOKEN with the GitHub Secret Token
|
||||
// GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
// https://help.github.com/en/actions/automating-your-workflow-with-github-actions/authenticating-with-the-github_token#about-the-github_token-secret
|
||||
const GITHUB_TOKEN = core.getInput('GITHUB_TOKEN');
|
||||
const octokit = github.getOctokit(GITHUB_TOKEN);
|
||||
const context = github.context;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
type SHA = string;
|
||||
type Diff = string;
|
||||
|
||||
async function getCommitDiff(commitSha: string) {
|
||||
const diffResponse = await octokit.request<string>(
|
||||
'GET /repos/{owner}/{repo}/commits/{ref}',
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
ref: commitSha,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3.diff'
|
||||
}
|
||||
}
|
||||
);
|
||||
return { sha: commitSha, diff: diffResponse.data };
|
||||
}
|
||||
|
||||
interface DiffAndSHA {
|
||||
sha: SHA;
|
||||
diff: Diff;
|
||||
}
|
||||
|
||||
interface MsgAndSHA {
|
||||
sha: SHA;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
// send only 3-4 size chunks of diffs in steps,
|
||||
// because openAI restricts "too many requests" at once with 429 error
|
||||
async function improveMessagesInChunks(diffsAndSHAs: DiffAndSHA[]) {
|
||||
const chunkSize = diffsAndSHAs!.length % 2 === 0 ? 4 : 3;
|
||||
outro(`Improving commit messages in chunks of ${chunkSize}.`);
|
||||
const improvePromises = diffsAndSHAs!.map((commit) =>
|
||||
generateCommitMessageByDiff(commit.diff, false)
|
||||
);
|
||||
|
||||
let improvedMessagesAndSHAs: MsgAndSHA[] = [];
|
||||
for (let step = 0; step < improvePromises.length; step += chunkSize) {
|
||||
const chunkOfPromises = improvePromises.slice(step, step + chunkSize);
|
||||
|
||||
try {
|
||||
const chunkOfImprovedMessages = await Promise.all(chunkOfPromises);
|
||||
|
||||
const chunkOfImprovedMessagesBySha = chunkOfImprovedMessages.map(
|
||||
(improvedMsg, i) => {
|
||||
const index = improvedMessagesAndSHAs.length;
|
||||
const sha = diffsAndSHAs![index + i].sha;
|
||||
|
||||
return { sha, msg: improvedMsg };
|
||||
}
|
||||
);
|
||||
|
||||
improvedMessagesAndSHAs.push(...chunkOfImprovedMessagesBySha);
|
||||
|
||||
// sometimes openAI errors with 429 code (too many requests),
|
||||
// so lets sleep a bit
|
||||
const sleepFor =
|
||||
1000 * randomIntFromInterval(1, 5) + 100 * randomIntFromInterval(1, 5);
|
||||
|
||||
outro(
|
||||
`Improved ${chunkOfPromises.length} messages. Sleeping for ${sleepFor}`
|
||||
);
|
||||
|
||||
await sleep(sleepFor);
|
||||
} catch (error) {
|
||||
outro(error as string);
|
||||
|
||||
// if sleeping in try block still fails with 429,
|
||||
// openAI wants at least 1 minute before next request
|
||||
const sleepFor = 60000 + 1000 * randomIntFromInterval(1, 5);
|
||||
outro(`Retrying after sleeping for ${sleepFor}`);
|
||||
await sleep(sleepFor);
|
||||
|
||||
// go to previous step
|
||||
step -= chunkSize;
|
||||
}
|
||||
}
|
||||
|
||||
return improvedMessagesAndSHAs;
|
||||
}
|
||||
|
||||
const getDiffsBySHAs = async (SHAs: string[]) => {
|
||||
const diffPromises = SHAs.map((sha) => getCommitDiff(sha));
|
||||
|
||||
const diffs = await Promise.all(diffPromises).catch((error) => {
|
||||
outro(`Error in Promise.all(getCommitDiffs(SHAs)): ${error}.`);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return diffs;
|
||||
};
|
||||
|
||||
async function improveCommitMessages(
|
||||
commitsToImprove: { id: string; message: string }[]
|
||||
): Promise<void> {
|
||||
if (commitsToImprove.length) {
|
||||
outro(`Found ${commitsToImprove.length} commits to improve.`);
|
||||
} else {
|
||||
outro('No new commits found.');
|
||||
return;
|
||||
}
|
||||
|
||||
outro('Fetching commit diffs by SHAs.');
|
||||
const commitSHAsToImprove = commitsToImprove.map((commit) => commit.id);
|
||||
const diffsWithSHAs = await getDiffsBySHAs(commitSHAsToImprove);
|
||||
outro('Done.');
|
||||
|
||||
const improvedMessagesWithSHAs = await improveMessagesInChunks(diffsWithSHAs);
|
||||
|
||||
console.log(
|
||||
`Improved ${improvedMessagesWithSHAs.length} commits: `,
|
||||
improvedMessagesWithSHAs
|
||||
);
|
||||
|
||||
// Check if there are actually any changes in the commit messages
|
||||
const messagesChanged = improvedMessagesWithSHAs.some(
|
||||
({ sha, msg }, index) => msg !== commitsToImprove[index].message
|
||||
);
|
||||
|
||||
if (!messagesChanged) {
|
||||
console.log('No changes in commit messages detected, skipping rebase');
|
||||
return;
|
||||
}
|
||||
|
||||
const createCommitMessageFile = (message: string, index: number) =>
|
||||
writeFileSync(`./commit-${index}.txt`, message);
|
||||
improvedMessagesWithSHAs.forEach(({ msg }, i) =>
|
||||
createCommitMessageFile(msg, i)
|
||||
);
|
||||
|
||||
writeFileSync(`./count.txt`, '0');
|
||||
|
||||
writeFileSync(
|
||||
'./rebase-exec.sh',
|
||||
`#!/bin/bash
|
||||
count=$(cat count.txt)
|
||||
git commit --amend -F commit-$count.txt
|
||||
echo $(( count + 1 )) > count.txt`
|
||||
);
|
||||
|
||||
await exec.exec(`chmod +x ./rebase-exec.sh`);
|
||||
|
||||
await exec.exec(
|
||||
'git',
|
||||
['rebase', `${commitsToImprove[0].id}^`, '--exec', './rebase-exec.sh'],
|
||||
{
|
||||
env: {
|
||||
GIT_SEQUENCE_EDITOR: 'sed -i -e "s/^pick/reword/g"',
|
||||
GIT_COMMITTER_NAME: process.env.GITHUB_ACTOR!,
|
||||
GIT_COMMITTER_EMAIL: `${process.env.GITHUB_ACTOR}@users.noreply.github.com`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const deleteCommitMessageFile = (index: number) =>
|
||||
unlinkSync(`./commit-${index}.txt`);
|
||||
commitsToImprove.forEach((_commit, i) => deleteCommitMessageFile(i));
|
||||
|
||||
unlinkSync('./count.txt');
|
||||
unlinkSync('./rebase-exec.sh');
|
||||
|
||||
outro('Force pushing non-interactively rebased commits into remote.');
|
||||
|
||||
await exec.exec('git', ['status']);
|
||||
|
||||
// Force push the rebased commits
|
||||
await exec.exec('git', ['push', `--force`]);
|
||||
|
||||
outro('Done 🧙');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
intro('OpenCommit — improving lame commit messages');
|
||||
|
||||
try {
|
||||
if (github.context.eventName === 'push') {
|
||||
outro(`Processing commits in a Push event`);
|
||||
|
||||
const payload = github.context.payload as PushEvent;
|
||||
|
||||
const commits = payload.commits;
|
||||
|
||||
// Set local Git user identity for future git history manipulations
|
||||
if (payload.pusher.email)
|
||||
await exec.exec('git', ['config', 'user.email', payload.pusher.email]);
|
||||
|
||||
await exec.exec('git', ['config', 'user.name', payload.pusher.name]);
|
||||
|
||||
await exec.exec('git', ['status']);
|
||||
await exec.exec('git', ['log', '--oneline']);
|
||||
|
||||
await improveCommitMessages(commits);
|
||||
} else {
|
||||
outro('Wrong action.');
|
||||
core.error(
|
||||
`OpenCommit was called on ${github.context.payload.action}. OpenCommit is supposed to be used on "push" action.`
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const err = error?.message || error;
|
||||
core.setFailed(err);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"localLanguage": "english",
|
||||
"commitFix": "fix(server.ts): change port variable case from lowercase port to uppercase PORT to improve semantics",
|
||||
"commitFeat": "feat(server.ts): add support for process.env.PORT environment variable to be able to run app on a configurable port",
|
||||
"commitFix": "fix(server.ts): change port variable case from lowercase port to uppercase PORT",
|
||||
"commitFeat": "feat(server.ts): add support for process.env.PORT environment variable",
|
||||
"commitDescription": "The port variable is now named PORT, which improves consistency with the naming conventions as PORT is a constant. Support for an environment variable allows the application to be more flexible as it can now run on any available port specified via the process.env.PORT environment variable."
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import cs from '../i18n/cs.json';
|
||||
import de from '../i18n/de.json';
|
||||
import en from '../i18n/en.json';
|
||||
import es_ES from '../i18n/es_ES.json';
|
||||
import fr from '../i18n/fr.json';
|
||||
import id_ID from '../i18n/id_ID.json';
|
||||
import it from '../i18n/it.json';
|
||||
import ja from '../i18n/ja.json';
|
||||
import ko from '../i18n/ko.json';
|
||||
import nl from '../i18n/nl.json';
|
||||
import pl from '../i18n/pl.json';
|
||||
import pt_br from '../i18n/pt_br.json';
|
||||
import ru from '../i18n/ru.json';
|
||||
import sv from '../i18n/sv.json';
|
||||
import th from '../i18n/th.json';
|
||||
import tr from '../i18n/tr.json';
|
||||
import vi_VN from '../i18n/vi_VN.json';
|
||||
import zh_CN from '../i18n/zh_CN.json';
|
||||
import zh_TW from '../i18n/zh_TW.json';
|
||||
import en from '../i18n/en.json' assert { type: 'json' };
|
||||
import cs from '../i18n/cs.json' assert { type: 'json' };
|
||||
import de from '../i18n/de.json' assert { type: 'json' };
|
||||
import fr from '../i18n/fr.json' assert { type: 'json' };
|
||||
import it from '../i18n/it.json' assert { type: 'json' };
|
||||
import ko from '../i18n/ko.json' assert { type: 'json' };
|
||||
import zh_CN from '../i18n/zh_CN.json' assert { type: 'json' };
|
||||
import zh_TW from '../i18n/zh_TW.json' assert { type: 'json' };
|
||||
import ja from '../i18n/ja.json' assert { type: 'json' };
|
||||
import pt_br from '../i18n/pt_br.json' assert { type: 'json' };
|
||||
import vi_VN from '../i18n/vi_VN.json' assert { type: 'json' };
|
||||
import es_ES from '../i18n/es_ES.json' assert { type: 'json' };
|
||||
import sv from '../i18n/sv.json' assert { type: 'json' };
|
||||
import nl from '../i18n/nl.json' assert { type: 'json' };
|
||||
import ru from '../i18n/ru.json' assert { type: 'json' };
|
||||
import id_ID from '../i18n/id_ID.json' assert { type: 'json' };
|
||||
import pl from '../i18n/pl.json' assert { type: 'json' };
|
||||
import tr from '../i18n/tr.json' assert { type: 'json' };
|
||||
import th from '../i18n/th.json' assert { type: 'json' };
|
||||
|
||||
export enum I18nLocals {
|
||||
'en' = 'en',
|
||||
@@ -36,7 +36,7 @@ export enum I18nLocals {
|
||||
'id_ID' = 'id_ID',
|
||||
'pl' = 'pl',
|
||||
'tr' = 'tr',
|
||||
'th' = 'th'
|
||||
'th' = 'th',
|
||||
}
|
||||
|
||||
export const i18n = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"localLanguage": "简体中文",
|
||||
"commitFix": "fix(server.ts):将端口变量从小写port改为大写PORT",
|
||||
"commitFeat": "feat(server.ts):添加对process.env.PORT环境变量的支持",
|
||||
"commitFix": "修复(server.ts):将端口变量从小写port改为大写PORT",
|
||||
"commitFeat": "功能(server.ts):添加对process.env.PORT环境变量的支持",
|
||||
"commitDescription": "现在端口变量被命名为PORT,这提高了命名约定的一致性,因为PORT是一个常量。环境变量的支持使应用程序更加灵活,因为它现在可以通过process.env.PORT环境变量在任何可用端口上运行。"
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { spinner } from '@clack/prompts';
|
||||
|
||||
import { getConfig } from '../../commands/config';
|
||||
import { i18n, I18nLocals } from '../../i18n';
|
||||
import { getEngine } from '../../utils/engine';
|
||||
import { COMMITLINT_LLM_CONFIG_PATH } from './constants';
|
||||
import { computeHash } from './crypto';
|
||||
import { commitlintPrompts, inferPromptsFromCommitlintConfig } from './prompts';
|
||||
import { getCommitLintPWDConfig } from './pwd-commitlint';
|
||||
import { CommitlintLLMConfig } from './types';
|
||||
import * as utils from './utils';
|
||||
|
||||
const config = getConfig();
|
||||
const translation = i18n[(config.OCO_LANGUAGE as I18nLocals) || 'en'];
|
||||
|
||||
export const configureCommitlintIntegration = async (force = false) => {
|
||||
const spin = spinner();
|
||||
spin.start('Loading @commitlint configuration');
|
||||
|
||||
const fileExists = await utils.commitlintLLMConfigExists();
|
||||
|
||||
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(
|
||||
// `${OPENCOMMIT_COMMITLINT_CONFIG}-commitlint-debug`,
|
||||
// JSON.stringify(commitLintConfig, null, 2)
|
||||
// );
|
||||
|
||||
const hash = await computeHash(JSON.stringify(commitLintConfig));
|
||||
|
||||
spin.stop(`Read @commitlint configuration (hash: ${hash})`);
|
||||
|
||||
if (fileExists) {
|
||||
// Check if we need to update the prompts.
|
||||
const { hash: existingHash } = await utils.getCommitlintLLMConfig();
|
||||
if (hash === existingHash && !force) {
|
||||
spin.stop(
|
||||
'Hashes are the same, no need to update the config. Run "force" command to bypass.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
spin.start('Generating consistency with given @commitlint rules');
|
||||
|
||||
const prompts = inferPromptsFromCommitlintConfig(commitLintConfig);
|
||||
|
||||
const consistencyPrompts =
|
||||
commitlintPrompts.GEN_COMMITLINT_CONSISTENCY_PROMPT(prompts);
|
||||
|
||||
// debug prompt which will generate a consistency
|
||||
// await fs.writeFile(
|
||||
// `${COMMITLINT_LLM_CONFIG}-debug`,
|
||||
// consistencyPrompts.map((p) => p.content)
|
||||
// );
|
||||
|
||||
const engine = getEngine();
|
||||
let consistency =
|
||||
(await engine.generateCommitMessage(consistencyPrompts)) || '{}';
|
||||
|
||||
// Cleanup the consistency answer. Sometimes 'gpt-3.5-turbo' sends rule's back.
|
||||
prompts.forEach((prompt) => (consistency = consistency.replace(prompt, '')));
|
||||
|
||||
// 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);
|
||||
|
||||
const commitlintLLMConfig: CommitlintLLMConfig = {
|
||||
hash,
|
||||
prompts,
|
||||
consistency: {
|
||||
[translation.localLanguage]: {
|
||||
...JSON.parse(consistency as string)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await utils.writeCommitlintLLMConfig(commitlintLLMConfig);
|
||||
|
||||
spin.stop(`Done - please review contents of ${COMMITLINT_LLM_CONFIG_PATH}`);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const COMMITLINT_LLM_CONFIG_PATH = `${process.env.PWD}/.opencommit-commitlint`;
|
||||
@@ -1,15 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const computeHash = async (
|
||||
content: string,
|
||||
algorithm: string = 'sha256'
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const hash = crypto.createHash(algorithm);
|
||||
hash.update(content);
|
||||
return hash.digest('hex');
|
||||
} catch (error) {
|
||||
console.error('Error while computing hash:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import { OpenAI } from 'openai';
|
||||
|
||||
import { outro } from '@clack/prompts';
|
||||
import {
|
||||
PromptConfig,
|
||||
QualifiedConfig,
|
||||
RuleConfigSeverity,
|
||||
RuleConfigTuple
|
||||
} from '@commitlint/types';
|
||||
|
||||
import { getConfig } from '../../commands/config';
|
||||
import { i18n, I18nLocals } from '../../i18n';
|
||||
import { IDENTITY, INIT_DIFF_PROMPT } from '../../prompts';
|
||||
|
||||
const config = getConfig();
|
||||
const translation = i18n[(config.OCO_LANGUAGE as I18nLocals) || 'en'];
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: {
|
||||
[K in keyof T[P]]?: T[P][K];
|
||||
};
|
||||
};
|
||||
|
||||
type PromptFunction = (
|
||||
applicable: string,
|
||||
value: any,
|
||||
prompt: DeepPartial<PromptConfig>
|
||||
) => string;
|
||||
|
||||
type PromptResolverFunction = (
|
||||
key: string,
|
||||
applicable: string,
|
||||
value: any,
|
||||
prompt?: DeepPartial<PromptConfig>
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Extracts more contexte for each type-enum.
|
||||
* IDEA: replicate the concept for scopes and refactor to a generic feature.
|
||||
*/
|
||||
const getTypeRuleExtraDescription = (
|
||||
type: string,
|
||||
prompt?: DeepPartial<PromptConfig>
|
||||
) => prompt?.questions?.type?.enum?.[type]?.description;
|
||||
|
||||
/*
|
||||
IDEA: Compress llm readable prompt for each section of commit message: one line for header, one line for scope, etc.
|
||||
- The type must be in lowercase and should be one of the following values: featuring, fixing, documenting, styling, refactoring, testing, chores, perf, build, ci, revert.
|
||||
- The scope should not be empty and provide context for the change (e.g., module or file changed).
|
||||
- The subject should not be empty, should not end with a period, and should provide a concise description of the change. It should not be in sentence-case, start-case, pascal-case, or upper-case.
|
||||
*/
|
||||
const llmReadableRules: {
|
||||
[ruleName: string]: PromptResolverFunction;
|
||||
} = {
|
||||
blankline: (key, applicable) =>
|
||||
`There should ${applicable} be a blank line at the beginning of the ${key}.`,
|
||||
caseRule: (key, applicable, value: string | Array<string>) =>
|
||||
`The ${key} should ${applicable} be in ${
|
||||
Array.isArray(value)
|
||||
? `one of the following case:
|
||||
- ${value.join('\n - ')}.`
|
||||
: `${value} case.`
|
||||
}`,
|
||||
emptyRule: (key, applicable) => `The ${key} should ${applicable} be empty.`,
|
||||
enumRule: (key, applicable, value: string | Array<string>) =>
|
||||
`The ${key} should ${applicable} be one of the following values:
|
||||
- ${Array.isArray(value) ? value.join('\n - ') : value}.`,
|
||||
enumTypeRule: (key, applicable, value: string | Array<string>, prompt) =>
|
||||
`The ${key} should ${applicable} be one of the following values:
|
||||
- ${
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.map((v) => {
|
||||
const description = getTypeRuleExtraDescription(v, prompt);
|
||||
if (description) {
|
||||
return `${v} (${description})`;
|
||||
} else return v;
|
||||
})
|
||||
.join('\n - ')
|
||||
: value
|
||||
}.`,
|
||||
fullStopRule: (key, applicable, value: string) =>
|
||||
`The ${key} should ${applicable} end with '${value}'.`,
|
||||
maxLengthRule: (key, applicable, value: string) =>
|
||||
`The ${key} should ${applicable} have ${value} characters or less.`,
|
||||
minLengthRule: (key, applicable, value: string) =>
|
||||
`The ${key} should ${applicable} have ${value} characters or more.`
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO: Validate rules to every rule in the @commitlint configuration.
|
||||
* IDEA: Plugins can extend the list of rule. Provide user with a way to infer or extend when "No prompt handler for rule".
|
||||
*/
|
||||
const rulesPrompts: {
|
||||
[ruleName: string]: PromptFunction;
|
||||
} = {
|
||||
'body-case': (applicable: string, value: string | Array<string>) =>
|
||||
llmReadableRules.caseRule('body', applicable, value),
|
||||
'body-empty': (applicable: string) =>
|
||||
llmReadableRules.emptyRule('body', applicable, undefined),
|
||||
'body-full-stop': (applicable: string, value: string) =>
|
||||
llmReadableRules.fullStopRule('body', applicable, value),
|
||||
'body-leading-blank': (applicable: string) =>
|
||||
llmReadableRules.blankline('body', applicable, undefined),
|
||||
'body-max-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.maxLengthRule('body', applicable, value),
|
||||
'body-max-line-length': (applicable: string, value: string) =>
|
||||
`Each line of the body should ${applicable} have ${value} characters or less.`,
|
||||
'body-min-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.minLengthRule('body', applicable, value),
|
||||
'footer-case': (applicable: string, value: string | Array<string>) =>
|
||||
llmReadableRules.caseRule('footer', applicable, value),
|
||||
'footer-empty': (applicable: string) =>
|
||||
llmReadableRules.emptyRule('footer', applicable, undefined),
|
||||
'footer-leading-blank': (applicable: string) =>
|
||||
llmReadableRules.blankline('footer', applicable, undefined),
|
||||
'footer-max-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.maxLengthRule('footer', applicable, value),
|
||||
'footer-max-line-length': (applicable: string, value: string) =>
|
||||
`Each line of the footer should ${applicable} have ${value} characters or less.`,
|
||||
'footer-min-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.minLengthRule('footer', applicable, value),
|
||||
'header-case': (applicable: string, value: string | Array<string>) =>
|
||||
llmReadableRules.caseRule('header', applicable, value),
|
||||
'header-full-stop': (applicable: string, value: string) =>
|
||||
llmReadableRules.fullStopRule('header', applicable, value),
|
||||
'header-max-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.maxLengthRule('header', applicable, value),
|
||||
'header-min-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.minLengthRule('header', applicable, value),
|
||||
'references-empty': (applicable: string) =>
|
||||
llmReadableRules.emptyRule('references section', applicable, undefined),
|
||||
'scope-case': (applicable: string, value: string | Array<string>) =>
|
||||
llmReadableRules.caseRule('scope', applicable, value),
|
||||
'scope-empty': (applicable: string) =>
|
||||
llmReadableRules.emptyRule('scope', applicable, undefined),
|
||||
'scope-enum': (applicable: string, value: string | Array<string>) =>
|
||||
llmReadableRules.enumRule('type', applicable, value),
|
||||
'scope-max-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.maxLengthRule('scope', applicable, value),
|
||||
'scope-min-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.minLengthRule('scope', applicable, value),
|
||||
'signed-off-by': (applicable: string, value: string) =>
|
||||
`The commit message should ${applicable} have a "Signed-off-by" line with the value "${value}".`,
|
||||
'subject-case': (applicable: string, value: string | Array<string>) =>
|
||||
llmReadableRules.caseRule('subject', applicable, value),
|
||||
'subject-empty': (applicable: string) =>
|
||||
llmReadableRules.emptyRule('subject', applicable, undefined),
|
||||
'subject-full-stop': (applicable: string, value: string) =>
|
||||
llmReadableRules.fullStopRule('subject', applicable, value),
|
||||
'subject-max-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.maxLengthRule('subject', applicable, value),
|
||||
'subject-min-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.minLengthRule('subject', applicable, value),
|
||||
'type-case': (applicable: string, value: string | Array<string>) =>
|
||||
llmReadableRules.caseRule('type', applicable, value),
|
||||
'type-empty': (applicable: string) =>
|
||||
llmReadableRules.emptyRule('type', applicable, undefined),
|
||||
'type-enum': (applicable: string, value: string | Array<string>, prompt) =>
|
||||
llmReadableRules.enumTypeRule('type', applicable, value, prompt),
|
||||
'type-max-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.maxLengthRule('type', applicable, value),
|
||||
'type-min-length': (applicable: string, value: string) =>
|
||||
llmReadableRules.minLengthRule('type', applicable, value)
|
||||
};
|
||||
|
||||
const getPrompt = (
|
||||
ruleName: string,
|
||||
ruleConfig: RuleConfigTuple<unknown>,
|
||||
prompt: DeepPartial<PromptConfig>
|
||||
) => {
|
||||
const [severity, applicable, value] = ruleConfig;
|
||||
|
||||
// Should we exclude "Disabled" properties?
|
||||
// Is this used to disable a subjacent rule when extending presets?
|
||||
if (severity === RuleConfigSeverity.Disabled) return null;
|
||||
|
||||
const promptFn = rulesPrompts[ruleName];
|
||||
if (promptFn) {
|
||||
return promptFn(applicable, value, prompt);
|
||||
}
|
||||
|
||||
// Plugins may add their custom rules.
|
||||
// We might want to call OpenAI to build this rule's llm-readable prompt.
|
||||
outro(`${chalk.red('✖')} No prompt handler for rule "${ruleName}".`);
|
||||
return `Please manualy set the prompt for rule "${ruleName}".`;
|
||||
};
|
||||
|
||||
export const inferPromptsFromCommitlintConfig = (
|
||||
config: QualifiedConfig
|
||||
): string[] => {
|
||||
const { rules, prompt } = config;
|
||||
if (!rules) return [];
|
||||
return Object.keys(rules)
|
||||
.map((ruleName) =>
|
||||
getPrompt(ruleName, rules[ruleName] as RuleConfigTuple<unknown>, prompt)
|
||||
)
|
||||
.filter((prompt) => prompt !== null) as string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Breaking down commit message structure for conventional commit, and mapping bits with
|
||||
* ubiquitous language from @commitlint.
|
||||
* While gpt-4 does this on it self, gpt-3.5 can't map this on his own atm.
|
||||
*/
|
||||
const STRUCTURE_OF_COMMIT = `
|
||||
- Header of commit is composed of type, scope, subject: <type-of-commit>(<scope-of-commit>): <subject-of-commit>
|
||||
- Description of commit is composed of body and footer (optional): <body-of-commit>\n<footer(s)-of-commit>`;
|
||||
|
||||
// Prompt to generate LLM-readable rules based on @commitlint rules.
|
||||
const GEN_COMMITLINT_CONSISTENCY_PROMPT = (
|
||||
prompts: string[]
|
||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] => [
|
||||
{
|
||||
role: 'system',
|
||||
content: `${IDENTITY} Your mission is to create clean and comprehensive commit messages for two different changes in a single codebase and output them in the provided JSON format: one for a bug fix and another for a new feature.
|
||||
|
||||
Here are the specific requirements and conventions that should be strictly followed:
|
||||
|
||||
Commit Message Conventions:
|
||||
- The commit message consists of three parts: Header, Body, and Footer.
|
||||
- Header:
|
||||
- Format: \`<type>(<scope>): <subject>\`
|
||||
- ${prompts.join('\n- ')}
|
||||
|
||||
JSON Output Format:
|
||||
- The JSON output should contain the commit messages for a bug fix and a new feature in the following format:
|
||||
\`\`\`json
|
||||
{
|
||||
"localLanguage": "${translation.localLanguage}",
|
||||
"commitFix": "<Header of commit for bug fix>",
|
||||
"commitFeat": "<Header of commit for feature>",
|
||||
"commitDescription": "<Description of commit for both the bug fix and the feature>"
|
||||
}
|
||||
\`\`\`
|
||||
- The "commitDescription" should not include the commit message’s header, only the description.
|
||||
- Description should not be more than 74 characters.
|
||||
|
||||
Additional Details:
|
||||
- Changing the variable 'port' to uppercase 'PORT' is considered a bug fix.
|
||||
- Allowing the server to listen on a port specified through the environment variable is considered a new feature.
|
||||
|
||||
Example Git Diff is to follow:`
|
||||
},
|
||||
INIT_DIFF_PROMPT
|
||||
];
|
||||
|
||||
/**
|
||||
* Prompt to have LLM generate a message using @commitlint rules.
|
||||
*
|
||||
* @param language
|
||||
* @param prompts
|
||||
* @returns
|
||||
*/
|
||||
const INIT_MAIN_PROMPT = (
|
||||
language: string,
|
||||
prompts: string[]
|
||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam => ({
|
||||
role: 'system',
|
||||
content: `${IDENTITY} Your mission is to create clean and comprehensive commit messages in the given @commitlint convention and explain WHAT were the changes ${
|
||||
config.OCO_WHY ? 'and WHY the changes were done' : ''
|
||||
}. I'll send you an output of 'git diff --staged' command, and you convert it into a commit message.
|
||||
${
|
||||
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- ')}
|
||||
|
||||
The conventions refers to the following structure of commit message:
|
||||
${STRUCTURE_OF_COMMIT}`
|
||||
});
|
||||
|
||||
export const commitlintPrompts = {
|
||||
INIT_MAIN_PROMPT,
|
||||
GEN_COMMITLINT_CONSISTENCY_PROMPT
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const findModulePath = (moduleName: string) => {
|
||||
const searchPaths = [
|
||||
path.join('node_modules', moduleName),
|
||||
path.join('node_modules', '.pnpm')
|
||||
];
|
||||
|
||||
for (const basePath of searchPaths) {
|
||||
try {
|
||||
const resolvedPath = require.resolve(moduleName, { paths: [basePath] });
|
||||
return resolvedPath;
|
||||
} catch {
|
||||
// Continue to the next search path if the module is not found
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Cannot find module ${moduleName}`);
|
||||
};
|
||||
|
||||
const getCommitLintModuleType = async (): Promise<'cjs' | 'esm'> => {
|
||||
const packageFile = '@commitlint/load/package.json';
|
||||
const packageJsonPath = findModulePath(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
|
||||
* directory (`process.env.PWD`) by requiring the `load` module from the `@commitlint` package.
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export const getCommitLintPWDConfig =
|
||||
async (): Promise<QualifiedConfigOnAnyVersion | null> => {
|
||||
let load: Function, modulePath: string;
|
||||
switch (await getCommitLintModuleType()) {
|
||||
case 'cjs':
|
||||
/**
|
||||
* CommonJS (<= commitlint@v18.x.x.)
|
||||
*/
|
||||
modulePath = findModulePath('@commitlint/load');
|
||||
load = require(modulePath).default;
|
||||
break;
|
||||
case 'esm':
|
||||
/**
|
||||
* ES Module (commitlint@v19.x.x. <= )
|
||||
* Directory import is not supported in ES Module resolution, so import the file directly
|
||||
*/
|
||||
modulePath = await findModulePath('@commitlint/load/lib/load.js');
|
||||
load = (await import(modulePath)).default;
|
||||
break;
|
||||
}
|
||||
|
||||
if (load && typeof load === 'function') {
|
||||
return await load();
|
||||
}
|
||||
|
||||
// @commitlint/load is not a function
|
||||
return null;
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { i18n } from '../../i18n';
|
||||
|
||||
export type ConsistencyPrompt = (typeof i18n)[keyof typeof i18n];
|
||||
|
||||
export type CommitlintLLMConfig = {
|
||||
hash: string;
|
||||
prompts: string[];
|
||||
consistency: {
|
||||
[key: string]: ConsistencyPrompt;
|
||||
};
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import { COMMITLINT_LLM_CONFIG_PATH } from './constants';
|
||||
import { CommitlintLLMConfig } from './types';
|
||||
|
||||
/**
|
||||
* Removes the "\n" only if occurring twice
|
||||
*/
|
||||
export const removeDoubleNewlines = (input: string): string => {
|
||||
const pattern = /\\n\\n/g;
|
||||
if (pattern.test(input)) {
|
||||
const newInput = input.replace(pattern, '');
|
||||
return removeDoubleNewlines(newInput);
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
export const getJSONBlock = (input: string): string => {
|
||||
const jsonIndex = input.search('```json');
|
||||
if (jsonIndex > -1) {
|
||||
input = input.slice(jsonIndex + 8);
|
||||
const endJsonIndex = input.search('```');
|
||||
input = input.slice(0, endJsonIndex);
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
export const commitlintLLMConfigExists = async (): Promise<boolean> => {
|
||||
let exists;
|
||||
try {
|
||||
await fs.access(COMMITLINT_LLM_CONFIG_PATH);
|
||||
exists = true;
|
||||
} catch (e) {
|
||||
exists = false;
|
||||
}
|
||||
|
||||
return exists;
|
||||
};
|
||||
|
||||
export const writeCommitlintLLMConfig = async (
|
||||
commitlintLLMConfig: CommitlintLLMConfig
|
||||
): Promise<void> => {
|
||||
await fs.writeFile(
|
||||
COMMITLINT_LLM_CONFIG_PATH,
|
||||
JSON.stringify(commitlintLLMConfig, null, 2)
|
||||
);
|
||||
};
|
||||
|
||||
export const getCommitlintLLMConfig =
|
||||
async (): Promise<CommitlintLLMConfig> => {
|
||||
const content = await fs.readFile(COMMITLINT_LLM_CONFIG_PATH);
|
||||
const commitLintLLMConfig = JSON.parse(
|
||||
content.toString()
|
||||
) as CommitlintLLMConfig;
|
||||
return commitLintLLMConfig;
|
||||
};
|
||||
222
src/prompts.ts
222
src/prompts.ts
@@ -1,222 +0,0 @@
|
||||
import { note } from '@clack/prompts';
|
||||
import { OpenAI } from 'openai';
|
||||
import { getConfig } from './commands/config';
|
||||
import { i18n, I18nLocals } from './i18n';
|
||||
import { configureCommitlintIntegration } from './modules/commitlint/config';
|
||||
import { commitlintPrompts } from './modules/commitlint/prompts';
|
||||
import { ConsistencyPrompt } from './modules/commitlint/types';
|
||||
import * as utils from './modules/commitlint/utils';
|
||||
import { removeConventionalCommitWord } from './utils/removeConventionalCommitWord';
|
||||
|
||||
const config = getConfig();
|
||||
const translation = i18n[(config.OCO_LANGUAGE as I18nLocals) || 'en'];
|
||||
|
||||
export const IDENTITY =
|
||||
'You are to act as an author of a commit message in git.';
|
||||
|
||||
const GITMOJI_HELP = `Use GitMoji convention to preface the commit. Here are some help to choose the right emoji (emoji, description):
|
||||
🐛, Fix a bug;
|
||||
✨, Introduce new features;
|
||||
📝, Add or update documentation;
|
||||
🚀, Deploy stuff;
|
||||
✅, Add, update, or pass tests;
|
||||
♻️, Refactor code;
|
||||
⬆️, Upgrade dependencies;
|
||||
🔧, Add or update configuration files;
|
||||
🌐, Internationalization and localization;
|
||||
💡, Add or update comments in source code;`;
|
||||
|
||||
const FULL_GITMOJI_SPEC = `${GITMOJI_HELP}
|
||||
🎨, Improve structure / format of the code;
|
||||
⚡️, Improve performance;
|
||||
🔥, Remove code or files;
|
||||
🚑️, Critical hotfix;
|
||||
💄, Add or update the UI and style files;
|
||||
🎉, Begin a project;
|
||||
🔒️, Fix security issues;
|
||||
🔐, Add or update secrets;
|
||||
🔖, Release / Version tags;
|
||||
🚨, Fix compiler / linter warnings;
|
||||
🚧, Work in progress;
|
||||
💚, Fix CI Build;
|
||||
⬇️, Downgrade dependencies;
|
||||
📌, Pin dependencies to specific versions;
|
||||
👷, Add or update CI build system;
|
||||
📈, Add or update analytics or track code;
|
||||
➕, Add a dependency;
|
||||
➖, Remove a dependency;
|
||||
🔨, Add or update development scripts;
|
||||
✏️, Fix typos;
|
||||
💩, Write bad code that needs to be improved;
|
||||
⏪️, Revert changes;
|
||||
🔀, Merge branches;
|
||||
📦️, Add or update compiled files or packages;
|
||||
👽️, Update code due to external API changes;
|
||||
🚚, Move or rename resources (e.g.: files, paths, routes);
|
||||
📄, Add or update license;
|
||||
💥, Introduce breaking changes;
|
||||
🍱, Add or update assets;
|
||||
♿️, Improve accessibility;
|
||||
🍻, Write code drunkenly;
|
||||
💬, Add or update text and literals;
|
||||
🗃️, Perform database related changes;
|
||||
🔊, Add or update logs;
|
||||
🔇, Remove logs;
|
||||
👥, Add or update contributor(s);
|
||||
🚸, Improve user experience / usability;
|
||||
🏗️, Make architectural changes;
|
||||
📱, Work on responsive design;
|
||||
🤡, Mock things;
|
||||
🥚, Add or update an easter egg;
|
||||
🙈, Add or update a .gitignore file;
|
||||
📸, Add or update snapshots;
|
||||
⚗️, Perform experiments;
|
||||
🔍️, Improve SEO;
|
||||
🏷️, Add or update types;
|
||||
🌱, Add or update seed files;
|
||||
🚩, Add, update, or remove feature flags;
|
||||
🥅, Catch errors;
|
||||
💫, Add or update animations and transitions;
|
||||
🗑️, Deprecate code that needs to be cleaned up;
|
||||
🛂, Work on code related to authorization, roles and permissions;
|
||||
🩹, Simple fix for a non-critical issue;
|
||||
🧐, Data exploration/inspection;
|
||||
⚰️, Remove dead code;
|
||||
🧪, Add a failing test;
|
||||
👔, Add or update business logic;
|
||||
🩺, Add or update healthcheck;
|
||||
🧱, Infrastructure related changes;
|
||||
🧑💻, Improve developer experience;
|
||||
💸, Add sponsorships or money related infrastructure;
|
||||
🧵, Add or update code related to multithreading or concurrency;
|
||||
🦺, Add or update code related to validation.`;
|
||||
|
||||
const CONVENTIONAL_COMMIT_KEYWORDS =
|
||||
'Do not preface the commit with anything, except for the conventional commit keywords: fix, feat, build, chore, ci, docs, style, refactor, perf, test.';
|
||||
|
||||
const getCommitConvention = (fullGitMojiSpec: boolean) =>
|
||||
config.OCO_EMOJI
|
||||
? fullGitMojiSpec
|
||||
? FULL_GITMOJI_SPEC
|
||||
: GITMOJI_HELP
|
||||
: CONVENTIONAL_COMMIT_KEYWORDS;
|
||||
|
||||
const getDescriptionInstruction = () =>
|
||||
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.";
|
||||
|
||||
const getOneLineCommitInstruction = () =>
|
||||
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.'
|
||||
: '';
|
||||
|
||||
const INIT_MAIN_PROMPT = (
|
||||
language: string,
|
||||
fullGitMojiSpec: boolean
|
||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam => ({
|
||||
role: 'system',
|
||||
content: (() => {
|
||||
const commitConvention = fullGitMojiSpec
|
||||
? 'GitMoji specification'
|
||||
: 'Conventional Commit Convention';
|
||||
const missionStatement = `${IDENTITY} Your mission is to create clean and comprehensive commit messages as per the ${commitConvention} and explain WHAT were the changes and mainly WHY the changes were done.`;
|
||||
const diffInstruction =
|
||||
"I'll send you an output of 'git diff --staged' command, and you are to convert it into a commit message.";
|
||||
const conventionGuidelines = getCommitConvention(fullGitMojiSpec);
|
||||
const descriptionGuideline = getDescriptionInstruction();
|
||||
const oneLineCommitGuideline = getOneLineCommitInstruction();
|
||||
const generalGuidelines = `Use the present tense. Lines must not be longer than 74 characters. Use ${language} for the commit message.`;
|
||||
|
||||
return `${missionStatement}\n${diffInstruction}\n${conventionGuidelines}\n${descriptionGuideline}\n${oneLineCommitGuideline}\n${generalGuidelines}`;
|
||||
})()
|
||||
});
|
||||
|
||||
export const INIT_DIFF_PROMPT: OpenAI.Chat.Completions.ChatCompletionMessageParam =
|
||||
{
|
||||
role: 'user',
|
||||
content: `diff --git a/src/server.ts b/src/server.ts
|
||||
index ad4db42..f3b18a9 100644
|
||||
--- a/src/server.ts
|
||||
+++ b/src/server.ts
|
||||
@@ -10,7 +10,7 @@
|
||||
import {
|
||||
initWinstonLogger();
|
||||
|
||||
const app = express();
|
||||
-const port = 7799;
|
||||
+const PORT = 7799;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
app.use((_, res, next) => {
|
||||
// ROUTES
|
||||
app.use(PROTECTED_ROUTER_URL, protectedRouter);
|
||||
|
||||
-app.listen(port, () => {
|
||||
- console.log(\`Server listening on port \${port}\`);
|
||||
+app.listen(process.env.PORT || PORT, () => {
|
||||
+ console.log(\`Server listening on port \${PORT}\`);
|
||||
});`
|
||||
};
|
||||
|
||||
const getContent = (translation: ConsistencyPrompt) => {
|
||||
const fix = config.OCO_EMOJI
|
||||
? `🐛 ${removeConventionalCommitWord(translation.commitFix)}`
|
||||
: translation.commitFix;
|
||||
|
||||
const feat = config.OCO_EMOJI
|
||||
? `✨ ${removeConventionalCommitWord(translation.commitFeat)}`
|
||||
: translation.commitFeat;
|
||||
|
||||
const description = config.OCO_DESCRIPTION
|
||||
? translation.commitDescription
|
||||
: '';
|
||||
|
||||
return `${fix}\n${feat}\n${description}`;
|
||||
};
|
||||
|
||||
const INIT_CONSISTENCY_PROMPT = (
|
||||
translation: ConsistencyPrompt
|
||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam => ({
|
||||
role: 'assistant',
|
||||
content: getContent(translation)
|
||||
});
|
||||
|
||||
export const getMainCommitPrompt = async (
|
||||
fullGitMojiSpec: boolean
|
||||
): Promise<Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>> => {
|
||||
switch (config.OCO_PROMPT_MODULE) {
|
||||
case '@commitlint':
|
||||
if (!(await utils.commitlintLLMConfigExists())) {
|
||||
note(
|
||||
`OCO_PROMPT_MODULE is @commitlint but you haven't generated consistency for this project yet.`
|
||||
);
|
||||
await configureCommitlintIntegration();
|
||||
}
|
||||
|
||||
// Replace example prompt with a prompt that's generated by OpenAI for the commitlint config.
|
||||
const commitLintConfig = await utils.getCommitlintLLMConfig();
|
||||
|
||||
return [
|
||||
commitlintPrompts.INIT_MAIN_PROMPT(
|
||||
translation.localLanguage,
|
||||
commitLintConfig.prompts
|
||||
),
|
||||
INIT_DIFF_PROMPT,
|
||||
INIT_CONSISTENCY_PROMPT(
|
||||
commitLintConfig.consistency[
|
||||
translation.localLanguage
|
||||
] as ConsistencyPrompt
|
||||
)
|
||||
];
|
||||
|
||||
default:
|
||||
return [
|
||||
INIT_MAIN_PROMPT(translation.localLanguage, fullGitMojiSpec),
|
||||
INIT_DIFF_PROMPT,
|
||||
INIT_CONSISTENCY_PROMPT(translation)
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,7 @@
|
||||
import { getOpenCommitLatestVersion } from '../api';
|
||||
import currentPackage from '../../package.json' assert { type: 'json' };
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { outro } from '@clack/prompts';
|
||||
|
||||
import currentPackage from '../../package.json';
|
||||
import { getOpenCommitLatestVersion } from '../version';
|
||||
|
||||
export const checkIsLatestVersion = async () => {
|
||||
const latestVersion = await getOpenCommitLatestVersion();
|
||||
|
||||
@@ -12,7 +9,7 @@ export const checkIsLatestVersion = async () => {
|
||||
const currentVersion = currentPackage.version;
|
||||
|
||||
if (currentVersion !== latestVersion) {
|
||||
outro(
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
`
|
||||
You are not using the latest stable version of OpenCommit with new features and bug fixes.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { getConfig, OCO_AI_PROVIDER_ENUM } from '../commands/config';
|
||||
import { AnthropicEngine } from '../engine/anthropic';
|
||||
import { AzureEngine } from '../engine/azure';
|
||||
import { AiEngine } from '../engine/Engine';
|
||||
import { FlowiseAi } from '../engine/flowise';
|
||||
import { Gemini } from '../engine/gemini';
|
||||
import { OllamaAi } from '../engine/ollama';
|
||||
import { OpenAiEngine } from '../engine/openAi';
|
||||
import { TestAi, TestMockType } from '../engine/testAi';
|
||||
|
||||
export function getEngine(): AiEngine {
|
||||
const config = getConfig();
|
||||
const provider = config.OCO_AI_PROVIDER;
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
model: config.OCO_MODEL!,
|
||||
maxTokensOutput: config.OCO_TOKENS_MAX_OUTPUT!,
|
||||
maxTokensInput: config.OCO_TOKENS_MAX_INPUT!,
|
||||
baseURL: config.OCO_OPENAI_BASE_PATH!
|
||||
};
|
||||
|
||||
switch (provider) {
|
||||
case OCO_AI_PROVIDER_ENUM.OLLAMA:
|
||||
return new OllamaAi({
|
||||
...DEFAULT_CONFIG,
|
||||
apiKey: '',
|
||||
baseURL: config.OCO_OLLAMA_API_URL!
|
||||
});
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.ANTHROPIC:
|
||||
return new AnthropicEngine({
|
||||
...DEFAULT_CONFIG,
|
||||
apiKey: config.OCO_ANTHROPIC_API_KEY!
|
||||
});
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.TEST:
|
||||
return new TestAi(config.OCO_TEST_MOCK_TYPE as TestMockType);
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.GEMINI:
|
||||
return new Gemini({
|
||||
...DEFAULT_CONFIG,
|
||||
apiKey: config.OCO_GEMINI_API_KEY!,
|
||||
baseURL: config.OCO_GEMINI_BASE_PATH!
|
||||
});
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.AZURE:
|
||||
return new AzureEngine({
|
||||
...DEFAULT_CONFIG,
|
||||
apiKey: config.OCO_AZURE_API_KEY!
|
||||
});
|
||||
|
||||
case OCO_AI_PROVIDER_ENUM.FLOWISE:
|
||||
return new FlowiseAi({
|
||||
...DEFAULT_CONFIG,
|
||||
baseURL: config.OCO_FLOWISE_ENDPOINT || DEFAULT_CONFIG.baseURL,
|
||||
apiKey: config.OCO_FLOWISE_API_KEY!
|
||||
});
|
||||
|
||||
default:
|
||||
return new OpenAiEngine({
|
||||
...DEFAULT_CONFIG,
|
||||
apiKey: config.OCO_OPENAI_API_KEY!
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { execa } from 'execa';
|
||||
import { outro, spinner } from '@clack/prompts';
|
||||
import { readFileSync } from 'fs';
|
||||
import ignore, { Ignore } from 'ignore';
|
||||
|
||||
import { outro, spinner } from '@clack/prompts';
|
||||
|
||||
export const assertGitRepo = async () => {
|
||||
try {
|
||||
await execa('git', ['rev-parse']);
|
||||
@@ -16,26 +15,23 @@ export const assertGitRepo = async () => {
|
||||
// (file) => `:(exclude)${file}`
|
||||
// );
|
||||
|
||||
export const getIgnoredFolders = (): string[] => {
|
||||
try {
|
||||
return readFileSync('.opencommitignore').toString().split('\n');
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getOpenCommitIgnore = (): Ignore => {
|
||||
const ig = ignore();
|
||||
const ignorePatterns = getIgnoredFolders();
|
||||
ig.add(ignorePatterns);
|
||||
|
||||
try {
|
||||
ig.add(readFileSync('.opencommitignore').toString().split('\n'));
|
||||
} catch (e) {}
|
||||
|
||||
return ig;
|
||||
};
|
||||
|
||||
export const getCoreHooksPath = async (): Promise<string> => {
|
||||
const { stdout } = await execa('git', ['config', 'core.hooksPath']);
|
||||
export const getCoreHooksPath = async(): Promise<string> => {
|
||||
const { stdout } = await execa('git', [
|
||||
'config',
|
||||
'core.hooksPath']);
|
||||
|
||||
return stdout;
|
||||
};
|
||||
}
|
||||
|
||||
export const getStagedFiles = async (): Promise<string[]> => {
|
||||
const { stdout: gitDir } = await execa('git', [
|
||||
@@ -80,30 +76,19 @@ 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');
|
||||
};
|
||||
|
||||
export const getDiff = async ({ files }: { files: string[] }) => {
|
||||
const lockFiles = files.filter(
|
||||
(file) =>
|
||||
file.includes('.lock') ||
|
||||
file.includes('-lock.') ||
|
||||
file.includes('.svg') ||
|
||||
file.includes('.png') ||
|
||||
file.includes('.jpg') ||
|
||||
file.includes('.jpeg') ||
|
||||
file.includes('.webp') ||
|
||||
file.includes('.gif')
|
||||
(file) => file.includes('.lock') || file.includes('-lock.')
|
||||
);
|
||||
|
||||
if (lockFiles.length) {
|
||||
outro(
|
||||
`Some files are excluded by default from 'git diff'. No commit messages are generated for this files:\n${lockFiles.join(
|
||||
`Some files are '.lock' files which are excluded by default from 'git diff'. No commit messages are generated for this files:\n${lockFiles.join(
|
||||
'\n'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { tokenCount } from './tokenCount';
|
||||
|
||||
export function mergeDiffs(arr: string[], maxStringLength: number): string[] {
|
||||
const mergedArr: string[] = [];
|
||||
let currentItem: string = arr[0];
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export function randomIntFromInterval(min: number, max: number) {
|
||||
// min and max included
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function removeConventionalCommitWord(message: string): string {
|
||||
return message.replace(/^(fix|feat)\((.+?)\):/, '($2):');
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import cl100k_base from '@dqbd/tiktoken/encoders/cl100k_base.json';
|
||||
import { Tiktoken } from '@dqbd/tiktoken/lite';
|
||||
import cl100k_base from '@dqbd/tiktoken/encoders/cl100k_base.json' assert { type: 'json' };
|
||||
|
||||
export function tokenCount(content: string): number {
|
||||
const encoding = new Tiktoken(
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { outro } from '@clack/prompts';
|
||||
import { execa } from 'execa';
|
||||
|
||||
export const getOpenCommitLatestVersion = async (): Promise<
|
||||
string | undefined
|
||||
> => {
|
||||
try {
|
||||
const { stdout } = await execa('npm', ['view', 'opencommit', 'version']);
|
||||
return stdout;
|
||||
} catch (_) {
|
||||
outro('Error while getting the latest version of opencommit');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
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('consistency')).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();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional']
|
||||
};
|
||||
2029
test/e2e/prompt-module/data/commitlint_18/package-lock.json
generated
2029
test/e2e/prompt-module/data/commitlint_18/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
extends: ['@commitlint/config-conventional']
|
||||
};
|
||||
1453
test/e2e/prompt-module/data/commitlint_19/package-lock.json
generated
1453
test/e2e/prompt-module/data/commitlint_19/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional']
|
||||
};
|
||||
1671
test/e2e/prompt-module/data/commitlint_9/package-lock.json
generated
1671
test/e2e/prompt-module/data/commitlint_9/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,33 +0,0 @@
|
||||
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));
|
||||
@@ -1,10 +0,0 @@
|
||||
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 })
|
||||
@@ -1,295 +0,0 @@
|
||||
import { existsSync, readFileSync, rmSync } from 'fs';
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
getConfig,
|
||||
setConfig
|
||||
} from '../../src/commands/config';
|
||||
import { prepareFile } from './utils';
|
||||
import { dirname } from 'path';
|
||||
|
||||
describe('config', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let globalConfigFile: { filePath: string; cleanup: () => Promise<void> };
|
||||
let envConfigFile: { filePath: string; cleanup: () => Promise<void> };
|
||||
|
||||
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(async () => {
|
||||
resetEnv(originalEnv);
|
||||
if (globalConfigFile) await globalConfigFile.cleanup();
|
||||
if (envConfigFile) await envConfigFile.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (globalConfigFile) await globalConfigFile.cleanup();
|
||||
if (envConfigFile) await envConfigFile.cleanup();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetEnv(originalEnv);
|
||||
});
|
||||
|
||||
const generateConfig = async (
|
||||
fileName: string,
|
||||
content: Record<string, string>
|
||||
) => {
|
||||
const fileContent = Object.entries(content)
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join('\n');
|
||||
return await prepareFile(fileName, fileContent);
|
||||
};
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('should prioritize local .env over global .opencommit config', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {
|
||||
OCO_OPENAI_API_KEY: 'global-key',
|
||||
OCO_MODEL: 'gpt-3.5-turbo',
|
||||
OCO_LANGUAGE: 'en'
|
||||
});
|
||||
|
||||
envConfigFile = await generateConfig('.env', {
|
||||
OCO_OPENAI_API_KEY: 'local-key',
|
||||
OCO_ANTHROPIC_API_KEY: 'local-anthropic-key',
|
||||
OCO_LANGUAGE: 'fr'
|
||||
});
|
||||
|
||||
const config = getConfig({
|
||||
globalPath: globalConfigFile.filePath,
|
||||
envPath: envConfigFile.filePath
|
||||
});
|
||||
|
||||
expect(config).not.toEqual(null);
|
||||
expect(config.OCO_OPENAI_API_KEY).toEqual('local-key');
|
||||
expect(config.OCO_MODEL).toEqual('gpt-3.5-turbo');
|
||||
expect(config.OCO_LANGUAGE).toEqual('fr');
|
||||
expect(config.OCO_ANTHROPIC_API_KEY).toEqual('local-anthropic-key');
|
||||
});
|
||||
|
||||
it('should fallback to global config when local config is not set', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {
|
||||
OCO_OPENAI_API_KEY: 'global-key',
|
||||
OCO_MODEL: 'gpt-4',
|
||||
OCO_LANGUAGE: 'de',
|
||||
OCO_DESCRIPTION: 'true'
|
||||
});
|
||||
|
||||
envConfigFile = await generateConfig('.env', {
|
||||
OCO_ANTHROPIC_API_KEY: 'local-anthropic-key'
|
||||
});
|
||||
|
||||
const config = getConfig({
|
||||
globalPath: globalConfigFile.filePath,
|
||||
envPath: envConfigFile.filePath
|
||||
});
|
||||
|
||||
expect(config).not.toEqual(null);
|
||||
expect(config.OCO_OPENAI_API_KEY).toEqual('global-key');
|
||||
expect(config.OCO_ANTHROPIC_API_KEY).toEqual('local-anthropic-key');
|
||||
expect(config.OCO_MODEL).toEqual('gpt-4');
|
||||
expect(config.OCO_LANGUAGE).toEqual('de');
|
||||
expect(config.OCO_DESCRIPTION).toEqual(true);
|
||||
});
|
||||
|
||||
it('should handle boolean and numeric values correctly', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {
|
||||
OCO_TOKENS_MAX_INPUT: '4096',
|
||||
OCO_TOKENS_MAX_OUTPUT: '500',
|
||||
OCO_GITPUSH: 'true'
|
||||
});
|
||||
|
||||
envConfigFile = await generateConfig('.env', {
|
||||
OCO_TOKENS_MAX_INPUT: '8192',
|
||||
OCO_ONE_LINE_COMMIT: 'false'
|
||||
});
|
||||
|
||||
const config = getConfig({
|
||||
globalPath: globalConfigFile.filePath,
|
||||
envPath: envConfigFile.filePath
|
||||
});
|
||||
|
||||
expect(config).not.toEqual(null);
|
||||
expect(config.OCO_TOKENS_MAX_INPUT).toEqual(8192);
|
||||
expect(config.OCO_TOKENS_MAX_OUTPUT).toEqual(500);
|
||||
expect(config.OCO_GITPUSH).toEqual(true);
|
||||
expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
|
||||
});
|
||||
|
||||
it('should handle empty local config correctly', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {
|
||||
OCO_OPENAI_API_KEY: 'global-key',
|
||||
OCO_MODEL: 'gpt-4',
|
||||
OCO_LANGUAGE: 'es'
|
||||
});
|
||||
|
||||
envConfigFile = await generateConfig('.env', {});
|
||||
|
||||
const config = getConfig({
|
||||
globalPath: globalConfigFile.filePath,
|
||||
envPath: envConfigFile.filePath
|
||||
});
|
||||
|
||||
expect(config).not.toEqual(null);
|
||||
expect(config.OCO_OPENAI_API_KEY).toEqual('global-key');
|
||||
expect(config.OCO_MODEL).toEqual('gpt-4');
|
||||
expect(config.OCO_LANGUAGE).toEqual('es');
|
||||
});
|
||||
|
||||
it('should override global config with null values in local .env', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {
|
||||
OCO_OPENAI_API_KEY: 'global-key',
|
||||
OCO_MODEL: 'gpt-4',
|
||||
OCO_LANGUAGE: 'es'
|
||||
});
|
||||
|
||||
envConfigFile = await generateConfig('.env', {
|
||||
OCO_OPENAI_API_KEY: 'null'
|
||||
});
|
||||
|
||||
const config = getConfig({
|
||||
globalPath: globalConfigFile.filePath,
|
||||
envPath: envConfigFile.filePath
|
||||
});
|
||||
|
||||
expect(config).not.toEqual(null);
|
||||
expect(config.OCO_OPENAI_API_KEY).toEqual(null);
|
||||
});
|
||||
|
||||
it('should handle empty global config', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {});
|
||||
envConfigFile = await generateConfig('.env', {});
|
||||
|
||||
const config = getConfig({
|
||||
globalPath: globalConfigFile.filePath,
|
||||
envPath: envConfigFile.filePath
|
||||
});
|
||||
|
||||
expect(config).not.toEqual(null);
|
||||
expect(config.OCO_OPENAI_API_KEY).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setConfig', () => {
|
||||
beforeEach(async () => {
|
||||
// we create and delete the file to have the parent directory, but not the file, to test the creation of the file
|
||||
globalConfigFile = await generateConfig('.opencommit', {});
|
||||
rmSync(globalConfigFile.filePath);
|
||||
});
|
||||
|
||||
it('should create .opencommit file with DEFAULT CONFIG if it does not exist on first setConfig run', async () => {
|
||||
const isGlobalConfigFileExist = existsSync(globalConfigFile.filePath);
|
||||
expect(isGlobalConfigFileExist).toBe(false);
|
||||
|
||||
await setConfig(
|
||||
[['OCO_OPENAI_API_KEY', 'persisted-key_1']],
|
||||
globalConfigFile.filePath
|
||||
);
|
||||
|
||||
const fileContent = readFileSync(globalConfigFile.filePath, 'utf8');
|
||||
expect(fileContent).toContain('OCO_OPENAI_API_KEY=persisted-key_1');
|
||||
Object.entries(DEFAULT_CONFIG).forEach(([key, value]) => {
|
||||
expect(fileContent).toContain(`${key}=${value}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set new config values', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {});
|
||||
await setConfig(
|
||||
[
|
||||
['OCO_OPENAI_API_KEY', 'new-key'],
|
||||
['OCO_MODEL', 'gpt-4']
|
||||
],
|
||||
globalConfigFile.filePath
|
||||
);
|
||||
|
||||
const config = getConfig({ globalPath: globalConfigFile.filePath });
|
||||
expect(config.OCO_OPENAI_API_KEY).toEqual('new-key');
|
||||
expect(config.OCO_MODEL).toEqual('gpt-4');
|
||||
});
|
||||
|
||||
it('should update existing config values', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {
|
||||
OCO_OPENAI_API_KEY: 'initial-key'
|
||||
});
|
||||
await setConfig(
|
||||
[['OCO_OPENAI_API_KEY', 'updated-key']],
|
||||
globalConfigFile.filePath
|
||||
);
|
||||
|
||||
const config = getConfig({ globalPath: globalConfigFile.filePath });
|
||||
expect(config.OCO_OPENAI_API_KEY).toEqual('updated-key');
|
||||
});
|
||||
|
||||
it('should handle boolean and numeric values correctly', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {});
|
||||
await setConfig(
|
||||
[
|
||||
['OCO_TOKENS_MAX_INPUT', '8192'],
|
||||
['OCO_DESCRIPTION', 'true'],
|
||||
['OCO_ONE_LINE_COMMIT', 'false']
|
||||
],
|
||||
globalConfigFile.filePath
|
||||
);
|
||||
|
||||
const config = getConfig({ globalPath: globalConfigFile.filePath });
|
||||
expect(config.OCO_TOKENS_MAX_INPUT).toEqual(8192);
|
||||
expect(config.OCO_DESCRIPTION).toEqual(true);
|
||||
expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
|
||||
});
|
||||
|
||||
it('should throw an error for unsupported config keys', async () => {
|
||||
globalConfigFile = await generateConfig('.opencommit', {});
|
||||
|
||||
try {
|
||||
await setConfig(
|
||||
[['UNSUPPORTED_KEY', 'value']],
|
||||
globalConfigFile.filePath
|
||||
);
|
||||
throw new Error('NEVER_REACHED');
|
||||
} catch (error) {
|
||||
expect(error.message).toContain(
|
||||
'Unsupported config key: UNSUPPORTED_KEY'
|
||||
);
|
||||
expect(error.message).not.toContain('NEVER_REACHED');
|
||||
}
|
||||
});
|
||||
|
||||
it('should persist changes to the config file', async () => {
|
||||
const isGlobalConfigFileExist = existsSync(globalConfigFile.filePath);
|
||||
expect(isGlobalConfigFileExist).toBe(false);
|
||||
|
||||
await setConfig(
|
||||
[['OCO_OPENAI_API_KEY', 'persisted-key']],
|
||||
globalConfigFile.filePath
|
||||
);
|
||||
|
||||
const fileContent = readFileSync(globalConfigFile.filePath, 'utf8');
|
||||
expect(fileContent).toContain('OCO_OPENAI_API_KEY=persisted-key');
|
||||
});
|
||||
|
||||
it('should set multiple configs in a row and keep the changes', async () => {
|
||||
const isGlobalConfigFileExist = existsSync(globalConfigFile.filePath);
|
||||
expect(isGlobalConfigFileExist).toBe(false);
|
||||
|
||||
await setConfig(
|
||||
[['OCO_OPENAI_API_KEY', 'persisted-key']],
|
||||
globalConfigFile.filePath
|
||||
);
|
||||
|
||||
const fileContent1 = readFileSync(globalConfigFile.filePath, 'utf8');
|
||||
expect(fileContent1).toContain('OCO_OPENAI_API_KEY=persisted-key');
|
||||
|
||||
await setConfig([['OCO_MODEL', 'gpt-4']], globalConfigFile.filePath);
|
||||
|
||||
const fileContent2 = readFileSync(globalConfigFile.filePath, 'utf8');
|
||||
expect(fileContent2).toContain('OCO_MODEL=gpt-4');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Gemini } from '../../src/engine/gemini';
|
||||
|
||||
import { GenerativeModel, GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import {
|
||||
ConfigType,
|
||||
getConfig,
|
||||
OCO_AI_PROVIDER_ENUM
|
||||
} from '../../src/commands/config';
|
||||
import { OpenAI } from 'openai';
|
||||
|
||||
describe('Gemini', () => {
|
||||
let gemini: Gemini;
|
||||
let mockConfig: ConfigType;
|
||||
let mockGoogleGenerativeAi: GoogleGenerativeAI;
|
||||
let mockGenerativeModel: GenerativeModel;
|
||||
let mockExit: jest.SpyInstance<never, [code?: number | undefined], any>;
|
||||
|
||||
const noop: (...args: any[]) => any = (...args: any[]) => {};
|
||||
|
||||
const mockGemini = () => {
|
||||
mockConfig = getConfig() as ConfigType;
|
||||
|
||||
gemini = new Gemini({
|
||||
apiKey: mockConfig.OCO_GEMINI_API_KEY,
|
||||
model: mockConfig.OCO_MODEL
|
||||
});
|
||||
};
|
||||
|
||||
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()
|
||||
}));
|
||||
|
||||
mockExit = jest.spyOn(process, 'exit').mockImplementation();
|
||||
|
||||
mockConfig = getConfig() as ConfigType;
|
||||
|
||||
mockConfig.OCO_AI_PROVIDER = OCO_AI_PROVIDER_ENUM.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.skip('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 generate commit message', async () => {
|
||||
const mockGenerateContent = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { text: () => 'generated content' } });
|
||||
mockGenerativeModel.generateContent = mockGenerateContent;
|
||||
|
||||
mockGemini();
|
||||
|
||||
const messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam> =
|
||||
[
|
||||
{ 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');
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { existsSync, mkdtemp, rm, writeFile } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
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 () => {
|
||||
if (existsSync(tempDir)) {
|
||||
await fsRemove(tempDir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
filePath,
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES6", "ES2020"],
|
||||
|
||||
"module": "CommonJS",
|
||||
"target": "ESNext",
|
||||
"lib": ["ES5", "ES6"],
|
||||
|
||||
"module": "ESNext",
|
||||
// "rootDir": "./src",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node",
|
||||
|
||||
"allowJs": true,
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["test/jest-setup.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"ts-node": {
|
||||
"esm": true,
|
||||
|
||||
Reference in New Issue
Block a user