The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .dockerignore
├── .eslintrc.json
├── .github
    ├── CONTRIBUTING.md
    ├── ISSUE_TEMPLATE
    │   ├── bug.yaml
    │   └── featureRequest.yaml
    ├── TODO.md
    ├── github-mark-white.png
    ├── logo-black.png
    ├── logo-grad.svg
    ├── logo.svg
    ├── opencommit-example.png
    └── workflows
    │   ├── codeql.yml
    │   ├── dependency-review.yml
    │   └── test.yml
├── .gitignore
├── .npmignore
├── .opencommitignore
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── action.yml
├── esbuild.config.js
├── jest.config.ts
├── out
    ├── cli.cjs
    ├── github-action.cjs
    └── tiktoken_bg.wasm
├── package-lock.json
├── package.json
├── src
    ├── CommandsEnum.ts
    ├── cli.ts
    ├── commands
    │   ├── ENUMS.ts
    │   ├── README.md
    │   ├── commit.ts
    │   ├── commitlint.ts
    │   ├── config.ts
    │   ├── githook.ts
    │   └── prepare-commit-msg-hook.ts
    ├── engine
    │   ├── Engine.ts
    │   ├── anthropic.ts
    │   ├── azure.ts
    │   ├── deepseek.ts
    │   ├── flowise.ts
    │   ├── gemini.ts
    │   ├── groq.ts
    │   ├── mistral.ts
    │   ├── mlx.ts
    │   ├── ollama.ts
    │   ├── openAi.ts
    │   ├── openrouter.ts
    │   └── testAi.ts
    ├── generateCommitMessageFromGitDiff.ts
    ├── github-action.ts
    ├── i18n
    │   ├── cs.json
    │   ├── de.json
    │   ├── en.json
    │   ├── es_ES.json
    │   ├── fr.json
    │   ├── id_ID.json
    │   ├── index.ts
    │   ├── it.json
    │   ├── ja.json
    │   ├── ko.json
    │   ├── nl.json
    │   ├── pl.json
    │   ├── pt_br.json
    │   ├── ru.json
    │   ├── sv.json
    │   ├── th.json
    │   ├── tr.json
    │   ├── vi_VN.json
    │   ├── zh_CN.json
    │   └── zh_TW.json
    ├── migrations
    │   ├── 00_use_single_api_key_and_url.ts
    │   ├── 01_remove_obsolete_config_keys_from_global_file.ts
    │   ├── 02_set_missing_default_values.ts
    │   ├── _migrations.ts
    │   └── _run.ts
    ├── modules
    │   └── commitlint
    │   │   ├── config.ts
    │   │   ├── constants.ts
    │   │   ├── crypto.ts
    │   │   ├── prompts.ts
    │   │   ├── pwd-commitlint.ts
    │   │   ├── types.ts
    │   │   └── utils.ts
    ├── prompts.ts
    ├── utils
    │   ├── checkIsLatestVersion.ts
    │   ├── engine.ts
    │   ├── git.ts
    │   ├── mergeDiffs.ts
    │   ├── randomIntFromInterval.ts
    │   ├── removeContentTags.ts
    │   ├── removeConventionalCommitWord.ts
    │   ├── sleep.ts
    │   ├── tokenCount.ts
    │   └── trytm.ts
    └── version.ts
├── test
    ├── Dockerfile
    ├── e2e
    │   ├── gitPush.test.ts
    │   ├── noChanges.test.ts
    │   ├── oneFile.test.ts
    │   ├── prompt-module
    │   │   ├── commitlint.test.ts
    │   │   └── data
    │   │   │   ├── commitlint_18
    │   │   │       ├── commitlint.config.js
    │   │   │       ├── package-lock.json
    │   │   │       └── package.json
    │   │   │   ├── commitlint_19
    │   │   │       ├── commitlint.config.js
    │   │   │       ├── package-lock.json
    │   │   │       └── package.json
    │   │   │   └── commitlint_9
    │   │   │       ├── commitlint.config.js
    │   │   │       ├── package-lock.json
    │   │   │       └── package.json
    │   ├── setup.sh
    │   └── utils.ts
    ├── jest-setup.ts
    └── unit
    │   ├── config.test.ts
    │   ├── gemini.test.ts
    │   ├── removeContentTags.test.ts
    │   └── utils.ts
└── tsconfig.json


/.dockerignore:
--------------------------------------------------------------------------------
1 | .env
2 | 


--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "extends": [
 3 |     "eslint:recommended",
 4 |     "plugin:@typescript-eslint/recommended",
 5 |     "plugin:prettier/recommended"
 6 |   ],
 7 |   "parser": "@typescript-eslint/parser",
 8 |   "parserOptions": {
 9 |     "ecmaVersion": 12,
10 |     "sourceType": "module"
11 |   },
12 |   "plugins": ["simple-import-sort", "import", "@typescript-eslint", "prettier"],
13 |   "settings": {
14 |     "import/resolver": {
15 |       "node": {
16 |         "extensions": [".js", ".jsx", ".ts", ".tsx"]
17 |       }
18 |     }
19 |   },
20 |   "packageManager": "npm",
21 |   "rules": {
22 |     "prettier/prettier": "error",
23 |     "no-console": "error",
24 |     "import/order": "off",
25 |     "sort-imports": "off",
26 |     "simple-import-sort/imports": "error",
27 |     "simple-import-sort/exports": "error",
28 |     "import/first": "error",
29 |     "import/newline-after-import": "error",
30 |     "import/no-duplicates": "error",
31 |     "@typescript-eslint/no-non-null-assertion": "off"
32 |   }
33 | }
34 | 


--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
 1 | # Contribution Guidelines
 2 | 
 3 | Thanks for considering contributing to the project.
 4 | 
 5 | ## How to contribute
 6 | 
 7 | 1. Fork the project repository on GitHub.
 8 | 2. Clone your forked repository locally.
 9 | 3. Create a new branch for your changes.
10 | 4. Make your changes and commit them with descriptive commit messages.
11 | 5. Push your changes to your forked repository.
12 | 6. Create a pull request from your branch to the `dev` branch. Not `master` branch, PR to `dev` branch, please.
13 | 
14 | ## Getting started
15 | 
16 | To get started, follow these steps:
17 | 
18 | 1. Clone the project repository locally.
19 | 2. Install dependencies with `npm install`.
20 | 3. Run the project with `npm run dev`.
21 | 4. See [issues](https://github.com/di-sukharev/opencommit/issues) or [TODO.md](TODO.md) to help the project.
22 | 
23 | ## Commit message guidelines
24 | 
25 | Use the library to generate commits, stage the files and run `npm run dev` :)
26 | 
27 | ## Reporting issues
28 | 
29 | If you encounter any issues while using the project, please report them on the GitHub issue tracker. When reporting issues, please include as much information as possible, such as steps to reproduce the issue, expected behavior, and actual behavior.
30 | 
31 | ## Contacts
32 | 
33 | If you have any questions about contributing to the project, please contact by [creating an issue](https://github.com/di-sukharev/opencommit/issues) on the GitHub issue tracker.
34 | 
35 | ## License
36 | 
37 | By contributing to this project, you agree that your contributions will be licensed under the MIT license, as specified in the `LICENSE` file in the root of the project.
38 | 


--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yaml:
--------------------------------------------------------------------------------
 1 | name: 🐞 Bug Report
 2 | description: File a bug report
 3 | title: "[Bug]: "
 4 | labels: ["bug", "triage"]
 5 | assignees:
 6 |   - octocat
 7 | body:
 8 |   - type: markdown
 9 |     attributes:
10 |       value: |
11 |         Thanks for taking the time to fill out this bug report!
12 |   - type: input
13 |     id: opencommit-version
14 |     attributes:
15 |       label: Opencommit Version
16 |       description: What version of our software are you running?
17 |       placeholder: ex. 1.1.22
18 |     validations:
19 |       required: true
20 |   - type: input
21 |     id: node-version
22 |     attributes:
23 |       label: Node Version
24 |       description: What version of node are you running?
25 |       placeholder: ex. 19.8.1
26 |     validations:
27 |       required: true
28 |   - type: input
29 |     id: npm-version
30 |     attributes:
31 |       label: NPM Version
32 |       description: What version of npm are you running?
33 |       placeholder: ex. 9.6.2
34 |     validations:
35 |       required: true
36 |   - type: dropdown
37 |     id: OS
38 |     attributes:
39 |       label: What OS are you seeing the problem on?
40 |       multiple: true
41 |       options:
42 |         - Mac
43 |         - Windows
44 |         - Other Linux Distro
45 |   - type: textarea
46 |     id: what-happened
47 |     attributes:
48 |       label: What happened?
49 |       description: Also tell us, what did you expect to happen?
50 |       placeholder: Tell us what you see!
51 |       value: "A bug happened!"
52 |     validations:
53 |       required: true
54 |   - type: textarea
55 |     id: expected-behavior
56 |     attributes:
57 |       label: Expected Behavior
58 |       description: Also tell us, what did you expect to happen?
59 |       placeholder: Tell us what you expected to happen!
60 |     validations:
61 |       required: true   
62 |   - type: textarea
63 |     id: current-behavior
64 |     attributes:
65 |       label: Current Behavior
66 |       description: Also tell us, what is currently happening?
67 |       placeholder: Tell us what is happening now.
68 |     validations:
69 |       required: true
70 |   - type: textarea
71 |     id: possible-solution
72 |     attributes:
73 |       label: Possible Solution
74 |       description: Do you have a solution for the issue?
75 |       placeholder: Tell us what the solution could look like.
76 |     validations:
77 |       required: false
78 |   - type: textarea
79 |     id: steps-to-reproduce
80 |     attributes:
81 |       label: Steps to Reproduce
82 |       description: Tell us how to reproduce the issue?
83 |       placeholder: Tell us how to reproduce the issue?
84 |     validations:
85 |       required: false
86 |   - type: textarea
87 |     id: logs
88 |     attributes:
89 |       label: Relevant log output
90 |       description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
91 |       render: shell


--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/featureRequest.yaml:
--------------------------------------------------------------------------------
 1 | ---
 2 | name: 🛠️ Feature Request
 3 | description: Suggest an idea to help us improve Opencommit
 4 | title: "[Feature]: "
 5 | labels:
 6 |   - "feature_request"
 7 | 
 8 | body:
 9 |   - type: markdown
10 |     attributes:
11 |       value: |
12 |         **Thanks :heart: for taking the time to fill out this feature request report!**
13 |         We kindly ask that you search to see if an issue [already exists](https://github.com/di-sukharev/opencommit/issues?q=is%3Aissue+sort%3Acreated-desc+) for your feature.
14 | 
15 |         We are also happy to accept contributions from our users. For more details see [here](https://github.com/di-sukharev/opencommit/blob/master/.github/CONTRIBUTING.md).
16 | 
17 |   - type: textarea
18 |     attributes:
19 |       label: Description
20 |       description: |
21 |         A clear and concise description of the feature you're interested in.
22 |     validations:
23 |       required: true
24 | 
25 |   - type: textarea
26 |     attributes:
27 |       label: Suggested Solution
28 |       description: |
29 |         Describe the solution you'd like. A clear and concise description of what you want to happen.
30 |     validations:
31 |       required: true
32 | 
33 |   - type: textarea
34 |     attributes:
35 |       label: Alternatives
36 |       description: |
37 |         Describe alternatives you've considered.
38 |         A clear and concise description of any alternative solutions or features you've considered.
39 |     validations:
40 |       required: false
41 | 
42 |   - type: textarea
43 |     attributes:
44 |       label: Additional Context
45 |       description: |
46 |         Add any other context about the problem here.
47 |     validations:
48 |       required: false


--------------------------------------------------------------------------------
/.github/TODO.md:
--------------------------------------------------------------------------------
 1 | # TODOs
 2 | 
 3 | - [x] set prepare-commit-msg hook
 4 | - [] show "new version available" message, look into this commit e146d4d cli.ts file
 5 | - [] make bundle smaller by properly configuring esbuild
 6 | - [] [build for both mjs and cjs](https://snyk.io/blog/best-practices-create-modern-npm-package/)
 7 | - [] do // TODOs in the code
 8 | - [x] batch small files in one request
 9 | - [] add tests
10 | - [] optimize prompt, maybe no prompt would be cleaner
11 | - [] try setting max commit msg length, maybe it will make commits short and more concise
12 | 


--------------------------------------------------------------------------------
/.github/github-mark-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/di-sukharev/opencommit/9971b3c74e5f51397285bc2692f0e79679d37805/.github/github-mark-white.png


--------------------------------------------------------------------------------
/.github/logo-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/di-sukharev/opencommit/9971b3c74e5f51397285bc2692f0e79679d37805/.github/logo-black.png


--------------------------------------------------------------------------------
/.github/logo-grad.svg:
--------------------------------------------------------------------------------
 1 | <svg width="78" height="75" viewBox="0 0 78 75" fill="none" xmlns="http://www.w3.org/2000/svg">
 2 | <path d="M32.269 2.94345C34.6328 4.17458 36.5623 5.81371 38.0626 7.86409C37.7038 8.37105 37.3661 8.90001 37.0496 9.45094L37.0495 9.45091L37.0456 9.45797C35.2629 12.6805 34.3831 16.5345 34.3831 21V54C34.3831 58.4007 35.2636 62.2523 37.0435 65.5381L37.0433 65.5382L37.0496 65.5491C37.3661 66.1 37.7038 66.629 38.0626 67.1359C36.5622 69.1863 34.6328 70.8254 32.269 72.0565L32.2652 72.0586C29.2195 73.6786 25.5374 74.5 21.2 74.5C16.8638 74.5 13.1471 73.6791 10.0328 72.0575C6.98854 70.4377 4.62693 68.1096 2.94057 65.0635C1.31973 61.949 0.5 58.2664 0.5 54V21C0.5 16.6643 1.32072 12.9834 2.93951 9.93843C4.62596 6.89138 6.98794 4.56255 10.0329 2.94245C13.1472 1.32089 16.8639 0.5 21.2 0.5C25.5374 0.5 29.2195 1.32137 32.2652 2.94145L32.269 2.94345ZM38.6667 8.74806C38.9107 9.13077 39.1413 9.52635 39.3586 9.93481L39.3585 9.93484L39.3625 9.94203C41.047 12.9872 41.9 16.6336 41.9 20.9V54C41.9 58.266 41.0472 61.9477 39.3603 65.0619L39.3586 65.0652C39.1413 65.4736 38.9107 65.8692 38.6667 66.2519C38.4054 65.8665 38.1565 65.468 37.9199 65.0565C36.235 61.9435 35.3831 58.2635 35.3831 54V21C35.3831 16.6672 36.236 12.989 37.9187 9.94557C38.1556 9.53328 38.405 9.13412 38.6667 8.74806ZM39.2936 7.87926C40.8728 5.82164 42.8446 4.17787 45.2123 2.94436C48.3955 1.32076 52.1474 0.5 56.4831 0.5C60.8172 0.5 64.5319 1.3534 67.645 3.03964L67.6449 3.0397L67.6522 3.04345C70.7657 4.6651 73.1602 6.99537 74.8456 10.042C76.464 12.9676 77.3148 16.448 77.3792 20.5H69.3778C69.2917 16.5201 68.1674 13.3804 65.942 11.1517C63.6909 8.76341 60.5126 7.6 56.4831 7.6C52.4533 7.6 49.2164 8.72969 46.8349 11.0412L46.8348 11.0412L46.8296 11.0464C44.5081 13.3679 43.3831 16.6791 43.3831 20.9V54C43.3831 58.2218 44.5085 61.5622 46.8243 63.9482L46.8295 63.9536L46.8349 63.9588C49.2164 66.2703 52.4533 67.4 56.4831 67.4C60.5114 67.4 63.6898 66.2708 65.9421 63.9481C68.1656 61.657 69.2916 58.4862 69.3778 54.5H77.379C77.3138 58.4875 76.4638 61.9697 74.8444 64.9601C73.1588 68.0063 70.7636 70.3703 67.6486 72.0584C64.5346 73.6794 60.8185 74.5 56.4831 74.5C52.1474 74.5 48.3956 73.6793 45.2125 72.0557C42.8446 70.8222 40.8729 69.1784 39.2936 67.1207C39.6322 66.6146 39.9479 66.0865 40.2405 65.5365C42.0198 62.251 42.9 58.4 42.9 54V20.9C42.9 16.5014 42.0203 12.6824 40.2396 9.46166C39.9472 8.91234 39.6319 8.38486 39.2936 7.87926ZM11.8359 63.9427L11.8359 63.9427L11.841 63.9481C14.0918 66.2691 17.2355 67.4 21.2 67.4C25.2274 67.4 28.3768 66.2711 30.5644 63.9423C32.8103 61.5559 33.9 58.2177 33.9 54V21C33.9 16.7865 32.8123 13.4792 30.5643 11.1575C28.378 8.76316 25.2286 7.6 21.2 7.6C17.2326 7.6 14.088 8.76605 11.8384 11.1546C9.58856 13.4765 8.5 16.7848 8.5 21V54C8.5 58.2179 9.58979 61.5562 11.8359 63.9427Z" fill="url(#paint0_linear_498_146)" stroke="url(#paint1_linear_498_146)"/>
 3 | <defs>
 4 | <linearGradient id="paint0_linear_498_146" x1="38.9416" y1="0" x2="38.9416" y2="75" gradientUnits="userSpaceOnUse">
 5 | <stop stop-color="#D33075"/>
 6 | <stop offset="1" stop-color="#6157D8"/>
 7 | </linearGradient>
 8 | <linearGradient id="paint1_linear_498_146" x1="38.9416" y1="0" x2="38.9416" y2="75" gradientUnits="userSpaceOnUse">
 9 | <stop stop-color="#D33075"/>
10 | <stop offset="1" stop-color="#6157D8"/>
11 | </linearGradient>
12 | </defs>
13 | </svg>
14 | 


--------------------------------------------------------------------------------
/.github/logo.svg:
--------------------------------------------------------------------------------
1 | <svg width="78" height="75" viewBox="0 0 78 75" fill="none" xmlns="http://www.w3.org/2000/svg">
2 | <path d="M21.2 75C16.8 75 13 74.1667 9.8 72.5C6.66667 70.8333 4.23333 68.4333 2.5 65.3C0.833333 62.1 0 58.3333 0 54V21C0 16.6 0.833333 12.8333 2.5 9.7C4.23333 6.56666 6.66667 4.16666 9.8 2.5C13 0.833333 16.8 0 21.2 0C25.6 0 29.3667 0.833333 32.5 2.5C35.7 4.16666 38.1333 6.56666 39.8 9.7C41.5333 12.8333 42.4 16.5667 42.4 20.9V54C42.4 58.3333 41.5333 62.1 39.8 65.3C38.1333 68.4333 35.7 70.8333 32.5 72.5C29.3667 74.1667 25.6 75 21.2 75ZM21.2 66.9C25.1333 66.9 28.1333 65.8 30.2 63.6C32.3333 61.3333 33.4 58.1333 33.4 54V21C33.4 16.8667 32.3333 13.7 30.2 11.5C28.1333 9.23333 25.1333 8.1 21.2 8.1C17.3333 8.1 14.3333 9.23333 12.2 11.5C10.0667 13.7 9 16.8667 9 21V54C9 58.1333 10.0667 61.3333 12.2 63.6C14.3333 65.8 17.3333 66.9 21.2 66.9Z" fill="black"/>
3 | <path d="M56.4831 75C52.0831 75 48.2498 74.1667 44.9831 72.5C41.7831 70.8333 39.2831 68.4333 37.4831 65.3C35.7498 62.1 34.8831 58.3333 34.8831 54V21C34.8831 16.6 35.7498 12.8333 37.4831 9.7C39.2831 6.56666 41.7831 4.16666 44.9831 2.5C48.2498 0.833333 52.0831 0 56.4831 0C60.8831 0 64.6831 0.866665 67.8831 2.6C71.0831 4.26667 73.5498 6.66667 75.2831 9.8C77.0165 12.9333 77.8831 16.6667 77.8831 21H68.8831C68.8831 16.8667 67.7831 13.7 65.5831 11.5C63.4498 9.23333 60.4165 8.1 56.4831 8.1C52.5498 8.1 49.4498 9.2 47.1831 11.4C44.9831 13.6 43.8831 16.7667 43.8831 20.9V54C43.8831 58.1333 44.9831 61.3333 47.1831 63.6C49.4498 65.8 52.5498 66.9 56.4831 66.9C60.4165 66.9 63.4498 65.8 65.5831 63.6C67.7831 61.3333 68.8831 58.1333 68.8831 54H77.8831C77.8831 58.2667 77.0165 62 75.2831 65.2C73.5498 68.3333 71.0831 70.7667 67.8831 72.5C64.6831 74.1667 60.8831 75 56.4831 75Z" fill="black"/>
4 | </svg>
5 | 


--------------------------------------------------------------------------------
/.github/opencommit-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/di-sukharev/opencommit/9971b3c74e5f51397285bc2692f0e79679d37805/.github/opencommit-example.png


--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
 1 | # For most projects, this workflow file will not need changing; you simply need
 2 | # to commit it to your repository.
 3 | #
 4 | # You may wish to alter this file to override the set of languages analyzed,
 5 | # or to provide custom queries or build logic.
 6 | #
 7 | # ******** NOTE ********
 8 | # We have attempted to detect the languages in your repository. Please check
 9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 | 
14 | on:
15 |   push:
16 |     branches: [ "master" ]
17 |   pull_request:
18 |     # The branches below must be a subset of the branches above
19 |     branches: [ "master" ]
20 |   schedule:
21 |     - cron: '21 16 * * 0'
22 | 
23 | jobs:
24 |   analyze:
25 |     name: Analyze
26 |     runs-on: ubuntu-latest
27 |     permissions:
28 |       actions: read
29 |       contents: read
30 |       security-events: write
31 | 
32 |     strategy:
33 |       fail-fast: false
34 |       matrix:
35 |         language: [ 'javascript' ]
36 |         # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 |         # Use only 'java' to analyze code written in Java, Kotlin or both
38 |         # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
39 |         # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
40 | 
41 |     steps:
42 |     - name: Checkout repository
43 |       uses: actions/checkout@v4
44 | 
45 |     # Initializes the CodeQL tools for scanning.
46 |     - name: Initialize CodeQL
47 |       uses: github/codeql-action/init@v3
48 |       with:
49 |         languages: ${{ matrix.language }}
50 |         # If you wish to specify custom queries, you can do so here or in a config file.
51 |         # By default, queries listed here will override any specified in a config file.
52 |         # Prefix the list here with "+" to use these queries and those in the config file.
53 | 
54 |         # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
55 |         # queries: security-extended,security-and-quality
56 | 
57 | 
58 |     # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
59 |     # If this step fails, then you should remove it and run the build manually (see below)
60 |     - name: Autobuild
61 |       uses: github/codeql-action/autobuild@v3
62 | 
63 |     # ℹ️ Command-line programs to run using the OS shell.
64 |     # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
65 | 
66 |     #   If the Autobuild fails above, remove it and uncomment the following three lines.
67 |     #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
68 | 
69 |     # - run: |
70 |     #     echo "Run, Build Application using script"
71 |     #     ./location_of_script_within_repo/buildscript.sh
72 | 
73 |     - name: Perform CodeQL Analysis
74 |       uses: github/codeql-action/analyze@v3
75 |       with:
76 |         category: "/language:${{matrix.language}}"
77 | 


--------------------------------------------------------------------------------
/.github/workflows/dependency-review.yml:
--------------------------------------------------------------------------------
 1 | # Dependency Review Action
 2 | #
 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
 4 | #
 5 | # Source repository: https://github.com/actions/dependency-review-action
 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
 7 | name: 'Dependency Review'
 8 | on: [pull_request]
 9 | 
10 | permissions:
11 |   contents: read
12 | 
13 | jobs:
14 |   dependency-review:
15 |     runs-on: ubuntu-latest
16 |     steps:
17 |       - name: 'Checkout Repository'
18 |         uses: actions/checkout@v4
19 |       - name: 'Dependency Review'
20 |         uses: actions/dependency-review-action@v3
21 | 


--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
 1 | name: Testing
 2 | 
 3 | on:
 4 |   pull_request:
 5 |   push:
 6 |     branches:
 7 |       - master
 8 |       - main
 9 | 
10 | jobs:
11 |   unit-test:
12 |     runs-on: ubuntu-latest
13 |     strategy:
14 |       matrix:
15 |         node-version: [20.x]
16 |     steps:
17 |     - uses: actions/checkout@v4
18 |     - name: Use Node.js ${{ matrix.node-version }}
19 |       uses: actions/setup-node@v3
20 |       with:
21 |         node-version: ${{ matrix.node-version }}
22 |         cache: 'npm'
23 |     - name: Install dependencies
24 |       run: npm install
25 |     - name: Run Unit Tests
26 |       run: npm run test:unit
27 |   e2e-test:
28 |     runs-on: ubuntu-latest
29 |     strategy:
30 |       matrix:
31 |         node-version: [20.x]
32 |     steps:
33 |     - uses: actions/checkout@v4
34 |     - name: Use Node.js ${{ matrix.node-version }}
35 |       uses: actions/setup-node@v3
36 |       with:
37 |         node-version: ${{ matrix.node-version }}
38 |         cache: 'npm'
39 |     - name: Install git
40 |       run: |
41 |         sudo apt-get update
42 |         sudo apt-get install -y git
43 |         git --version
44 |     - name: Setup git
45 |       run: |
46 |         git config --global user.email "test@example.com"
47 |         git config --global user.name "Test User"
48 |     - name: Install dependencies
49 |       run: npm install
50 |     - name: Build
51 |       run: npm run build
52 |     - name: Run E2E Tests
53 |       run: npm run test:e2e
54 |   prettier:
55 |     runs-on: ubuntu-latest
56 |     steps:
57 |     - uses: actions/checkout@v4
58 |     - name: Use Node.js
59 |       uses: actions/setup-node@v4
60 |       with:
61 |         node-version: '20.x'
62 |         cache: 'npm'
63 |     - name: Install dependencies
64 |       run: npm ci
65 |     - name: Run Prettier
66 |       run: npm run format:check
67 |     - name: Prettier Output
68 |       if: failure()
69 |       run: |
70 |         echo "Prettier check failed. Please run 'npm run format' to fix formatting issues."
71 |         exit 1
72 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | node_modules/
 2 | coverage/
 3 | temp/
 4 | build/
 5 | application.log
 6 | .DS_Store
 7 | /*.env
 8 | logfile.log
 9 | uncaughtExceptions.log
10 | .vscode
11 | src/*.json
12 | .idea
13 | test.ts
14 | notes.md
15 | .nvmrc


--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | out/github-action.cjs


--------------------------------------------------------------------------------
/.opencommitignore:
--------------------------------------------------------------------------------
1 | out


--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /build
2 | /dist
3 | /out


--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 |   "trailingComma": "none",
3 |   "singleQuote": true
4 | }
5 | 


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) Dima Sukharev, https://github.com/di-sukharev
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a
 6 | 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:
 7 | 
 8 | The above copyright notice and this permission notice shall be included
 9 | in all copies or substantial portions of the Software.
10 | 
11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
14 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
16 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | <div align="center">
  2 |   <div>
  3 |     <img src=".github/logo-grad.svg" alt="OpenCommit logo"/>
  4 |     <h1 align="center">OpenCommit</h1>
  5 |     <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>
  6 |   </div>
  7 | 	<h2>Auto-generate meaningful commits in a second</h2>
  8 | 	<p>Killing lame commits with AI 🤯🔫</p>
  9 | 	<a href="https://www.npmjs.com/package/opencommit"><img src="https://img.shields.io/npm/v/opencommit" alt="Current version"></a>
 10 |   <h4 align="center">🪩 Winner of <a href="https://twitter.com/_sukharev_/status/1683448136973582336">GitHub 2023 hackathon</a> 🪩</h4>
 11 | </div>
 12 | 
 13 | ---
 14 | 
 15 | <div align="center">
 16 |     <img src=".github/opencommit-example.png" alt="OpenCommit example"/>
 17 | </div>
 18 | 
 19 | 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.
 20 | 
 21 | ## Setup OpenCommit as a CLI tool
 22 | 
 23 | 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.
 24 | 
 25 | 1. Install OpenCommit globally to use in any repository:
 26 | 
 27 |    ```sh
 28 |    npm install -g opencommit
 29 |    ```
 30 | 
 31 | 2. Get your API key from [OpenAI](https://platform.openai.com/account/api-keys) or other supported LLM providers (we support them all). Make sure that you add your OpenAI payment details to your account, so the API works.
 32 | 
 33 | 3. Set the key to OpenCommit config:
 34 | 
 35 |    ```sh
 36 |    oco config set OCO_API_KEY=<your_api_key>
 37 |    ```
 38 | 
 39 |    Your API key is stored locally in the `~/.opencommit` config file.
 40 | 
 41 | ## Usage
 42 | 
 43 | You can call OpenCommit with `oco` command to generate a commit message for your staged changes:
 44 | 
 45 | ```sh
 46 | git add <files...>
 47 | oco
 48 | ```
 49 | 
 50 | Running `git add` is optional, `oco` will do it for you.
 51 | 
 52 | ### Running locally with Ollama
 53 | 
 54 | You can also run it with local model through ollama:
 55 | 
 56 | - install and start ollama
 57 | - run `ollama run mistral` (do this only once, to pull model)
 58 | - run (in your project directory):
 59 | 
 60 | ```sh
 61 | git add <files...>
 62 | oco config set OCO_AI_PROVIDER='ollama' OCO_MODEL='llama3:8b'
 63 | ```
 64 | 
 65 | Default model is `mistral`.
 66 | 
 67 | If you have ollama that is set up in docker/ on another machine with GPUs (not locally), you can change the default endpoint url.
 68 | 
 69 | You can do so by setting the `OCO_API_URL` environment variable as follows:
 70 | 
 71 | ```sh
 72 | oco config set OCO_API_URL='http://192.168.1.10:11434/api/chat'
 73 | ```
 74 | 
 75 | where 192.168.1.10 is example of endpoint URL, where you have ollama set up.
 76 | 
 77 | ### Flags
 78 | 
 79 | There are multiple optional flags that can be used with the `oco` command:
 80 | 
 81 | #### Use Full GitMoji Specification
 82 | 
 83 | Link to the GitMoji specification: https://gitmoji.dev/
 84 | 
 85 | 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 (🐛✨📝🚀✅♻️⬆️🔧🌐💡).
 86 | 
 87 | 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.
 88 | 
 89 | ```
 90 | oco --fgm
 91 | ```
 92 | 
 93 | #### Skip Commit Confirmation
 94 | 
 95 | 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:
 96 | 
 97 | ```
 98 | oco --yes
 99 | ```
100 | 
101 | ## Configuration
102 | 
103 | ### Local per repo configuration
104 | 
105 | Create a `.env` file and add OpenCommit config variables there like this:
106 | 
107 | ```env
108 | ...
109 | OCO_AI_PROVIDER=<openai (default), anthropic, azure, ollama, gemini, flowise, deepseek>
110 | OCO_API_KEY=<your OpenAI API token> // or other LLM provider API token
111 | OCO_API_URL=<may be used to set proxy path to OpenAI api>
112 | OCO_API_CUSTOM_HEADERS=<JSON string of custom HTTP headers to include in API requests>
113 | OCO_TOKENS_MAX_INPUT=<max model token limit (default: 4096)>
114 | OCO_TOKENS_MAX_OUTPUT=<max response tokens (default: 500)>
115 | OCO_DESCRIPTION=<postface a message with ~3 sentences description of the changes>
116 | OCO_EMOJI=<boolean, add GitMoji>
117 | OCO_MODEL=<either 'gpt-4o-mini' (default), 'gpt-4o', 'gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo', 'gpt-3.5-turbo-0125', 'gpt-4-1106-preview', 'gpt-4-turbo-preview' or 'gpt-4-0125-preview' or any Anthropic or Ollama model or any string basically, but it should be a valid model name>
118 | OCO_LANGUAGE=<locale, scroll to the bottom to see options>
119 | OCO_MESSAGE_TEMPLATE_PLACEHOLDER=<message template placeholder, default: '$msg'>
120 | OCO_PROMPT_MODULE=<either conventional-commit or @commitlint, default: conventional-commit>
121 | OCO_ONE_LINE_COMMIT=<one line commit message, default: false>
122 | ```
123 | 
124 | Global configs are same as local configs, but they are stored in the global `~/.opencommit` config file and set with `oco config set` command, e.g. `oco config set OCO_MODEL=gpt-4o`.
125 | 
126 | ### Global config for all repos
127 | 
128 | 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.
129 | 
130 | Simply set any of the variables above like this:
131 | 
132 | ```sh
133 | oco config set OCO_MODEL=gpt-4o-mini
134 | ```
135 | 
136 | To see all available configuration parameters and their accepted values:
137 | 
138 | ```sh
139 | oco config describe
140 | ```
141 | 
142 | To see details for a specific parameter:
143 | 
144 | ```sh
145 | oco config describe OCO_MODEL
146 | ```
147 | 
148 | Configure [GitMoji](https://gitmoji.dev/) to preface a message.
149 | 
150 | ```sh
151 | oco config set OCO_EMOJI=true
152 | ```
153 | 
154 | To remove preface emojis:
155 | 
156 | ```sh
157 | oco config set OCO_EMOJI=false
158 | ```
159 | 
160 | Other config options are behaving the same.
161 | 
162 | ### Output WHY the changes were done (WIP)
163 | 
164 | 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`.
165 | 
166 | 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.
167 | 
168 | ```sh
169 | oco config set OCO_WHY=true
170 | ```
171 | 
172 | ### Switch to GPT-4 or other models
173 | 
174 | By default, OpenCommit uses `gpt-4o-mini` model.
175 | 
176 | You may switch to gpt-4o which performs better, but costs more 🤠
177 | 
178 | ```sh
179 | oco config set OCO_MODEL=gpt-4o
180 | ```
181 | 
182 | or for as a cheaper option:
183 | 
184 | ```sh
185 | oco config set OCO_MODEL=gpt-3.5-turbo
186 | ```
187 | 
188 | ### Switch to other LLM providers with a custom URL
189 | 
190 | By default OpenCommit uses [OpenAI](https://openai.com).
191 | 
192 | You could switch to [Azure OpenAI Service](https://learn.microsoft.com/azure/cognitive-services/openai/) or Flowise or Ollama.
193 | 
194 | ```sh
195 | oco config set OCO_AI_PROVIDER=azure OCO_API_KEY=<your_azure_api_key> OCO_API_URL=<your_azure_endpoint>
196 | 
197 | oco config set OCO_AI_PROVIDER=flowise OCO_API_KEY=<your_flowise_api_key> OCO_API_URL=<your_flowise_endpoint>
198 | 
199 | oco config set OCO_AI_PROVIDER=ollama OCO_API_KEY=<your_ollama_api_key> OCO_API_URL=<your_ollama_endpoint>
200 | ```
201 | 
202 | ### Locale configuration
203 | 
204 | To globally specify the language used to generate commit messages:
205 | 
206 | ```sh
207 | # de, German, Deutsch
208 | oco config set OCO_LANGUAGE=de
209 | oco config set OCO_LANGUAGE=German
210 | oco config set OCO_LANGUAGE=Deutsch
211 | 
212 | # fr, French, française
213 | oco config set OCO_LANGUAGE=fr
214 | oco config set OCO_LANGUAGE=French
215 | oco config set OCO_LANGUAGE=française
216 | ```
217 | 
218 | The default language setting is **English**
219 | All available languages are currently listed in the [i18n](https://github.com/di-sukharev/opencommit/tree/master/src/i18n) folder
220 | 
221 | ### Push to git (gonna be deprecated)
222 | 
223 | A prompt for pushing to git is on by default but if you would like to turn it off just use:
224 | 
225 | ```sh
226 | oco config set OCO_GITPUSH=false
227 | ```
228 | 
229 | and it will exit right after commit is confirmed without asking if you would like to push to remote.
230 | 
231 | ### Switch to `@commitlint`
232 | 
233 | 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.
234 | 
235 | You can set this option by running the following command:
236 | 
237 | ```sh
238 | oco config set OCO_PROMPT_MODULE=<module>
239 | ```
240 | 
241 | Replace `<module>` with either `conventional-commit` or `@commitlint`.
242 | 
243 | #### Example:
244 | 
245 | To switch to using the `'@commitlint` prompt module, run:
246 | 
247 | ```sh
248 | oco config set OCO_PROMPT_MODULE=@commitlint
249 | ```
250 | 
251 | To switch back to the default conventional-commit message generator, run:
252 | 
253 | ```sh
254 | oco config set OCO_PROMPT_MODULE=conventional-commit
255 | ```
256 | 
257 | #### Integrating with `@commitlint`
258 | 
259 | 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:
260 | 
261 | ```sh
262 | oco commitlint force
263 | ```
264 | 
265 | To view the generated configuration for `@commitlint`, you can use this command:
266 | 
267 | ```sh
268 | oco commitlint get
269 | ```
270 | 
271 | This allows you to ensure that the configuration is set up as desired.
272 | 
273 | 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.
274 | 
275 | 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.
276 | 
277 | This offers you greater control over the generated commit messages, allowing for customization that aligns with your project's conventions.
278 | 
279 | ## Git flags
280 | 
281 | 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`.
282 | 
283 | ```sh
284 | oco --no-verify
285 | ```
286 | 
287 | is translated to :
288 | 
289 | ```sh
290 | git commit -m "${generatedMessage}" --no-verify
291 | ```
292 | 
293 | To include a message in the generated message, you can utilize the template function, for instance:
294 | 
295 | ```sh
296 | oco '#205: $msg’
297 | ```
298 | 
299 | > 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!"
300 | 
301 | ### Message Template Placeholder Config
302 | 
303 | #### Overview
304 | 
305 | 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.
306 | 
307 | #### Implementation Details
308 | 
309 | In our codebase, the implementation of this feature can be found in the following segment:
310 | 
311 | ```javascript
312 | commitMessage = messageTemplate.replace(
313 |   config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
314 |   commitMessage
315 | );
316 | ```
317 | 
318 | This line is responsible for replacing the placeholder in the `messageTemplate` with the actual `commitMessage`.
319 | 
320 | #### Usage
321 | 
322 | 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.
323 | 
324 | #### Committing with the Message
325 | 
326 | 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.
327 | 
328 | ### Ignore files
329 | 
330 | You can remove files from being sent to OpenAI by creating a `.opencommitignore` file. For example:
331 | 
332 | ```ignorelang
333 | path/to/large-asset.zip
334 | **/*.jpg
335 | ```
336 | 
337 | This helps prevent opencommit from uploading artifacts and large files.
338 | 
339 | By default, opencommit ignores files matching: `*-lock.*` and `*.lock`
340 | 
341 | ## Git hook (KILLER FEATURE)
342 | 
343 | 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.
344 | 
345 | To set the hook:
346 | 
347 | ```sh
348 | oco hook set
349 | ```
350 | 
351 | To unset the hook:
352 | 
353 | ```sh
354 | oco hook unset
355 | ```
356 | 
357 | To use the hook:
358 | 
359 | ```sh
360 | git add <files...>
361 | git commit
362 | ```
363 | 
364 | Or follow the process of your IDE Source Control feature, when it calls `git commit` command — OpenCommit will integrate into the flow.
365 | 
366 | ## Setup OpenCommit as a GitHub Action (BETA) 🔥
367 | 
368 | OpenCommit is now available as a GitHub Action which automatically improves all new commits messages when you push to remote!
369 | 
370 | 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`.
371 | 
372 | Create a file `.github/workflows/opencommit.yml` with the contents below:
373 | 
374 | ```yml
375 | name: 'OpenCommit Action'
376 | 
377 | on:
378 |   push:
379 |     # this list of branches is often enough,
380 |     # but you may still ignore other public branches
381 |     branches-ignore: [main master dev development release]
382 | 
383 | jobs:
384 |   opencommit:
385 |     timeout-minutes: 10
386 |     name: OpenCommit
387 |     runs-on: ubuntu-latest
388 |     permissions: write-all
389 |     steps:
390 |       - name: Setup Node.js Environment
391 |         uses: actions/setup-node@v2
392 |         with:
393 |           node-version: '16'
394 |       - uses: actions/checkout@v3
395 |         with:
396 |           fetch-depth: 0
397 |       - uses: di-sukharev/opencommit@github-action-v1.0.4
398 |         with:
399 |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
400 | 
401 |         env:
402 |           # set openAI api key in repo actions secrets,
403 |           # for openAI keys go to: https://platform.openai.com/account/api-keys
404 |           # for repo secret go to: <your_repo_url>/settings/secrets/actions
405 |           OCO_API_KEY: ${{ secrets.OCO_API_KEY }}
406 | 
407 |           # customization
408 |           OCO_TOKENS_MAX_INPUT: 4096
409 |           OCO_TOKENS_MAX_OUTPUT: 500
410 |           OCO_OPENAI_BASE_PATH: ''
411 |           OCO_DESCRIPTION: false
412 |           OCO_EMOJI: false
413 |           OCO_MODEL: gpt-4o
414 |           OCO_LANGUAGE: en
415 |           OCO_PROMPT_MODULE: conventional-commit
416 | ```
417 | 
418 | That is it. Now when you push to any branch in your repo — all NEW commits are being improved by your never-tired AI.
419 | 
420 | Make sure you exclude public collaboration branches (`main`, `dev`, `etc`) in `branches-ignore`, so OpenCommit does not rebase commits there while improving the messages.
421 | 
422 | 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.
423 | 
424 | ## Payments
425 | 
426 | You pay for your requests to OpenAI API on your own.
427 | 
428 | OpenCommit stores your key locally.
429 | 
430 | OpenCommit by default uses 3.5-turbo model, it should not exceed $0.10 per casual working day.
431 | 
432 | You may switch to gpt-4, it's better, but more expensive.
433 | 


--------------------------------------------------------------------------------
/action.yml:
--------------------------------------------------------------------------------
 1 | name: 'OpenCommit — improve commits with AI 🧙'
 2 | description: 'Replaces lame commit messages with meaningful AI-generated messages when you push to remote'
 3 | author: 'https://github.com/di-sukharev'
 4 | repo: 'https://github.com/di-sukharev/opencommit/tree/github-action'
 5 | branding:
 6 |   icon: 'git-commit'
 7 |   color: 'green'
 8 | keywords:
 9 |   [
10 |     'git',
11 |     'chatgpt',
12 |     'gpt',
13 |     'ai',
14 |     'openai',
15 |     'opencommit',
16 |     'aicommit',
17 |     'aicommits',
18 |     'gptcommit',
19 |     'commit'
20 |   ]
21 | 
22 | inputs:
23 |   GITHUB_TOKEN:
24 |     description: 'GitHub token'
25 |     required: true
26 | 
27 | runs:
28 |   using: 'node16'
29 |   main: 'out/github-action.cjs'
30 | 


--------------------------------------------------------------------------------
/esbuild.config.js:
--------------------------------------------------------------------------------
 1 | import { build } from 'esbuild';
 2 | import fs from 'fs';
 3 | 
 4 | await build({
 5 |   entryPoints: ['./src/cli.ts'],
 6 |   bundle: true,
 7 |   platform: 'node',
 8 |   format: 'cjs',
 9 |   outfile: './out/cli.cjs'
10 | });
11 | 
12 | await build({
13 |   entryPoints: ['./src/github-action.ts'],
14 |   bundle: true,
15 |   platform: 'node',
16 |   format: 'cjs',
17 |   outfile: './out/github-action.cjs'
18 | });
19 | 
20 | const wasmFile = fs.readFileSync(
21 |   './node_modules/@dqbd/tiktoken/lite/tiktoken_bg.wasm'
22 | );
23 | 
24 | fs.writeFileSync('./out/tiktoken_bg.wasm', wasmFile);
25 | 


--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * For a detailed explanation regarding each configuration property, visit:
 3 |  * https://jestjs.io/docs/configuration
 4 |  */
 5 | 
 6 | import type { Config } from 'jest';
 7 | 
 8 | const config: Config = {
 9 |   testTimeout: 100_000,
10 |   coverageProvider: 'v8',
11 |   moduleDirectories: ['node_modules', 'src'],
12 |   preset: 'ts-jest/presets/default-esm',
13 |   setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
14 |   testEnvironment: 'node',
15 |   testRegex: ['.*\\.test\\.ts
#39;],
16 |   // Tell Jest to ignore the specific duplicate package.json files
17 |   // that are causing Haste module naming collisions
18 |   modulePathIgnorePatterns: [
19 |     '<rootDir>/test/e2e/prompt-module/data/'
20 |   ],
21 |   transformIgnorePatterns: [
22 |     'node_modules/(?!(cli-testing-library|@clack|cleye)/.*)'
23 |   ],
24 |   transform: {
25 |     '^.+\\.(ts|tsx|js|jsx|mjs)
#39;: [
26 |       'ts-jest',
27 |       {
28 |         diagnostics: false,
29 |         useESM: true,
30 |         tsconfig: {
31 |           module: 'ESNext',
32 |           target: 'ES2022'
33 |         }
34 |       }
35 |     ]
36 |   },
37 |   moduleNameMapper: {
38 |     '^(\\.{1,2}/.*)\\.js
#39;: '$1'
39 |   }
40 | };
41 | 
42 | export default config;
43 | 


--------------------------------------------------------------------------------
/out/tiktoken_bg.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/di-sukharev/opencommit/9971b3c74e5f51397285bc2692f0e79679d37805/out/tiktoken_bg.wasm


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "name": "opencommit",
  3 |   "version": "3.2.9",
  4 |   "description": "Auto-generate impressive commits in 1 second. Killing lame commits with AI 🤯🔫",
  5 |   "keywords": [
  6 |     "git",
  7 |     "chatgpt",
  8 |     "gpt",
  9 |     "ai",
 10 |     "openai",
 11 |     "opencommit",
 12 |     "aicommit",
 13 |     "aicommits",
 14 |     "gptcommit",
 15 |     "commit",
 16 |     "ollama"
 17 |   ],
 18 |   "main": "cli.js",
 19 |   "bin": {
 20 |     "opencommit": "out/cli.cjs",
 21 |     "oco": "out/cli.cjs"
 22 |   },
 23 |   "repository": {
 24 |     "url": "git+https://github.com/di-sukharev/opencommit.git"
 25 |   },
 26 |   "type": "module",
 27 |   "author": "https://github.com/di-sukharev",
 28 |   "license": "MIT",
 29 |   "files": [
 30 |     "out/cli.cjs",
 31 |     "out/tiktoken_bg.wasm"
 32 |   ],
 33 |   "release": {
 34 |     "branches": [
 35 |       "master"
 36 |     ]
 37 |   },
 38 |   "publishConfig": {
 39 |     "access": "public"
 40 |   },
 41 |   "scripts": {
 42 |     "watch": "npm run -S build -- --sourcemap --watch",
 43 |     "start": "node ./out/cli.cjs",
 44 |     "ollama:start": "OCO_AI_PROVIDER='ollama' node ./out/cli.cjs",
 45 |     "dev": "ts-node ./src/cli.ts",
 46 |     "dev:gemini": "OCO_AI_PROVIDER='gemini' ts-node ./src/cli.ts",
 47 |     "build": "npx rimraf out && node esbuild.config.js",
 48 |     "build:push": "npm run build && git add . && git commit -m 'build' && git push",
 49 |     "deploy": "npm publish --tag latest",
 50 |     "deploy:build": "npm run build:push && git push --tags && npm run deploy",
 51 |     "deploy:patch": "npm version patch && npm run deploy:build",
 52 |     "lint": "eslint src --ext ts && tsc --noEmit",
 53 |     "format": "prettier --write src",
 54 |     "format:check": "prettier --check src",
 55 |     "test": "node --no-warnings --experimental-vm-modules $( [ -f ./node_modules/.bin/jest ] && echo ./node_modules/.bin/jest || which jest ) test/unit",
 56 |     "test:all": "npm run test:unit:docker && npm run test:e2e:docker",
 57 |     "test:docker-build": "docker build -t oco-test -f test/Dockerfile .",
 58 |     "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest test/unit",
 59 |     "test:unit:docker": "npm run test:docker-build && DOCKER_CONTENT_TRUST=0 docker run --rm oco-test npm run test:unit",
 60 |     "test:e2e": "npm run test:e2e:setup && jest test/e2e",
 61 |     "test:e2e:setup": "sh test/e2e/setup.sh",
 62 |     "test:e2e:docker": "npm run test:docker-build && DOCKER_CONTENT_TRUST=0 docker run --rm oco-test npm run test:e2e",
 63 |     "mlx:start": "OCO_AI_PROVIDER='mlx' node ./out/cli.cjs"
 64 |   },
 65 |   "devDependencies": {
 66 |     "@commitlint/types": "^17.4.4",
 67 |     "@types/ini": "^1.3.31",
 68 |     "@types/inquirer": "^9.0.3",
 69 |     "@types/jest": "^29.5.12",
 70 |     "@types/node": "^16.18.14",
 71 |     "@typescript-eslint/eslint-plugin": "^8.29.0",
 72 |     "@typescript-eslint/parser": "^8.29.0",
 73 |     "cli-testing-library": "^2.0.2",
 74 |     "dotenv": "^16.0.3",
 75 |     "esbuild": "^0.25.5",
 76 |     "eslint": "^9.24.0",
 77 |     "jest": "^29.7.0",
 78 |     "prettier": "^2.8.4",
 79 |     "rimraf": "^6.0.1",
 80 |     "ts-jest": "^29.1.2",
 81 |     "ts-node": "^10.9.1",
 82 |     "typescript": "^4.9.3"
 83 |   },
 84 |   "dependencies": {
 85 |     "@actions/core": "^1.10.0",
 86 |     "@actions/exec": "^1.1.1",
 87 |     "@actions/github": "^6.0.1",
 88 |     "@anthropic-ai/sdk": "^0.19.2",
 89 |     "@azure/openai": "^1.0.0-beta.12",
 90 |     "@clack/prompts": "^0.6.1",
 91 |     "@dqbd/tiktoken": "^1.0.2",
 92 |     "@google/generative-ai": "^0.11.4",
 93 |     "@mistralai/mistralai": "^1.3.5",
 94 |     "@octokit/webhooks-schemas": "^6.11.0",
 95 |     "@octokit/webhooks-types": "^6.11.0",
 96 |     "axios": "^1.3.4",
 97 |     "chalk": "^5.2.0",
 98 |     "cleye": "^1.3.2",
 99 |     "crypto": "^1.0.1",
100 |     "execa": "^7.0.0",
101 |     "ignore": "^5.2.4",
102 |     "ini": "^3.0.1",
103 |     "inquirer": "^9.1.4",
104 |     "openai": "^4.57.0",
105 |     "punycode": "^2.3.1",
106 |     "zod": "^3.23.8"
107 |   },
108 |   "overrides": {
109 |     "ajv": "^8.17.1",
110 |     "whatwg-url": "^14.0.0"
111 |   }
112 | }
113 | 


--------------------------------------------------------------------------------
/src/CommandsEnum.ts:
--------------------------------------------------------------------------------
1 | export enum COMMANDS {
2 |   config = 'config',
3 |   hook = 'hook',
4 |   commitlint = 'commitlint'
5 | }
6 | 


--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env node
 2 | 
 3 | import { cli } from 'cleye';
 4 | 
 5 | import packageJSON from '../package.json';
 6 | import { commit } from './commands/commit';
 7 | import { commitlintConfigCommand } from './commands/commitlint';
 8 | import { configCommand } from './commands/config';
 9 | import { hookCommand, isHookCalled } from './commands/githook.js';
10 | import { prepareCommitMessageHook } from './commands/prepare-commit-msg-hook';
11 | import { checkIsLatestVersion } from './utils/checkIsLatestVersion';
12 | import { runMigrations } from './migrations/_run.js';
13 | 
14 | const extraArgs = process.argv.slice(2);
15 | 
16 | cli(
17 |   {
18 |     version: packageJSON.version,
19 |     name: 'opencommit',
20 |     commands: [configCommand, hookCommand, commitlintConfigCommand],
21 |     flags: {
22 |       fgm: {
23 |         type: Boolean,
24 |         description: 'Use full GitMoji specification',
25 |         default: false
26 |       },
27 |       context: {
28 |         type: String,
29 |         alias: 'c',
30 |         description: 'Additional user input context for the commit message',
31 |         default: ''
32 |       },
33 |       yes: {
34 |         type: Boolean,
35 |         alias: 'y',
36 |         description: 'Skip commit confirmation prompt',
37 |         default: false
38 |       }
39 |     },
40 |     ignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',
41 |     help: { description: packageJSON.description }
42 |   },
43 |   async ({ flags }) => {
44 |     await runMigrations();
45 |     await checkIsLatestVersion();
46 | 
47 |     if (await isHookCalled()) {
48 |       prepareCommitMessageHook();
49 |     } else {
50 |       commit(extraArgs, flags.context, false, flags.fgm, flags.yes);
51 |     }
52 |   },
53 |   extraArgs
54 | );
55 | 


--------------------------------------------------------------------------------
/src/commands/ENUMS.ts:
--------------------------------------------------------------------------------
1 | export enum COMMANDS {
2 |   config = 'config',
3 |   hook = 'hook',
4 |   commitlint = 'commitlint'
5 | }
6 | 


--------------------------------------------------------------------------------
/src/commands/README.md:
--------------------------------------------------------------------------------
 1 | # @commitlint Module for opencommit
 2 | 
 3 | 1. Load commitlint configuration within tree.
 4 | 2. Generate a commit with commitlint prompt:
 5 |    - Will not run if hash is the same.
 6 |    - Infer a prompt for each commitlint rule.
 7 |    - Ask OpenAI to generate consistency with embedded commitlint rules.
 8 |    - Store configuration close to commitlint configuration.
 9 | 3. Replace conventional-commit prompt with commitlint prompt.
10 | 


--------------------------------------------------------------------------------
/src/commands/commit.ts:
--------------------------------------------------------------------------------
  1 | import {
  2 |   confirm,
  3 |   intro,
  4 |   isCancel,
  5 |   multiselect,
  6 |   outro,
  7 |   select,
  8 |   spinner
  9 | } from '@clack/prompts';
 10 | import chalk from 'chalk';
 11 | import { execa } from 'execa';
 12 | import { generateCommitMessageByDiff } from '../generateCommitMessageFromGitDiff';
 13 | import {
 14 |   assertGitRepo,
 15 |   getChangedFiles,
 16 |   getDiff,
 17 |   getStagedFiles,
 18 |   gitAdd
 19 | } from '../utils/git';
 20 | import { trytm } from '../utils/trytm';
 21 | import { getConfig } from './config';
 22 | 
 23 | const config = getConfig();
 24 | 
 25 | const getGitRemotes = async () => {
 26 |   const { stdout } = await execa('git', ['remote']);
 27 |   return stdout.split('\n').filter((remote) => Boolean(remote.trim()));
 28 | };
 29 | 
 30 | // Check for the presence of message templates
 31 | const checkMessageTemplate = (extraArgs: string[]): string | false => {
 32 |   for (const key in extraArgs) {
 33 |     if (extraArgs[key].includes(config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER))
 34 |       return extraArgs[key];
 35 |   }
 36 |   return false;
 37 | };
 38 | 
 39 | interface GenerateCommitMessageFromGitDiffParams {
 40 |   diff: string;
 41 |   extraArgs: string[];
 42 |   context?: string;
 43 |   fullGitMojiSpec?: boolean;
 44 |   skipCommitConfirmation?: boolean;
 45 | }
 46 | 
 47 | const generateCommitMessageFromGitDiff = async ({
 48 |   diff,
 49 |   extraArgs,
 50 |   context = '',
 51 |   fullGitMojiSpec = false,
 52 |   skipCommitConfirmation = false
 53 | }: GenerateCommitMessageFromGitDiffParams): Promise<void> => {
 54 |   await assertGitRepo();
 55 |   const commitGenerationSpinner = spinner();
 56 |   commitGenerationSpinner.start('Generating the commit message');
 57 | 
 58 |   try {
 59 |     let commitMessage = await generateCommitMessageByDiff(
 60 |       diff,
 61 |       fullGitMojiSpec,
 62 |       context
 63 |     );
 64 | 
 65 |     const messageTemplate = checkMessageTemplate(extraArgs);
 66 |     if (
 67 |       config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER &&
 68 |       typeof messageTemplate === 'string'
 69 |     ) {
 70 |       const messageTemplateIndex = extraArgs.indexOf(messageTemplate);
 71 |       extraArgs.splice(messageTemplateIndex, 1);
 72 | 
 73 |       commitMessage = messageTemplate.replace(
 74 |         config.OCO_MESSAGE_TEMPLATE_PLACEHOLDER,
 75 |         commitMessage
 76 |       );
 77 |     }
 78 | 
 79 |     commitGenerationSpinner.stop('📝 Commit message generated');
 80 | 
 81 |     outro(
 82 |       `Generated commit message:
 83 | ${chalk.grey('——————————————————')}
 84 | ${commitMessage}
 85 | ${chalk.grey('——————————————————')}`
 86 |     );
 87 | 
 88 |     const isCommitConfirmedByUser =
 89 |       skipCommitConfirmation ||
 90 |       (await confirm({
 91 |         message: 'Confirm the commit message?'
 92 |       }));
 93 | 
 94 |     if (isCancel(isCommitConfirmedByUser)) process.exit(1);
 95 | 
 96 |     if (isCommitConfirmedByUser) {
 97 |       const committingChangesSpinner = spinner();
 98 |       committingChangesSpinner.start('Committing the changes');
 99 |       const { stdout } = await execa('git', [
100 |         'commit',
101 |         '-m',
102 |         commitMessage,
103 |         ...extraArgs
104 |       ]);
105 |       committingChangesSpinner.stop(
106 |         `${chalk.green('✔')} Successfully committed`
107 |       );
108 | 
109 |       outro(stdout);
110 | 
111 |       const remotes = await getGitRemotes();
112 | 
113 |       // user isn't pushing, return early
114 |       if (config.OCO_GITPUSH === false) return;
115 | 
116 |       if (!remotes.length) {
117 |         const { stdout } = await execa('git', ['push']);
118 |         if (stdout) outro(stdout);
119 |         process.exit(0);
120 |       }
121 | 
122 |       if (remotes.length === 1) {
123 |         const isPushConfirmedByUser = await confirm({
124 |           message: 'Do you want to run `git push`?'
125 |         });
126 | 
127 |         if (isCancel(isPushConfirmedByUser)) process.exit(1);
128 | 
129 |         if (isPushConfirmedByUser) {
130 |           const pushSpinner = spinner();
131 | 
132 |           pushSpinner.start(`Running 'git push ${remotes[0]}'`);
133 | 
134 |           const { stdout } = await execa('git', [
135 |             'push',
136 |             '--verbose',
137 |             remotes[0]
138 |           ]);
139 | 
140 |           pushSpinner.stop(
141 |             `${chalk.green('✔')} Successfully pushed all commits to ${
142 |               remotes[0]
143 |             }`
144 |           );
145 | 
146 |           if (stdout) outro(stdout);
147 |         } else {
148 |           outro('`git push` aborted');
149 |           process.exit(0);
150 |         }
151 |       } else {
152 |         const skipOption = `don't push`;
153 |         const selectedRemote = (await select({
154 |           message: 'Choose a remote to push to',
155 |           options: [...remotes, skipOption].map((remote) => ({
156 |             value: remote,
157 |             label: remote
158 |           }))
159 |         })) as string;
160 | 
161 |         if (isCancel(selectedRemote)) process.exit(1);
162 | 
163 |         if (selectedRemote !== skipOption) {
164 |           const pushSpinner = spinner();
165 | 
166 |           pushSpinner.start(`Running 'git push ${selectedRemote}'`);
167 | 
168 |           const { stdout } = await execa('git', ['push', selectedRemote]);
169 | 
170 |           if (stdout) outro(stdout);
171 | 
172 |           pushSpinner.stop(
173 |             `${chalk.green(
174 |               '✔'
175 |             )} successfully pushed all commits to ${selectedRemote}`
176 |           );
177 |         }
178 |       }
179 |     } else {
180 |       const regenerateMessage = await confirm({
181 |         message: 'Do you want to regenerate the message?'
182 |       });
183 | 
184 |       if (isCancel(regenerateMessage)) process.exit(1);
185 | 
186 |       if (regenerateMessage) {
187 |         await generateCommitMessageFromGitDiff({
188 |           diff,
189 |           extraArgs,
190 |           fullGitMojiSpec
191 |         });
192 |       }
193 |     }
194 |   } catch (error) {
195 |     commitGenerationSpinner.stop(
196 |       `${chalk.red('✖')} Failed to generate the commit message`
197 |     );
198 | 
199 |     console.log(error);
200 | 
201 |     const err = error as Error;
202 |     outro(`${chalk.red('✖')} ${err?.message || err}`);
203 |     process.exit(1);
204 |   }
205 | };
206 | 
207 | export async function commit(
208 |   extraArgs: string[] = [],
209 |   context: string = '',
210 |   isStageAllFlag: Boolean = false,
211 |   fullGitMojiSpec: boolean = false,
212 |   skipCommitConfirmation: boolean = false
213 | ) {
214 |   if (isStageAllFlag) {
215 |     const changedFiles = await getChangedFiles();
216 | 
217 |     if (changedFiles) await gitAdd({ files: changedFiles });
218 |     else {
219 |       outro('No changes detected, write some code and run `oco` again');
220 |       process.exit(1);
221 |     }
222 |   }
223 | 
224 |   const [stagedFiles, errorStagedFiles] = await trytm(getStagedFiles());
225 |   const [changedFiles, errorChangedFiles] = await trytm(getChangedFiles());
226 | 
227 |   if (!changedFiles?.length && !stagedFiles?.length) {
228 |     outro(chalk.red('No changes detected'));
229 |     process.exit(1);
230 |   }
231 | 
232 |   intro('open-commit');
233 |   if (errorChangedFiles ?? errorStagedFiles) {
234 |     outro(`${chalk.red('✖')} ${errorChangedFiles ?? errorStagedFiles}`);
235 |     process.exit(1);
236 |   }
237 | 
238 |   const stagedFilesSpinner = spinner();
239 | 
240 |   stagedFilesSpinner.start('Counting staged files');
241 | 
242 |   if (stagedFiles.length === 0) {
243 |     stagedFilesSpinner.stop('No files are staged');
244 | 
245 |     const isStageAllAndCommitConfirmedByUser = await confirm({
246 |       message: 'Do you want to stage all files and generate commit message?'
247 |     });
248 | 
249 |     if (isCancel(isStageAllAndCommitConfirmedByUser)) process.exit(1);
250 | 
251 |     if (isStageAllAndCommitConfirmedByUser) {
252 |       await commit(extraArgs, context, true, fullGitMojiSpec);
253 |       process.exit(0);
254 |     }
255 | 
256 |     if (stagedFiles.length === 0 && changedFiles.length > 0) {
257 |       const files = (await multiselect({
258 |         message: chalk.cyan('Select the files you want to add to the commit:'),
259 |         options: changedFiles.map((file) => ({
260 |           value: file,
261 |           label: file
262 |         }))
263 |       })) as string[];
264 | 
265 |       if (isCancel(files)) process.exit(0);
266 | 
267 |       await gitAdd({ files });
268 |     }
269 | 
270 |     await commit(extraArgs, context, false, fullGitMojiSpec);
271 |     process.exit(0);
272 |   }
273 | 
274 |   stagedFilesSpinner.stop(
275 |     `${stagedFiles.length} staged files:\n${stagedFiles
276 |       .map((file) => `  ${file}`)
277 |       .join('\n')}`
278 |   );
279 | 
280 |   const [, generateCommitError] = await trytm(
281 |     generateCommitMessageFromGitDiff({
282 |       diff: await getDiff({ files: stagedFiles }),
283 |       extraArgs,
284 |       context,
285 |       fullGitMojiSpec,
286 |       skipCommitConfirmation
287 |     })
288 |   );
289 | 
290 |   if (generateCommitError) {
291 |     outro(`${chalk.red('✖')} ${generateCommitError}`);
292 |     process.exit(1);
293 |   }
294 | 
295 |   process.exit(0);
296 | }
297 | 


--------------------------------------------------------------------------------
/src/commands/commitlint.ts:
--------------------------------------------------------------------------------
 1 | import { intro, outro } from '@clack/prompts';
 2 | import chalk from 'chalk';
 3 | import { command } from 'cleye';
 4 | import { configureCommitlintIntegration } from '../modules/commitlint/config';
 5 | import { getCommitlintLLMConfig } from '../modules/commitlint/utils';
 6 | import { COMMANDS } from './ENUMS';
 7 | 
 8 | export enum CONFIG_MODES {
 9 |   get = 'get',
10 |   force = 'force'
11 | }
12 | 
13 | export const commitlintConfigCommand = command(
14 |   {
15 |     name: COMMANDS.commitlint,
16 |     parameters: ['<mode>']
17 |   },
18 |   async (argv) => {
19 |     intro('opencommit — configure @commitlint');
20 |     try {
21 |       const { mode } = argv._;
22 | 
23 |       if (mode === CONFIG_MODES.get) {
24 |         const commitLintConfig = await getCommitlintLLMConfig();
25 | 
26 |         outro(JSON.stringify(commitLintConfig, null, 2));
27 | 
28 |         return;
29 |       }
30 | 
31 |       if (mode === CONFIG_MODES.force) {
32 |         await configureCommitlintIntegration(true);
33 |         return;
34 |       }
35 | 
36 |       throw new Error(
37 |         `Unsupported mode: ${mode}. Valid modes are: "force" and "get"`
38 |       );
39 |     } catch (error) {
40 |       outro(`${chalk.red('✖')} ${error}`);
41 |       process.exit(1);
42 |     }
43 |   }
44 | );
45 | 


--------------------------------------------------------------------------------
/src/commands/githook.ts:
--------------------------------------------------------------------------------
  1 | import { intro, outro } from '@clack/prompts';
  2 | import chalk from 'chalk';
  3 | import { command } from 'cleye';
  4 | import { existsSync } from 'fs';
  5 | import fs from 'fs/promises';
  6 | import path from 'path';
  7 | import { assertGitRepo, getCoreHooksPath } from '../utils/git.js';
  8 | import { COMMANDS } from './ENUMS';
  9 | 
 10 | const HOOK_NAME = 'prepare-commit-msg';
 11 | const DEFAULT_SYMLINK_URL = path.join('.git', 'hooks', HOOK_NAME);
 12 | 
 13 | const getHooksPath = async (): Promise<string> => {
 14 |   try {
 15 |     const hooksPath = await getCoreHooksPath();
 16 |     return path.join(hooksPath, HOOK_NAME);
 17 |   } catch (error) {
 18 |     return DEFAULT_SYMLINK_URL;
 19 |   }
 20 | };
 21 | 
 22 | export const isHookCalled = async (): Promise<boolean> => {
 23 |   const hooksPath = await getHooksPath();
 24 |   return process.argv[1].endsWith(hooksPath);
 25 | };
 26 | 
 27 | const isHookExists = async (): Promise<boolean> => {
 28 |   const hooksPath = await getHooksPath();
 29 |   return existsSync(hooksPath);
 30 | };
 31 | 
 32 | export const hookCommand = command(
 33 |   {
 34 |     name: COMMANDS.hook,
 35 |     parameters: ['<set/unset>']
 36 |   },
 37 |   async (argv) => {
 38 |     const HOOK_URL = __filename;
 39 |     const SYMLINK_URL = await getHooksPath();
 40 |     try {
 41 |       await assertGitRepo();
 42 | 
 43 |       const { setUnset: mode } = argv._;
 44 | 
 45 |       if (mode === 'set') {
 46 |         intro(`setting opencommit as '${HOOK_NAME}' hook at ${SYMLINK_URL}`);
 47 | 
 48 |         if (await isHookExists()) {
 49 |           let realPath;
 50 |           try {
 51 |             realPath = await fs.realpath(SYMLINK_URL);
 52 |           } catch (error) {
 53 |             outro(error as string);
 54 |             realPath = null;
 55 |           }
 56 | 
 57 |           if (realPath === HOOK_URL)
 58 |             return outro(`OpenCommit is already set as '${HOOK_NAME}'`);
 59 | 
 60 |           throw new Error(
 61 |             `Different ${HOOK_NAME} is already set. Remove it before setting opencommit as '${HOOK_NAME}' hook.`
 62 |           );
 63 |         }
 64 | 
 65 |         await fs.mkdir(path.dirname(SYMLINK_URL), { recursive: true });
 66 |         await fs.symlink(HOOK_URL, SYMLINK_URL, 'file');
 67 |         await fs.chmod(SYMLINK_URL, 0o755);
 68 | 
 69 |         return outro(`${chalk.green('✔')} Hook set`);
 70 |       }
 71 | 
 72 |       if (mode === 'unset') {
 73 |         intro(
 74 |           `unsetting opencommit as '${HOOK_NAME}' hook from ${SYMLINK_URL}`
 75 |         );
 76 | 
 77 |         if (!(await isHookExists())) {
 78 |           return outro(
 79 |             `OpenCommit wasn't previously set as '${HOOK_NAME}' hook, nothing to remove`
 80 |           );
 81 |         }
 82 | 
 83 |         const realpath = await fs.realpath(SYMLINK_URL);
 84 |         if (realpath !== HOOK_URL) {
 85 |           return outro(
 86 |             `OpenCommit wasn't previously set as '${HOOK_NAME}' hook, but different hook was, if you want to remove it — do it manually`
 87 |           );
 88 |         }
 89 | 
 90 |         await fs.rm(SYMLINK_URL);
 91 |         return outro(`${chalk.green('✔')} Hook is removed`);
 92 |       }
 93 | 
 94 |       throw new Error(
 95 |         `Unsupported mode: ${mode}. Supported modes are: 'set' or 'unset'. Run: \`oco hook set\``
 96 |       );
 97 |     } catch (error) {
 98 |       outro(`${chalk.red('✖')} ${error}`);
 99 |       process.exit(1);
100 |     }
101 |   }
102 | );
103 | 


--------------------------------------------------------------------------------
/src/commands/prepare-commit-msg-hook.ts:
--------------------------------------------------------------------------------
 1 | import chalk from 'chalk';
 2 | import fs from 'fs/promises';
 3 | 
 4 | import { intro, outro, spinner } from '@clack/prompts';
 5 | 
 6 | import { generateCommitMessageByDiff } from '../generateCommitMessageFromGitDiff';
 7 | import { getChangedFiles, getDiff, getStagedFiles, gitAdd } from '../utils/git';
 8 | import { getConfig } from './config';
 9 | 
10 | const [messageFilePath, commitSource] = process.argv.slice(2);
11 | 
12 | export const prepareCommitMessageHook = async (
13 |   isStageAllFlag: Boolean = false
14 | ) => {
15 |   try {
16 |     if (!messageFilePath) {
17 |       throw new Error(
18 |         'Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'
19 |       );
20 |     }
21 | 
22 |     if (commitSource) return;
23 | 
24 |     if (isStageAllFlag) {
25 |       const changedFiles = await getChangedFiles();
26 | 
27 |       if (changedFiles) await gitAdd({ files: changedFiles });
28 |       else {
29 |         outro('No changes detected, write some code and run `oco` again');
30 |         process.exit(1);
31 |       }
32 |     }
33 | 
34 |     const staged = await getStagedFiles();
35 | 
36 |     if (!staged) return;
37 | 
38 |     intro('opencommit');
39 | 
40 |     const config = getConfig();
41 | 
42 |     if (!config.OCO_API_KEY) {
43 |       outro(
44 |         'No OCO_API_KEY is set. Set your key via `oco config set OCO_API_KEY=<value>. For more info see https://github.com/di-sukharev/opencommit'
45 |       );
46 |       return;
47 |     }
48 | 
49 |     const spin = spinner();
50 |     spin.start('Generating commit message');
51 | 
52 |     const commitMessage = await generateCommitMessageByDiff(
53 |       await getDiff({ files: staged })
54 |     );
55 |     spin.stop('Done');
56 | 
57 |     const fileContent = await fs.readFile(messageFilePath);
58 | 
59 |     const divider = '# ---------- [OpenCommit] ---------- #';
60 | 
61 |     await fs.writeFile(
62 |       messageFilePath,
63 |       `# ${commitMessage}\n\n${divider}\n# Remove the # above to use this generated commit message.\n# To cancel the commit, just close this window without making any changes.\n\n${fileContent.toString()}`
64 |     );
65 |   } catch (error) {
66 |     outro(`${chalk.red('✖')} ${error}`);
67 |     process.exit(1);
68 |   }
69 | };
70 | 


--------------------------------------------------------------------------------
/src/engine/Engine.ts:
--------------------------------------------------------------------------------
 1 | import AnthropicClient from '@anthropic-ai/sdk';
 2 | import { OpenAIClient as AzureOpenAIClient } from '@azure/openai';
 3 | import { GoogleGenerativeAI as GeminiClient } from '@google/generative-ai';
 4 | import { AxiosInstance as RawAxiosClient } from 'axios';
 5 | import { OpenAI as OpenAIClient } from 'openai';
 6 | import { Mistral as MistralClient } from '@mistralai/mistralai';
 7 | 
 8 | export interface AiEngineConfig {
 9 |   apiKey: string;
10 |   model: string;
11 |   maxTokensOutput: number;
12 |   maxTokensInput: number;
13 |   baseURL?: string;
14 |   customHeaders?: Record<string, string>;
15 | }
16 | 
17 | type Client =
18 |   | OpenAIClient
19 |   | AzureOpenAIClient
20 |   | AnthropicClient
21 |   | RawAxiosClient
22 |   | GeminiClient
23 |   | MistralClient;
24 | 
25 | export interface AiEngine {
26 |   config: AiEngineConfig;
27 |   client: Client;
28 |   generateCommitMessage(
29 |     messages: Array<OpenAIClient.Chat.Completions.ChatCompletionMessageParam>
30 |   ): Promise<string | null | undefined>;
31 | }
32 | 


--------------------------------------------------------------------------------
/src/engine/anthropic.ts:
--------------------------------------------------------------------------------
 1 | import AnthropicClient from '@anthropic-ai/sdk';
 2 | import {
 3 |   MessageCreateParamsNonStreaming,
 4 |   MessageParam
 5 | } from '@anthropic-ai/sdk/resources/messages.mjs';
 6 | import { outro } from '@clack/prompts';
 7 | import axios from 'axios';
 8 | import chalk from 'chalk';
 9 | import { OpenAI } from 'openai';
10 | import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
11 | import { removeContentTags } from '../utils/removeContentTags';
12 | import { tokenCount } from '../utils/tokenCount';
13 | import { AiEngine, AiEngineConfig } from './Engine';
14 | 
15 | interface AnthropicConfig extends AiEngineConfig {}
16 | 
17 | export class AnthropicEngine implements AiEngine {
18 |   config: AnthropicConfig;
19 |   client: AnthropicClient;
20 | 
21 |   constructor(config) {
22 |     this.config = config;
23 |     this.client = new AnthropicClient({ apiKey: this.config.apiKey });
24 |   }
25 | 
26 |   public generateCommitMessage = async (
27 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
28 |   ): Promise<string | undefined> => {
29 |     const systemMessage = messages.find((msg) => msg.role === 'system')
30 |       ?.content as string;
31 |     const restMessages = messages.filter(
32 |       (msg) => msg.role !== 'system'
33 |     ) as MessageParam[];
34 | 
35 |     const params: MessageCreateParamsNonStreaming = {
36 |       model: this.config.model,
37 |       system: systemMessage,
38 |       messages: restMessages,
39 |       temperature: 0,
40 |       top_p: 0.1,
41 |       max_tokens: this.config.maxTokensOutput
42 |     };
43 |     try {
44 |       const REQUEST_TOKENS = messages
45 |         .map((msg) => tokenCount(msg.content as string) + 4)
46 |         .reduce((a, b) => a + b, 0);
47 | 
48 |       if (
49 |         REQUEST_TOKENS >
50 |         this.config.maxTokensInput - this.config.maxTokensOutput
51 |       ) {
52 |         throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
53 |       }
54 | 
55 |       const data = await this.client.messages.create(params);
56 | 
57 |       const message = data?.content[0].text;
58 |       let content = message;
59 |       return removeContentTags(content, 'think');
60 |     } catch (error) {
61 |       const err = error as Error;
62 |       outro(`${chalk.red('✖')} ${err?.message || err}`);
63 | 
64 |       if (
65 |         axios.isAxiosError<{ error?: { message: string } }>(error) &&
66 |         error.response?.status === 401
67 |       ) {
68 |         const anthropicAiError = error.response.data.error;
69 | 
70 |         if (anthropicAiError?.message) outro(anthropicAiError.message);
71 |         outro(
72 |           'For help look into README https://github.com/di-sukharev/opencommit#setup'
73 |         );
74 |       }
75 | 
76 |       throw err;
77 |     }
78 |   };
79 | }
80 | 


--------------------------------------------------------------------------------
/src/engine/azure.ts:
--------------------------------------------------------------------------------
 1 | import {
 2 |   AzureKeyCredential,
 3 |   OpenAIClient as AzureOpenAIClient
 4 | } from '@azure/openai';
 5 | import { outro } from '@clack/prompts';
 6 | import axios from 'axios';
 7 | import chalk from 'chalk';
 8 | import { OpenAI } from 'openai';
 9 | import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
10 | import { removeContentTags } from '../utils/removeContentTags';
11 | import { tokenCount } from '../utils/tokenCount';
12 | import { AiEngine, AiEngineConfig } from './Engine';
13 | 
14 | interface AzureAiEngineConfig extends AiEngineConfig {
15 |   baseURL: string;
16 |   apiKey: string;
17 | }
18 | 
19 | export class AzureEngine implements AiEngine {
20 |   config: AzureAiEngineConfig;
21 |   client: AzureOpenAIClient;
22 | 
23 |   constructor(config: AzureAiEngineConfig) {
24 |     this.config = config;
25 |     this.client = new AzureOpenAIClient(
26 |       this.config.baseURL,
27 |       new AzureKeyCredential(this.config.apiKey)
28 |     );
29 |   }
30 | 
31 |   generateCommitMessage = async (
32 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
33 |   ): Promise<string | undefined> => {
34 |     try {
35 |       const REQUEST_TOKENS = messages
36 |         .map((msg) => tokenCount(msg.content as string) + 4)
37 |         .reduce((a, b) => a + b, 0);
38 | 
39 |       if (
40 |         REQUEST_TOKENS >
41 |         this.config.maxTokensInput - this.config.maxTokensOutput
42 |       ) {
43 |         throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
44 |       }
45 | 
46 |       const data = await this.client.getChatCompletions(
47 |         this.config.model,
48 |         messages
49 |       );
50 | 
51 |       const message = data.choices[0].message;
52 | 
53 |       if (message?.content === null) {
54 |         return undefined;
55 |       }
56 | 
57 |       let content = message?.content;
58 |       return removeContentTags(content, 'think');
59 |     } catch (error) {
60 |       outro(`${chalk.red('✖')} ${this.config.model}`);
61 | 
62 |       const err = error as Error;
63 |       outro(`${chalk.red('✖')} ${JSON.stringify(error)}`);
64 | 
65 |       if (
66 |         axios.isAxiosError<{ error?: { message: string } }>(error) &&
67 |         error.response?.status === 401
68 |       ) {
69 |         const openAiError = error.response.data.error;
70 | 
71 |         if (openAiError?.message) outro(openAiError.message);
72 |         outro(
73 |           'For help look into README https://github.com/di-sukharev/opencommit#setup'
74 |         );
75 |       }
76 | 
77 |       throw err;
78 |     }
79 |   };
80 | }
81 | 


--------------------------------------------------------------------------------
/src/engine/deepseek.ts:
--------------------------------------------------------------------------------
 1 | import axios from 'axios';
 2 | import { OpenAI } from 'openai';
 3 | import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
 4 | import { removeContentTags } from '../utils/removeContentTags';
 5 | import { tokenCount } from '../utils/tokenCount';
 6 | import { OpenAiEngine, OpenAiConfig } from './openAi';
 7 | 
 8 | export interface DeepseekConfig extends OpenAiConfig {}
 9 | 
10 | export class DeepseekEngine extends OpenAiEngine {
11 |   constructor(config: DeepseekConfig) {
12 |     // Call OpenAIEngine constructor with forced Deepseek baseURL
13 |     super({
14 |       ...config,
15 |       baseURL: 'https://api.deepseek.com/v1'
16 |     });
17 |   }
18 | 
19 |   // Identical method from OpenAiEngine, re-implemented here
20 |   public generateCommitMessage = async (
21 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
22 |   ): Promise<string | null> => {
23 |     const params = {
24 |       model: this.config.model,
25 |       messages,
26 |       temperature: 0,
27 |       top_p: 0.1,
28 |       max_tokens: this.config.maxTokensOutput
29 |     };
30 | 
31 |     try {
32 |       const REQUEST_TOKENS = messages
33 |         .map((msg) => tokenCount(msg.content as string) + 4)
34 |         .reduce((a, b) => a + b, 0);
35 | 
36 |       if (
37 |         REQUEST_TOKENS >
38 |         this.config.maxTokensInput - this.config.maxTokensOutput
39 |       )
40 |         throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
41 | 
42 |       const completion = await this.client.chat.completions.create(params);
43 | 
44 |       const message = completion.choices[0].message;
45 |       let content = message?.content;
46 |       return removeContentTags(content, 'think');
47 |     } catch (error) {
48 |       const err = error as Error;
49 |       if (
50 |         axios.isAxiosError<{ error?: { message: string } }>(error) &&
51 |         error.response?.status === 401
52 |       ) {
53 |         const openAiError = error.response.data.error;
54 | 
55 |         if (openAiError) throw new Error(openAiError.message);
56 |       }
57 | 
58 |       throw err;
59 |     }
60 |   };
61 | }
62 | 


--------------------------------------------------------------------------------
/src/engine/flowise.ts:
--------------------------------------------------------------------------------
 1 | import axios, { AxiosInstance } from 'axios';
 2 | import { OpenAI } from 'openai';
 3 | import { removeContentTags } from '../utils/removeContentTags';
 4 | import { AiEngine, AiEngineConfig } from './Engine';
 5 | 
 6 | interface FlowiseAiConfig extends AiEngineConfig {}
 7 | 
 8 | export class FlowiseEngine implements AiEngine {
 9 |   config: FlowiseAiConfig;
10 |   client: AxiosInstance;
11 | 
12 |   constructor(config) {
13 |     this.config = config;
14 |     this.client = axios.create({
15 |       url: `${config.baseURL}/${config.apiKey}`,
16 |       headers: { 'Content-Type': 'application/json' }
17 |     });
18 |   }
19 | 
20 |   async generateCommitMessage(
21 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
22 |   ): Promise<string | undefined> {
23 |     const gitDiff = (messages[messages.length - 1]?.content as string)
24 |       .replace(/\\/g, '\\\\')
25 |       .replace(/"/g, '\\"')
26 |       .replace(/\n/g, '\\n')
27 |       .replace(/\r/g, '\\r')
28 |       .replace(/\t/g, '\\t');
29 | 
30 |     const payload = {
31 |       question: gitDiff,
32 |       overrideConfig: {
33 |         systemMessagePrompt: messages[0]?.content
34 |       },
35 |       history: messages.slice(1, -1)
36 |     };
37 |     try {
38 |       const response = await this.client.post('', payload);
39 |       const message = response.data;
40 |       let content = message?.text;
41 |       return removeContentTags(content, 'think');
42 |     } catch (err: any) {
43 |       const message = err.response?.data?.error ?? err.message;
44 |       throw new Error('local model issues. details: ' + message);
45 |     }
46 |   }
47 | }
48 | 


--------------------------------------------------------------------------------
/src/engine/gemini.ts:
--------------------------------------------------------------------------------
 1 | import {
 2 |   Content,
 3 |   GoogleGenerativeAI,
 4 |   HarmBlockThreshold,
 5 |   HarmCategory,
 6 |   Part
 7 | } from '@google/generative-ai';
 8 | import axios from 'axios';
 9 | import { OpenAI } from 'openai';
10 | import { removeContentTags } from '../utils/removeContentTags';
11 | import { AiEngine, AiEngineConfig } from './Engine';
12 | 
13 | interface GeminiConfig extends AiEngineConfig {}
14 | 
15 | export class GeminiEngine implements AiEngine {
16 |   config: GeminiConfig;
17 |   client: GoogleGenerativeAI;
18 | 
19 |   constructor(config) {
20 |     this.client = new GoogleGenerativeAI(config.apiKey);
21 |     this.config = config;
22 |   }
23 | 
24 |   async generateCommitMessage(
25 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
26 |   ): Promise<string | undefined> {
27 |     const systemInstruction = messages
28 |       .filter((m) => m.role === 'system')
29 |       .map((m) => m.content)
30 |       .join('\n');
31 | 
32 |     const gemini = this.client.getGenerativeModel({
33 |       model: this.config.model,
34 |       systemInstruction
35 |     });
36 | 
37 |     const contents = messages
38 |       .filter((m) => m.role !== 'system')
39 |       .map(
40 |         (m) =>
41 |           ({
42 |             parts: [{ text: m.content } as Part],
43 |             role: m.role === 'user' ? m.role : 'model'
44 |           } as Content)
45 |       );
46 | 
47 |     try {
48 |       const result = await gemini.generateContent({
49 |         contents,
50 |         safetySettings: [
51 |           {
52 |             category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
53 |             threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
54 |           },
55 |           {
56 |             category: HarmCategory.HARM_CATEGORY_HARASSMENT,
57 |             threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
58 |           },
59 |           {
60 |             category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
61 |             threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
62 |           },
63 |           {
64 |             category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
65 |             threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
66 |           }
67 |         ],
68 |         generationConfig: {
69 |           maxOutputTokens: this.config.maxTokensOutput,
70 |           temperature: 0,
71 |           topP: 0.1
72 |         }
73 |       });
74 | 
75 |       const content = result.response.text();
76 |       return removeContentTags(content, 'think');
77 |     } catch (error) {
78 |       const err = error as Error;
79 |       if (
80 |         axios.isAxiosError<{ error?: { message: string } }>(error) &&
81 |         error.response?.status === 401
82 |       ) {
83 |         const geminiError = error.response.data.error;
84 |         if (geminiError) throw new Error(geminiError?.message);
85 |       }
86 | 
87 |       throw err;
88 |     }
89 |   }
90 | }
91 | 


--------------------------------------------------------------------------------
/src/engine/groq.ts:
--------------------------------------------------------------------------------
 1 | import { OpenAiConfig, OpenAiEngine } from './openAi';
 2 | 
 3 | interface GroqConfig extends OpenAiConfig {}
 4 | 
 5 | export class GroqEngine extends OpenAiEngine {
 6 |   constructor(config: GroqConfig) {
 7 |     config.baseURL = 'https://api.groq.com/openai/v1';
 8 |     super(config);
 9 |   }
10 | }
11 | 


--------------------------------------------------------------------------------
/src/engine/mistral.ts:
--------------------------------------------------------------------------------
 1 | import axios from 'axios';
 2 | import { OpenAI } from 'openai';
 3 | import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
 4 | import { removeContentTags } from '../utils/removeContentTags';
 5 | import { tokenCount } from '../utils/tokenCount';
 6 | import { AiEngine, AiEngineConfig } from './Engine';
 7 | 
 8 | // Using any for Mistral types to avoid type declaration issues
 9 | export interface MistralAiConfig extends AiEngineConfig {}
10 | export type MistralCompletionMessageParam = Array<any>;
11 | 
12 | // Import Mistral dynamically to avoid TS errors
13 | // eslint-disable-next-line @typescript-eslint/no-var-requires
14 | const Mistral = require('@mistralai/mistralai').Mistral;
15 | 
16 | export class MistralAiEngine implements AiEngine {
17 |   config: MistralAiConfig;
18 |   client: any; // Using any type for Mistral client to avoid TS errors
19 | 
20 |   constructor(config: MistralAiConfig) {
21 |     this.config = config;
22 | 
23 |     if (!config.baseURL) {
24 |       this.client = new Mistral({ apiKey: config.apiKey });
25 |     } else {
26 |       this.client = new Mistral({
27 |         apiKey: config.apiKey,
28 |         serverURL: config.baseURL
29 |       });
30 |     }
31 |   }
32 | 
33 |   public generateCommitMessage = async (
34 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
35 |   ): Promise<string | null> => {
36 |     const params = {
37 |       model: this.config.model,
38 |       messages: messages as MistralCompletionMessageParam,
39 |       topP: 0.1,
40 |       maxTokens: this.config.maxTokensOutput
41 |     };
42 | 
43 |     try {
44 |       const REQUEST_TOKENS = messages
45 |         .map((msg) => tokenCount(msg.content as string) + 4)
46 |         .reduce((a, b) => a + b, 0);
47 | 
48 |       if (
49 |         REQUEST_TOKENS >
50 |         this.config.maxTokensInput - this.config.maxTokensOutput
51 |       )
52 |         throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
53 | 
54 |       const completion = await this.client.chat.complete(params);
55 | 
56 |       if (!completion.choices) throw Error('No completion choice available.');
57 | 
58 |       const message = completion.choices[0].message;
59 | 
60 |       if (!message || !message.content)
61 |         throw Error('No completion choice available.');
62 | 
63 |       let content = message.content as string;
64 |       return removeContentTags(content, 'think');
65 |     } catch (error) {
66 |       const err = error as Error;
67 |       if (
68 |         axios.isAxiosError<{ error?: { message: string } }>(error) &&
69 |         error.response?.status === 401
70 |       ) {
71 |         const mistralError = error.response.data.error;
72 | 
73 |         if (mistralError) throw new Error(mistralError.message);
74 |       }
75 | 
76 |       throw err;
77 |     }
78 |   };
79 | }
80 | 


--------------------------------------------------------------------------------
/src/engine/mlx.ts:
--------------------------------------------------------------------------------
 1 | import axios, { AxiosInstance } from 'axios';
 2 | import { OpenAI } from 'openai';
 3 | import { removeContentTags } from '../utils/removeContentTags';
 4 | import { AiEngine, AiEngineConfig } from './Engine';
 5 | 
 6 | interface MLXConfig extends AiEngineConfig {}
 7 | 
 8 | export class MLXEngine implements AiEngine {
 9 |   config: MLXConfig;
10 |   client: AxiosInstance;
11 | 
12 |   constructor(config) {
13 |     this.config = config;
14 |     this.client = axios.create({
15 |       url: config.baseURL
16 |         ? `${config.baseURL}/${config.apiKey}`
17 |         : 'http://localhost:8080/v1/chat/completions',
18 |       headers: { 'Content-Type': 'application/json' }
19 |     });
20 |   }
21 | 
22 |   async generateCommitMessage(
23 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
24 |   ): Promise<string | undefined> {
25 |     const params = {
26 |       messages,
27 |       temperature: 0,
28 |       top_p: 0.1,
29 |       repetition_penalty: 1.5,
30 |       stream: false
31 |     };
32 |     try {
33 |       const response = await this.client.post(
34 |         this.client.getUri(this.config),
35 |         params
36 |       );
37 | 
38 |       const choices = response.data.choices;
39 |       const message = choices[0].message;
40 |       let content = message?.content;
41 |       return removeContentTags(content, 'think');
42 |     } catch (err: any) {
43 |       const message = err.response?.data?.error ?? err.message;
44 |       throw new Error(`MLX provider error: ${message}`);
45 |     }
46 |   }
47 | }
48 | 


--------------------------------------------------------------------------------
/src/engine/ollama.ts:
--------------------------------------------------------------------------------
 1 | import axios, { AxiosInstance } from 'axios';
 2 | import { OpenAI } from 'openai';
 3 | import { removeContentTags } from '../utils/removeContentTags';
 4 | import { AiEngine, AiEngineConfig } from './Engine';
 5 | 
 6 | interface OllamaConfig extends AiEngineConfig {}
 7 | 
 8 | export class OllamaEngine implements AiEngine {
 9 |   config: OllamaConfig;
10 |   client: AxiosInstance;
11 | 
12 |   constructor(config) {
13 |     this.config = config;
14 | 
15 |     // Combine base headers with custom headers
16 |     const headers = {
17 |       'Content-Type': 'application/json',
18 |       ...config.customHeaders
19 |     };
20 | 
21 |     this.client = axios.create({
22 |       url: config.baseURL
23 |         ? `${config.baseURL}/${config.apiKey}`
24 |         : 'http://localhost:11434/api/chat',
25 |       headers
26 |     });
27 |   }
28 | 
29 |   async generateCommitMessage(
30 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
31 |   ): Promise<string | undefined> {
32 |     const params = {
33 |       model: this.config.model ?? 'mistral',
34 |       messages,
35 |       options: { temperature: 0, top_p: 0.1 },
36 |       stream: false
37 |     };
38 |     try {
39 |       const response = await this.client.post(
40 |         this.client.getUri(this.config),
41 |         params
42 |       );
43 | 
44 |       const { message } = response.data;
45 |       let content = message?.content;
46 |       return removeContentTags(content, 'think');
47 |     } catch (err: any) {
48 |       const message = err.response?.data?.error ?? err.message;
49 |       throw new Error(`Ollama provider error: ${message}`);
50 |     }
51 |   }
52 | }
53 | 


--------------------------------------------------------------------------------
/src/engine/openAi.ts:
--------------------------------------------------------------------------------
 1 | import axios from 'axios';
 2 | import { OpenAI } from 'openai';
 3 | import { GenerateCommitMessageErrorEnum } from '../generateCommitMessageFromGitDiff';
 4 | import { parseCustomHeaders } from '../utils/engine';
 5 | import { removeContentTags } from '../utils/removeContentTags';
 6 | import { tokenCount } from '../utils/tokenCount';
 7 | import { AiEngine, AiEngineConfig } from './Engine';
 8 | 
 9 | export interface OpenAiConfig extends AiEngineConfig {}
10 | 
11 | export class OpenAiEngine implements AiEngine {
12 |   config: OpenAiConfig;
13 |   client: OpenAI;
14 | 
15 |   constructor(config: OpenAiConfig) {
16 |     this.config = config;
17 | 
18 |     const clientOptions: OpenAI.ClientOptions = {
19 |       apiKey: config.apiKey
20 |     };
21 | 
22 |     if (config.baseURL) {
23 |       clientOptions.baseURL = config.baseURL;
24 |     }
25 | 
26 |     if (config.customHeaders) {
27 |       const headers = parseCustomHeaders(config.customHeaders);
28 |       if (Object.keys(headers).length > 0) {
29 |         clientOptions.defaultHeaders = headers;
30 |       }
31 |     }
32 | 
33 |     this.client = new OpenAI(clientOptions);
34 |   }
35 | 
36 |   public generateCommitMessage = async (
37 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
38 |   ): Promise<string | null> => {
39 |     const params = {
40 |       model: this.config.model,
41 |       messages,
42 |       temperature: 0,
43 |       top_p: 0.1,
44 |       max_tokens: this.config.maxTokensOutput
45 |     };
46 | 
47 |     try {
48 |       const REQUEST_TOKENS = messages
49 |         .map((msg) => tokenCount(msg.content as string) + 4)
50 |         .reduce((a, b) => a + b, 0);
51 | 
52 |       if (
53 |         REQUEST_TOKENS >
54 |         this.config.maxTokensInput - this.config.maxTokensOutput
55 |       )
56 |         throw new Error(GenerateCommitMessageErrorEnum.tooMuchTokens);
57 | 
58 |       const completion = await this.client.chat.completions.create(params);
59 | 
60 |       const message = completion.choices[0].message;
61 |       let content = message?.content;
62 |       return removeContentTags(content, 'think');
63 |     } catch (error) {
64 |       const err = error as Error;
65 |       if (
66 |         axios.isAxiosError<{ error?: { message: string } }>(error) &&
67 |         error.response?.status === 401
68 |       ) {
69 |         const openAiError = error.response.data.error;
70 | 
71 |         if (openAiError) throw new Error(openAiError.message);
72 |       }
73 | 
74 |       throw err;
75 |     }
76 |   };
77 | }
78 | 


--------------------------------------------------------------------------------
/src/engine/openrouter.ts:
--------------------------------------------------------------------------------
 1 | import OpenAI from 'openai';
 2 | import { AiEngine, AiEngineConfig } from './Engine';
 3 | import axios, { AxiosInstance } from 'axios';
 4 | import { removeContentTags } from '../utils/removeContentTags';
 5 | 
 6 | interface OpenRouterConfig extends AiEngineConfig {}
 7 | 
 8 | export class OpenRouterEngine implements AiEngine {
 9 |   client: AxiosInstance;
10 | 
11 |   constructor(public config: OpenRouterConfig) {
12 |     this.client = axios.create({
13 |       baseURL: 'https://openrouter.ai/api/v1/chat/completions',
14 |       headers: {
15 |         Authorization: `Bearer ${config.apiKey}`,
16 |         'HTTP-Referer': 'https://github.com/di-sukharev/opencommit',
17 |         'X-Title': 'OpenCommit',
18 |         'Content-Type': 'application/json'
19 |       }
20 |     });
21 |   }
22 | 
23 |   public generateCommitMessage = async (
24 |     messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
25 |   ): Promise<string | null> => {
26 |     try {
27 |       const response = await this.client.post('', {
28 |         model: this.config.model,
29 |         messages
30 |       });
31 | 
32 |       const message = response.data.choices[0].message;
33 |       let content = message?.content;
34 |       return removeContentTags(content, 'think');
35 |     } catch (error) {
36 |       const err = error as Error;
37 |       if (
38 |         axios.isAxiosError<{ error?: { message: string } }>(error) &&
39 |         error.response?.status === 401
40 |       ) {
41 |         const openRouterError = error.response.data.error;
42 | 
43 |         if (openRouterError) throw new Error(openRouterError.message);
44 |       }
45 | 
46 |       throw err;
47 |     }
48 |   };
49 | }
50 | 


--------------------------------------------------------------------------------
/src/engine/testAi.ts:
--------------------------------------------------------------------------------
 1 | import { OpenAI } from 'openai';
 2 | 
 3 | import { AiEngine } from './Engine';
 4 | 
 5 | export const TEST_MOCK_TYPES = [
 6 |   'commit-message',
 7 |   'prompt-module-commitlint-config'
 8 | ] as const;
 9 | 
10 | export type TestMockType = (typeof TEST_MOCK_TYPES)[number];
11 | 
12 | type TestAiEngine = Partial<AiEngine> & {
13 |   mockType: TestMockType;
14 | };
15 | 
16 | export class TestAi implements TestAiEngine {
17 |   mockType: TestMockType;
18 | 
19 |   // those are not used in the test engine
20 |   config: any;
21 |   client: any;
22 |   // ---
23 | 
24 |   constructor(mockType: TestMockType) {
25 |     this.mockType = mockType;
26 |   }
27 | 
28 |   async generateCommitMessage(
29 |     _messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>
30 |   ): Promise<string | undefined> {
31 |     switch (this.mockType) {
32 |       case 'commit-message':
33 |         return 'fix(testAi.ts): test commit message';
34 |       case 'prompt-module-commitlint-config':
35 |         return (
36 |           `{\n` +
37 |           `  "localLanguage": "english",\n` +
38 |           `  "commitFix": "fix(server): Change 'port' variable to uppercase 'PORT'",\n` +
39 |           `  "commitFeat": "feat(server): Allow server to listen on a port specified through environment variable",\n` +
40 |           `  "commitDescription": "Change 'port' variable to uppercase 'PORT'. Allow server to listen on a port specified through environment variable."\n` +
41 |           `}`
42 |         );
43 |       default:
44 |         throw Error('unsupported test mock type');
45 |     }
46 |   }
47 | }
48 | 


--------------------------------------------------------------------------------
/src/generateCommitMessageFromGitDiff.ts:
--------------------------------------------------------------------------------
  1 | import { OpenAI } from 'openai';
  2 | import { DEFAULT_TOKEN_LIMITS, getConfig } from './commands/config';
  3 | import { getMainCommitPrompt } from './prompts';
  4 | import { getEngine } from './utils/engine';
  5 | import { mergeDiffs } from './utils/mergeDiffs';
  6 | import { tokenCount } from './utils/tokenCount';
  7 | 
  8 | const config = getConfig();
  9 | const MAX_TOKENS_INPUT = config.OCO_TOKENS_MAX_INPUT;
 10 | const MAX_TOKENS_OUTPUT = config.OCO_TOKENS_MAX_OUTPUT;
 11 | 
 12 | const generateCommitMessageChatCompletionPrompt = async (
 13 |   diff: string,
 14 |   fullGitMojiSpec: boolean,
 15 |   context: string
 16 | ): Promise<Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>> => {
 17 |   const INIT_MESSAGES_PROMPT = await getMainCommitPrompt(
 18 |     fullGitMojiSpec,
 19 |     context
 20 |   );
 21 | 
 22 |   const chatContextAsCompletionRequest = [...INIT_MESSAGES_PROMPT];
 23 | 
 24 |   chatContextAsCompletionRequest.push({
 25 |     role: 'user',
 26 |     content: diff
 27 |   });
 28 | 
 29 |   return chatContextAsCompletionRequest;
 30 | };
 31 | 
 32 | export enum GenerateCommitMessageErrorEnum {
 33 |   tooMuchTokens = 'TOO_MUCH_TOKENS',
 34 |   internalError = 'INTERNAL_ERROR',
 35 |   emptyMessage = 'EMPTY_MESSAGE',
 36 |   outputTokensTooHigh = `Token limit exceeded, OCO_TOKENS_MAX_OUTPUT must not be much higher than the default ${DEFAULT_TOKEN_LIMITS.DEFAULT_MAX_TOKENS_OUTPUT} tokens.`
 37 | }
 38 | 
 39 | const ADJUSTMENT_FACTOR = 20;
 40 | 
 41 | export const generateCommitMessageByDiff = async (
 42 |   diff: string,
 43 |   fullGitMojiSpec: boolean = false,
 44 |   context: string = ''
 45 | ): Promise<string> => {
 46 |   try {
 47 |     const INIT_MESSAGES_PROMPT = await getMainCommitPrompt(
 48 |       fullGitMojiSpec,
 49 |       context
 50 |     );
 51 | 
 52 |     const INIT_MESSAGES_PROMPT_LENGTH = INIT_MESSAGES_PROMPT.map(
 53 |       (msg) => tokenCount(msg.content as string) + 4
 54 |     ).reduce((a, b) => a + b, 0);
 55 | 
 56 |     const MAX_REQUEST_TOKENS =
 57 |       MAX_TOKENS_INPUT -
 58 |       ADJUSTMENT_FACTOR -
 59 |       INIT_MESSAGES_PROMPT_LENGTH -
 60 |       MAX_TOKENS_OUTPUT;
 61 | 
 62 |     if (tokenCount(diff) >= MAX_REQUEST_TOKENS) {
 63 |       const commitMessagePromises = await getCommitMsgsPromisesFromFileDiffs(
 64 |         diff,
 65 |         MAX_REQUEST_TOKENS,
 66 |         fullGitMojiSpec
 67 |       );
 68 | 
 69 |       const commitMessages = [] as string[];
 70 |       for (const promise of commitMessagePromises) {
 71 |         commitMessages.push((await promise) as string);
 72 |         await delay(2000);
 73 |       }
 74 | 
 75 |       return commitMessages.join('\n\n');
 76 |     }
 77 | 
 78 |     const messages = await generateCommitMessageChatCompletionPrompt(
 79 |       diff,
 80 |       fullGitMojiSpec,
 81 |       context
 82 |     );
 83 | 
 84 |     const engine = getEngine();
 85 |     const commitMessage = await engine.generateCommitMessage(messages);
 86 | 
 87 |     if (!commitMessage)
 88 |       throw new Error(GenerateCommitMessageErrorEnum.emptyMessage);
 89 | 
 90 |     return commitMessage;
 91 |   } catch (error) {
 92 |     throw error;
 93 |   }
 94 | };
 95 | 
 96 | function getMessagesPromisesByChangesInFile(
 97 |   fileDiff: string,
 98 |   separator: string,
 99 |   maxChangeLength: number,
100 |   fullGitMojiSpec: boolean
101 | ) {
102 |   const hunkHeaderSeparator = '@@ ';
103 |   const [fileHeader, ...fileDiffByLines] = fileDiff.split(hunkHeaderSeparator);
104 | 
105 |   // merge multiple line-diffs into 1 to save tokens
106 |   const mergedChanges = mergeDiffs(
107 |     fileDiffByLines.map((line) => hunkHeaderSeparator + line),
108 |     maxChangeLength
109 |   );
110 | 
111 |   const lineDiffsWithHeader = [] as string[];
112 |   for (const change of mergedChanges) {
113 |     const totalChange = fileHeader + change;
114 |     if (tokenCount(totalChange) > maxChangeLength) {
115 |       // If the totalChange is too large, split it into smaller pieces
116 |       const splitChanges = splitDiff(totalChange, maxChangeLength);
117 |       lineDiffsWithHeader.push(...splitChanges);
118 |     } else {
119 |       lineDiffsWithHeader.push(totalChange);
120 |     }
121 |   }
122 | 
123 |   const engine = getEngine();
124 |   const commitMsgsFromFileLineDiffs = lineDiffsWithHeader.map(
125 |     async (lineDiff) => {
126 |       const messages = await generateCommitMessageChatCompletionPrompt(
127 |         separator + lineDiff,
128 |         fullGitMojiSpec
129 |       );
130 | 
131 |       return engine.generateCommitMessage(messages);
132 |     }
133 |   );
134 | 
135 |   return commitMsgsFromFileLineDiffs;
136 | }
137 | 
138 | function splitDiff(diff: string, maxChangeLength: number) {
139 |   const lines = diff.split('\n');
140 |   const splitDiffs = [] as string[];
141 |   let currentDiff = '';
142 | 
143 |   if (maxChangeLength <= 0) {
144 |     throw new Error(GenerateCommitMessageErrorEnum.outputTokensTooHigh);
145 |   }
146 | 
147 |   for (let line of lines) {
148 |     // If a single line exceeds maxChangeLength, split it into multiple lines
149 |     while (tokenCount(line) > maxChangeLength) {
150 |       const subLine = line.substring(0, maxChangeLength);
151 |       line = line.substring(maxChangeLength);
152 |       splitDiffs.push(subLine);
153 |     }
154 | 
155 |     // Check the tokenCount of the currentDiff and the line separately
156 |     if (tokenCount(currentDiff) + tokenCount('\n' + line) > maxChangeLength) {
157 |       // If adding the next line would exceed the maxChangeLength, start a new diff
158 |       splitDiffs.push(currentDiff);
159 |       currentDiff = line;
160 |     } else {
161 |       // Otherwise, add the line to the current diff
162 |       currentDiff += '\n' + line;
163 |     }
164 |   }
165 | 
166 |   // Add the last diff
167 |   if (currentDiff) {
168 |     splitDiffs.push(currentDiff);
169 |   }
170 | 
171 |   return splitDiffs;
172 | }
173 | 
174 | export const getCommitMsgsPromisesFromFileDiffs = async (
175 |   diff: string,
176 |   maxDiffLength: number,
177 |   fullGitMojiSpec: boolean
178 | ) => {
179 |   const separator = 'diff --git ';
180 | 
181 |   const diffByFiles = diff.split(separator).slice(1);
182 | 
183 |   // merge multiple files-diffs into 1 prompt to save tokens
184 |   const mergedFilesDiffs = mergeDiffs(diffByFiles, maxDiffLength);
185 | 
186 |   const commitMessagePromises = [] as Promise<string | null | undefined>[];
187 | 
188 |   for (const fileDiff of mergedFilesDiffs) {
189 |     if (tokenCount(fileDiff) >= maxDiffLength) {
190 |       // if file-diff is bigger than gpt context — split fileDiff into lineDiff
191 |       const messagesPromises = getMessagesPromisesByChangesInFile(
192 |         fileDiff,
193 |         separator,
194 |         maxDiffLength,
195 |         fullGitMojiSpec
196 |       );
197 | 
198 |       commitMessagePromises.push(...messagesPromises);
199 |     } else {
200 |       const messages = await generateCommitMessageChatCompletionPrompt(
201 |         separator + fileDiff,
202 |         fullGitMojiSpec
203 |       );
204 | 
205 |       const engine = getEngine();
206 |       commitMessagePromises.push(engine.generateCommitMessage(messages));
207 |     }
208 |   }
209 | 
210 |   return commitMessagePromises;
211 | };
212 | 
213 | function delay(ms: number) {
214 |   return new Promise((resolve) => setTimeout(resolve, ms));
215 | }
216 | 


--------------------------------------------------------------------------------
/src/github-action.ts:
--------------------------------------------------------------------------------
  1 | import core from '@actions/core';
  2 | import exec from '@actions/exec';
  3 | import github from '@actions/github';
  4 | import { intro, outro } from '@clack/prompts';
  5 | import { PushEvent } from '@octokit/webhooks-types';
  6 | import { unlinkSync, writeFileSync } from 'fs';
  7 | import { generateCommitMessageByDiff } from './generateCommitMessageFromGitDiff';
  8 | import { randomIntFromInterval } from './utils/randomIntFromInterval';
  9 | import { sleep } from './utils/sleep';
 10 | 
 11 | // This should be a token with access to your repository scoped in as a secret.
 12 | // The YML workflow will need to set GITHUB_TOKEN with the GitHub Secret Token
 13 | // GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 14 | // https://help.github.com/en/actions/automating-your-workflow-with-github-actions/authenticating-with-the-github_token#about-the-github_token-secret
 15 | const GITHUB_TOKEN = core.getInput('GITHUB_TOKEN');
 16 | const octokit = github.getOctokit(GITHUB_TOKEN);
 17 | const context = github.context;
 18 | const owner = context.repo.owner;
 19 | const repo = context.repo.repo;
 20 | 
 21 | type SHA = string;
 22 | type Diff = string;
 23 | 
 24 | async function getCommitDiff(commitSha: string) {
 25 |   const diffResponse = await octokit.request<string>(
 26 |     'GET /repos/{owner}/{repo}/commits/{ref}',
 27 |     {
 28 |       owner,
 29 |       repo,
 30 |       ref: commitSha,
 31 |       headers: {
 32 |         Accept: 'application/vnd.github.v3.diff'
 33 |       }
 34 |     }
 35 |   );
 36 |   return { sha: commitSha, diff: diffResponse.data };
 37 | }
 38 | 
 39 | interface DiffAndSHA {
 40 |   sha: SHA;
 41 |   diff: Diff;
 42 | }
 43 | 
 44 | interface MsgAndSHA {
 45 |   sha: SHA;
 46 |   msg: string;
 47 | }
 48 | 
 49 | // send only 3-4 size chunks of diffs in steps,
 50 | // because openAI restricts "too many requests" at once with 429 error
 51 | async function improveMessagesInChunks(diffsAndSHAs: DiffAndSHA[]) {
 52 |   const chunkSize = diffsAndSHAs!.length % 2 === 0 ? 4 : 3;
 53 |   outro(`Improving commit messages in chunks of ${chunkSize}.`);
 54 |   const improvePromises = diffsAndSHAs!.map((commit) =>
 55 |     generateCommitMessageByDiff(commit.diff, false)
 56 |   );
 57 | 
 58 |   let improvedMessagesAndSHAs: MsgAndSHA[] = [];
 59 |   for (let step = 0; step < improvePromises.length; step += chunkSize) {
 60 |     const chunkOfPromises = improvePromises.slice(step, step + chunkSize);
 61 | 
 62 |     try {
 63 |       const chunkOfImprovedMessages = await Promise.all(chunkOfPromises);
 64 | 
 65 |       const chunkOfImprovedMessagesBySha = chunkOfImprovedMessages.map(
 66 |         (improvedMsg, i) => {
 67 |           const index = improvedMessagesAndSHAs.length;
 68 |           const sha = diffsAndSHAs![index + i].sha;
 69 | 
 70 |           return { sha, msg: improvedMsg };
 71 |         }
 72 |       );
 73 | 
 74 |       improvedMessagesAndSHAs.push(...chunkOfImprovedMessagesBySha);
 75 | 
 76 |       // sometimes openAI errors with 429 code (too many requests),
 77 |       // so lets sleep a bit
 78 |       const sleepFor =
 79 |         1000 * randomIntFromInterval(1, 5) + 100 * randomIntFromInterval(1, 5);
 80 | 
 81 |       outro(
 82 |         `Improved ${chunkOfPromises.length} messages. Sleeping for ${sleepFor}`
 83 |       );
 84 | 
 85 |       await sleep(sleepFor);
 86 |     } catch (error) {
 87 |       outro(error as string);
 88 | 
 89 |       // if sleeping in try block still fails with 429,
 90 |       // openAI wants at least 1 minute before next request
 91 |       const sleepFor = 60000 + 1000 * randomIntFromInterval(1, 5);
 92 |       outro(`Retrying after sleeping for ${sleepFor}`);
 93 |       await sleep(sleepFor);
 94 | 
 95 |       // go to previous step
 96 |       step -= chunkSize;
 97 |     }
 98 |   }
 99 | 
100 |   return improvedMessagesAndSHAs;
101 | }
102 | 
103 | const getDiffsBySHAs = async (SHAs: string[]) => {
104 |   const diffPromises = SHAs.map((sha) => getCommitDiff(sha));
105 | 
106 |   const diffs = await Promise.all(diffPromises).catch((error) => {
107 |     outro(`Error in Promise.all(getCommitDiffs(SHAs)): ${error}.`);
108 |     throw error;
109 |   });
110 | 
111 |   return diffs;
112 | };
113 | 
114 | async function improveCommitMessages(
115 |   commitsToImprove: { id: string; message: string }[]
116 | ): Promise<void> {
117 |   if (commitsToImprove.length) {
118 |     outro(`Found ${commitsToImprove.length} commits to improve.`);
119 |   } else {
120 |     outro('No new commits found.');
121 |     return;
122 |   }
123 | 
124 |   outro('Fetching commit diffs by SHAs.');
125 |   const commitSHAsToImprove = commitsToImprove.map((commit) => commit.id);
126 |   const diffsWithSHAs = await getDiffsBySHAs(commitSHAsToImprove);
127 |   outro('Done.');
128 | 
129 |   const improvedMessagesWithSHAs = await improveMessagesInChunks(diffsWithSHAs);
130 | 
131 |   console.log(
132 |     `Improved ${improvedMessagesWithSHAs.length} commits: `,
133 |     improvedMessagesWithSHAs
134 |   );
135 | 
136 |   // Check if there are actually any changes in the commit messages
137 |   const messagesChanged = improvedMessagesWithSHAs.some(
138 |     ({ sha, msg }, index) => msg !== commitsToImprove[index].message
139 |   );
140 | 
141 |   if (!messagesChanged) {
142 |     console.log('No changes in commit messages detected, skipping rebase');
143 |     return;
144 |   }
145 | 
146 |   const createCommitMessageFile = (message: string, index: number) =>
147 |     writeFileSync(`./commit-${index}.txt`, message);
148 |   improvedMessagesWithSHAs.forEach(({ msg }, i) =>
149 |     createCommitMessageFile(msg, i)
150 |   );
151 | 
152 |   writeFileSync(`./count.txt`, '0');
153 | 
154 |   writeFileSync(
155 |     './rebase-exec.sh',
156 |     `#!/bin/bash
157 |     count=$(cat count.txt)
158 |     git commit --amend -F commit-$count.txt
159 |     echo $(( count + 1 )) > count.txt`
160 |   );
161 | 
162 |   await exec.exec(`chmod +x ./rebase-exec.sh`);
163 | 
164 |   await exec.exec(
165 |     'git',
166 |     ['rebase', `${commitsToImprove[0].id}^`, '--exec', './rebase-exec.sh'],
167 |     {
168 |       env: {
169 |         GIT_SEQUENCE_EDITOR: 'sed -i -e "s/^pick/reword/g"',
170 |         GIT_COMMITTER_NAME: process.env.GITHUB_ACTOR!,
171 |         GIT_COMMITTER_EMAIL: `${process.env.GITHUB_ACTOR}@users.noreply.github.com`
172 |       }
173 |     }
174 |   );
175 | 
176 |   const deleteCommitMessageFile = (index: number) =>
177 |     unlinkSync(`./commit-${index}.txt`);
178 |   commitsToImprove.forEach((_commit, i) => deleteCommitMessageFile(i));
179 | 
180 |   unlinkSync('./count.txt');
181 |   unlinkSync('./rebase-exec.sh');
182 | 
183 |   outro('Force pushing non-interactively rebased commits into remote.');
184 | 
185 |   await exec.exec('git', ['status']);
186 | 
187 |   // Force push the rebased commits
188 |   await exec.exec('git', ['push', `--force`]);
189 | 
190 |   outro('Done 🧙');
191 | }
192 | 
193 | async function run() {
194 |   intro('OpenCommit — improving lame commit messages');
195 | 
196 |   try {
197 |     if (github.context.eventName === 'push') {
198 |       outro(`Processing commits in a Push event`);
199 | 
200 |       const payload = github.context.payload as PushEvent;
201 | 
202 |       const commits = payload.commits;
203 | 
204 |       // Set local Git user identity for future git history manipulations
205 |       if (payload.pusher.email)
206 |         await exec.exec('git', ['config', 'user.email', payload.pusher.email]);
207 | 
208 |       await exec.exec('git', ['config', 'user.name', payload.pusher.name]);
209 | 
210 |       await exec.exec('git', ['status']);
211 |       await exec.exec('git', ['log', '--oneline']);
212 | 
213 |       await improveCommitMessages(commits);
214 |     } else {
215 |       outro('Wrong action.');
216 |       core.error(
217 |         `OpenCommit was called on ${github.context.payload.action}. OpenCommit is supposed to be used on "push" action.`
218 |       );
219 |     }
220 |   } catch (error: any) {
221 |     const err = error?.message || error;
222 |     core.setFailed(err);
223 |   }
224 | }
225 | 
226 | run();
227 | 


--------------------------------------------------------------------------------
/src/i18n/cs.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "česky",
3 |   "commitFix": "fix(server.ts): zlepšení velikosti proměnné port na velká písmena PORT",
4 |   "commitFeat": "feat(server.ts): přidání podpory pro proměnnou prostředí process.env.PORT",
5 |   "commitDescription": "Proměnná port se nyní jmenuje PORT, což odpovídá konvenci pojmenování, protože PORT je konstanta. Podpora proměnné prostředí process.env.PORT umožňuje snadnější správu nastavení při spuštění.",
6 |   "commitFixOmitScope": "fix: zlepšení velikosti proměnné port na velká písmena PORT",
7 |   "commitFeatOmitScope": "feat: přidání podpory pro proměnnou prostředí process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/de.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "Deutsch",
3 |   "commitFix": "fix(server.ts): Ändere die Groß- und Kleinschreibung der Port-Variable von Kleinbuchstaben auf Großbuchstaben PORT.",
4 |   "commitFeat": "Funktion(server.ts): Unterstützung für die Umgebungsvariable process.env.PORT hinzufügen",
5 |   "commitDescription": "Die Port-Variable heißt jetzt PORT, was die Konsistenz mit den Namenskonventionen verbessert, da PORT eine Konstante ist. Die Unterstützung für eine Umgebungsvariable ermöglicht es der Anwendung, flexibler zu sein, da sie jetzt auf jedem verfügbaren Port laufen kann, der über die Umgebungsvariable process.env.PORT angegeben wird.",
6 |   "commitFixOmitScope": "fix: Ändere die Groß- und Kleinschreibung der Port-Variable von Kleinbuchstaben auf Großbuchstaben PORT.",
7 |   "commitFeatOmitScope": "Funktion: Unterstützung für die Umgebungsvariable process.env.PORT hinzufügen"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/en.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "english",
3 |   "commitFix": "fix(server.ts): change port variable case from lowercase port to uppercase PORT to improve semantics",
4 |   "commitFeat": "feat(server.ts): add support for process.env.PORT environment variable to be able to run app on a configurable port",
5 |   "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.",
6 |   "commitFixOmitScope": "fix: change port variable case from lowercase port to uppercase PORT to improve semantics",
7 |   "commitFeatOmitScope": "feat: add support for process.env.PORT environment variable to be able to run app on a configurable port"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/es_ES.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "spanish",
3 |   "commitFix": "fix(server.ts): cambiar la variable port de minúsculas a mayúsculas PORT",
4 |   "commitFeat": "feat(server.ts): añadir soporte para la variable de entorno process.env.PORT",
5 |   "commitDescription": "La variable port ahora se llama PORT, lo que mejora la coherencia con las convenciones de nomenclatura, ya que PORT es una constante. El soporte para una variable de entorno permite que la aplicación sea más flexible, ya que ahora puede ejecutarse en cualquier puerto disponible especificado a través de la variable de entorno process.env.PORT.",
6 |   "commitFixOmitScope": "fix: cambiar la variable port de minúsculas a mayúsculas PORT",
7 |   "commitFeatOmitScope": "feat: añadir soporte para la variable de entorno process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/fr.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "française",
3 |   "commitFix": "corriger(server.ts) : changer la casse de la variable de port de minuscules à majuscules (PORT)",
4 |   "commitFeat": "fonctionnalité(server.ts) : ajouter la prise en charge de la variable d'environnement process.env.PORT",
5 |   "commitDescription": "La variable de port est maintenant nommée PORT, ce qui améliore la cohérence avec les conventions de nommage car PORT est une constante. La prise en charge d'une variable d'environnement permet à l'application d'être plus flexible car elle peut maintenant s'exécuter sur n'importe quel port disponible spécifié via la variable d'environnement process.env.PORT.",
6 |   "commitFixOmitScope": "corriger : changer la casse de la variable de port de minuscules à majuscules (PORT)",
7 |   "commitFeatOmitScope": "fonctionnalité : ajouter la prise en charge de la variable d'environnement process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/id_ID.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "bahasa",
3 |   "commitFix": "fix(server.ts): mengubah huruf port variable dari huruf kecil ke huruf besar PORT",
4 |   "commitFeat": "feat(server.ts): menambahkan support di process.env.PORT environment variabel",
5 |   "commitDescription": "Port variabel bernama PORT, yang membantu konsistensi dengan memberi nama yaitu PORT yang konstan. Bantuan environment variabel membantu aplikasi lebih fleksibel, dan dapat di jalankan di port manapun yang tertulis pada process.env.PORT",
6 |   "commitFixOmitScope": "fix: mengubah huruf port variable dari huruf kecil ke huruf besar PORT",
7 |   "commitFeatOmitScope": "feat: menambahkan support di process.env.PORT environment variabel"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/index.ts:
--------------------------------------------------------------------------------
 1 | import cs from '../i18n/cs.json';
 2 | import de from '../i18n/de.json';
 3 | import en from '../i18n/en.json';
 4 | import es_ES from '../i18n/es_ES.json';
 5 | import fr from '../i18n/fr.json';
 6 | import id_ID from '../i18n/id_ID.json';
 7 | import it from '../i18n/it.json';
 8 | import ja from '../i18n/ja.json';
 9 | import ko from '../i18n/ko.json';
10 | import nl from '../i18n/nl.json';
11 | import pl from '../i18n/pl.json';
12 | import pt_br from '../i18n/pt_br.json';
13 | import ru from '../i18n/ru.json';
14 | import sv from '../i18n/sv.json';
15 | import th from '../i18n/th.json';
16 | import tr from '../i18n/tr.json';
17 | import vi_VN from '../i18n/vi_VN.json';
18 | import zh_CN from '../i18n/zh_CN.json';
19 | import zh_TW from '../i18n/zh_TW.json';
20 | 
21 | export enum I18nLocals {
22 |   'en' = 'en',
23 |   'zh_CN' = 'zh_CN',
24 |   'zh_TW' = 'zh_TW',
25 |   'ja' = 'ja',
26 |   'cs' = 'cs',
27 |   'de' = 'de',
28 |   'fr' = 'fr',
29 |   'nl' = 'nl',
30 |   'it' = 'it',
31 |   'ko' = 'ko',
32 |   'pt_br' = 'pt_br',
33 |   'es_ES' = 'es_ES',
34 |   'sv' = 'sv',
35 |   'ru' = 'ru',
36 |   'id_ID' = 'id_ID',
37 |   'pl' = 'pl',
38 |   'tr' = 'tr',
39 |   'th' = 'th'
40 | }
41 | 
42 | export const i18n = {
43 |   en,
44 |   zh_CN,
45 |   zh_TW,
46 |   ja,
47 |   cs,
48 |   de,
49 |   fr,
50 |   it,
51 |   ko,
52 |   pt_br,
53 |   vi_VN,
54 |   es_ES,
55 |   sv,
56 |   id_ID,
57 |   nl,
58 |   ru,
59 |   pl,
60 |   tr,
61 |   th
62 | };
63 | 
64 | export const I18N_CONFIG_ALIAS: { [key: string]: string[] } = {
65 |   zh_CN: ['zh_CN', '简体中文', '中文', '简体'],
66 |   zh_TW: ['zh_TW', '繁體中文', '繁體'],
67 |   ja: ['ja', 'Japanese', 'にほんご'],
68 |   ko: ['ko', 'Korean', '한국어'],
69 |   cs: ['cs', 'Czech', 'česky'],
70 |   de: ['de', 'German', 'Deutsch'],
71 |   fr: ['fr', 'French', 'française'],
72 |   it: ['it', 'Italian', 'italiano'],
73 |   nl: ['nl', 'Dutch', 'Nederlands'],
74 |   pt_br: ['pt_br', 'Portuguese', 'português'],
75 |   vi_VN: ['vi_VN', 'Vietnamese', 'tiếng Việt'],
76 |   en: ['en', 'English', 'english'],
77 |   es_ES: ['es_ES', 'Spanish', 'español'],
78 |   sv: ['sv', 'Swedish', 'Svenska'],
79 |   ru: ['ru', 'Russian', 'русский'],
80 |   id_ID: ['id_ID', 'Bahasa', 'bahasa'],
81 |   pl: ['pl', 'Polish', 'Polski'],
82 |   tr: ['tr', 'Turkish', 'Turkish'],
83 |   th: ['th', 'Thai', 'ไทย']
84 | };
85 | 
86 | export function getI18nLocal(value: string): string | boolean {
87 |   for (const key in I18N_CONFIG_ALIAS) {
88 |     const aliases = I18N_CONFIG_ALIAS[key];
89 |     if (aliases.includes(value)) {
90 |       return key;
91 |     }
92 |   }
93 |   return false;
94 | }
95 | 


--------------------------------------------------------------------------------
/src/i18n/it.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "italiano",
3 |   "commitFix": "fix(server.ts): cambia la grafia della variabile della porta dal minuscolo port al maiuscolo PORT",
4 |   "commitFeat": "feat(server.ts): aggiunge il supporto per la variabile di ambiente process.env.PORT",
5 |   "commitDescription": "La variabile port è ora chiamata PORT, migliorando la coerenza con le convenzioni di denominazione in quanto PORT è una costante. Il supporto per una variabile di ambiente consente all'applicazione di essere più flessibile poiché ora può essere eseguita su qualsiasi porta disponibile specificata tramite la variabile di ambiente process.env.PORT.",
6 |   "commitFixOmitScope": "fix: cambia la grafia della variabile della porta dal minuscolo port al maiuscolo PORT",
7 |   "commitFeatOmitScope": "feat: aggiunge il supporto per la variabile di ambiente process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/ja.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "日本語",
3 |   "commitFix": "修正(server.ts): ポート変数を小文字のportから大文字のPORTに変更",
4 |   "commitFeat": "新機能(server.ts): 環境変数process.env.PORTのサポートを追加",
5 |   "commitDescription": "ポート変数は現在PORTという名前になり、定数であるPORTを使うことで命名規則に一貫性が生まれました。環境変数をサポートすることで、環境変数process.env.PORTで指定された任意の利用可能なポートで実行できるようになり、アプリケーションはより柔軟になりました。",
6 |   "commitFixOmitScope": "修正: ポート変数を小文字のportから大文字のPORTに変更",
7 |   "commitFeatOmitScope": "新機能: 環境変数process.env.PORTのサポートを追加"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/ko.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "한국어",
3 |   "commitFix": "fix(server.ts): 포트 변수를 소문자 port에서 대문자 PORT로 변경",
4 |   "commitFeat": "피트(server.ts): process.env.PORT 환경 변수 지원 추가",
5 |   "commitDescription": "포트 변수는 이제 PORT로 이름이 지정되어 상수인 PORT와 일관성 있는 이름 규칙을 따릅니다. 환경 변수 지원을 통해 애플리케이션은 이제 process.env.PORT 환경 변수로 지정된 사용 가능한 모든 포트에서 실행할 수 있으므로 더 유연해졌습니다.",
6 |   "commitFixOmitScope": "fix: 포트 변수를 소문자 port에서 대문자 PORT로 변경",
7 |   "commitFeatOmitScope": "피트: process.env.PORT 환경 변수 지원 추가"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/nl.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "Nederlands",
3 |   "commitFix": "fix(server.ts): verander poortvariabele van kleine letters poort naar hoofdletters PORT",
4 |   "commitFeat": "feat(server.ts): voeg ondersteuning toe voor process.env.PORT omgevingsvariabele",
5 |   "commitDescription": "De poortvariabele heet nu PORT, wat de consistentie met de naamgevingsconventies verbetert omdat PORT een constante is. Ondersteuning voor een omgevingsvariabele maakt de applicatie flexibeler, omdat deze nu kan draaien op elke beschikbare poort die is gespecificeerd via de process.env.PORT omgevingsvariabele.",
6 |   "commitFixOmitScope": "fix: verander poortvariabele van kleine letters poort naar hoofdletters PORT",
7 |   "commitFeatOmitScope": "feat: voeg ondersteuning toe voor process.env.PORT omgevingsvariabele"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/pl.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "polski",
3 |   "commitFix": "fix(server.ts): poprawa wielkości zmiennej port na pisane z dużymi literami PORT",
4 |   "commitFeat": "feat(server.ts): dodanie obsługi zmiennej środowiskowej process.env.PORT",
5 |   "commitDescription": "Zmienna port jest teraz nazwana PORT, co jest zgodne z konwencją nazewniczą ponieważ PORT jest stałą. Obsługa zmiennej środowiskowej process.env.PORT pozwala łatwiej zarządzać ustawieniami przy starcie.",
6 |   "commitFixOmitScope": "fix: poprawa wielkości zmiennej port na pisane z dużymi literami PORT",
7 |   "commitFeatOmitScope": "feat: dodanie obsługi zmiennej środowiskowej process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/pt_br.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "português",
3 |   "commitFix": "fix(server.ts): altera o caso da variável de porta de port minúscula para PORT maiúscula",
4 |   "commitFeat": "feat(server.ts): adiciona suporte para a variável de ambiente process.env.PORT",
5 |   "commitDescription": "A variável de porta agora é denominada PORT, o que melhora a consistência com as convenções de nomenclatura, pois PORT é uma constante. O suporte para uma variável de ambiente permite que o aplicativo seja mais flexível, pois agora pode ser executado em qualquer porta disponível especificada por meio da variável de ambiente process.env.PORT.",
6 |   "commitFixOmitScope": "fix: altera o caso da variável de porta de port minúscula para PORT maiúscula",
7 |   "commitFeatOmitScope": "feat: adiciona suporte para a variável de ambiente process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/ru.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "русский",
3 |   "commitFix": "fix(server.ts): изменение регистра переменной порта с нижнего регистра port на верхний регистр PORT",
4 |   "commitFeat": "feat(server.ts): добавлена поддержка переменной окружения process.env.PORT",
5 |   "commitDescription": "Переменная port теперь называется PORT, что улучшает согласованность с соглашениями об именовании констант. Поддержка переменной окружения позволяет приложению быть более гибким, запускаясь на любом доступном порту, указанном с помощью переменной окружения process.env.PORT.",
6 |   "commitFixOmitScope": "fix: изменение регистра переменной порта с нижнего регистра port на верхний регистр PORT",
7 |   "commitFeatOmitScope": "feat: добавлена поддержка переменной окружения process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/sv.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "svenska",
3 |   "commitFix": "fixa(server.ts): ändra variabelnamnet för port från små bokstäver till stora bokstäver PORT",
4 |   "commitFeat": "nyhet(server.ts): lägg till stöd för process.env.PORT miljövariabel",
5 |   "commitDescription": "Variabeln som innehåller portnumret heter nu PORT vilket förbättrar konsekvensen med namngivningskonventionerna eftersom PORT är en konstant. Stöd för en miljövariabel gör att applikationen kan vara mer flexibel då den nu kan köras på vilken port som helst som specificeras via miljövariabeln process.env.PORT.",
6 |   "commitFixOmitScope": "fixa: ändra variabelnamnet för port från små bokstäver till stora bokstäver PORT",
7 |   "commitFeatOmitScope": "nyhet: lägg till stöd för process.env.PORT miljövariabel"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/th.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "ไทย",
3 |   "commitFix": "fix(server.ts): เปลี่ยนตัวพิมพ์ของตัวแปร จากตัวพิมพ์เล็ก port เป็นตัวพิมพ์ใหญ่ PORT",
4 |   "commitFeat": "feat(server.ts): เพิ่มการรองรับสำหรับตัวแปรสภาพแวดล้อม process.env.PORT",
5 |   "commitDescription": "ตอนนี้ตัวแปรพอร์ตมีชื่อว่า PORT, ซึ่งปรับปรุงความสอดคล้องกับหลักการตั้งชื่อเนื่องจาก PORT เป็นค่าคงที่. การสนับสนุนสำหรับตัวแปรสภาพแวดล้อม ช่วยให้แอปพลิเคชันมีความยืดหยุ่นมากขึ้นเนื่องจาก สามารถทำงานบนพอร์ตใด ๆ ตามที่กำหนด ซึ่งระบุผ่านตัวแปรสภาพแวดล้อม process.env.PORT",
6 |   "commitFixOmitScope": "fix: เปลี่ยนตัวพิมพ์ของตัวแปร จากตัวพิมพ์เล็ก port เป็นตัวพิมพ์ใหญ่ PORT",
7 |   "commitFeatOmitScope": "feat: เพิ่มการรองรับสำหรับตัวแปรสภาพแวดล้อม process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/tr.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "Turkish",
3 |   "commitFix": "fix(server.ts): port değişkeni küçük harfli porttan büyük harfli PORT'a değiştirildi",
4 |   "commitFeat": "feat(server.ts): process.env.PORT ortam değişkeni için destek eklendi.",
5 |   "commitDescription": "Bağlantı noktası değişkeni artık PORT olarak adlandırıldı ve PORT bir sabit değişken olduğu için bu adlandırma tutarlılığı artırır. Ortam değişkeni desteği, artık process.env.PORT ortam değişkeni aracılığıyla belirtilen herhangi bir kullanılabilir bağlantı noktasında çalışabileceğinden uygulamanın daha esnek olmasını sağlar.",
6 |   "commitFixOmitScope": "fix: port değişkeni küçük harfli porttan büyük harfli PORT'a değiştirildi",
7 |   "commitFeatOmitScope": "feat: process.env.PORT ortam değişkeni için destek eklendi."
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/vi_VN.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "vietnamese",
3 |   "commitFix": "fix(server.ts): thay đổi chữ viết thường của biến port thành chữ viết hoa PORT",
4 |   "commitFeat": "feat(server.ts): thêm hỗ trợ cho biến môi trường process.env.PORT",
5 |   "commitDescription": "Biến port đã được đổi tên thành PORT, giúp cải thiện tính nhất quán trong việc đặt tên theo quy ước vì PORT là một hằng số. Hỗ trợ cho biến môi trường cho phép ứng dụng linh hoạt hơn khi có thể chạy trên bất kỳ cổng nào được chỉ định thông qua biến môi trường process.env.PORT.",
6 |   "commitFixOmitScope": "fix: thay đổi chữ viết thường của biến port thành chữ viết hoa PORT",
7 |   "commitFeatOmitScope": "feat: thêm hỗ trợ cho biến môi trường process.env.PORT"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/zh_CN.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "简体中文",
3 |   "commitFix": "fix(server.ts):将端口变量从小写port改为大写PORT",
4 |   "commitFeat": "feat(server.ts):添加对process.env.PORT环境变量的支持",
5 |   "commitDescription": "现在端口变量被命名为PORT,这提高了命名约定的一致性,因为PORT是一个常量。环境变量的支持使应用程序更加灵活,因为它现在可以通过process.env.PORT环境变量在任何可用端口上运行。",
6 |   "commitFixOmitScope": "fix:将端口变量从小写port改为大写PORT",
7 |   "commitFeatOmitScope": "feat:添加对process.env.PORT环境变量的支持"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/i18n/zh_TW.json:
--------------------------------------------------------------------------------
1 | {
2 |   "localLanguage": "繁體中文",
3 |   "commitFix": "修正(server.ts):將端口變數從小寫端口改為大寫PORT",
4 |   "commitFeat": "功能(server.ts):新增對process.env.PORT環境變數的支援",
5 |   "commitDescription": "現在port變數已更名為PORT,以符合命名慣例,因為PORT是一個常量。支援環境變數可以使應用程序更靈活,因為它現在可以通過process.env.PORT環境變數運行在任何可用端口上。",
6 |   "commitFixOmitScope": "修正:將端口變數從小寫端口改為大寫PORT",
7 |   "commitFeatOmitScope": "功能:新增對process.env.PORT環境變數的支援"
8 | }
9 | 


--------------------------------------------------------------------------------
/src/migrations/00_use_single_api_key_and_url.ts:
--------------------------------------------------------------------------------
 1 | import {
 2 |   CONFIG_KEYS,
 3 |   getConfig,
 4 |   OCO_AI_PROVIDER_ENUM,
 5 |   setConfig
 6 | } from '../commands/config';
 7 | 
 8 | export default function () {
 9 |   const config = getConfig({ setDefaultValues: false });
10 | 
11 |   const aiProvider = config.OCO_AI_PROVIDER;
12 | 
13 |   let apiKey: string | undefined;
14 |   let apiUrl: string | undefined;
15 | 
16 |   if (aiProvider === OCO_AI_PROVIDER_ENUM.OLLAMA) {
17 |     apiKey = config['OCO_OLLAMA_API_KEY'];
18 |     apiUrl = config['OCO_OLLAMA_API_URL'];
19 |   } else if (aiProvider === OCO_AI_PROVIDER_ENUM.ANTHROPIC) {
20 |     apiKey = config['OCO_ANTHROPIC_API_KEY'];
21 |     apiUrl = config['OCO_ANTHROPIC_BASE_PATH'];
22 |   } else if (aiProvider === OCO_AI_PROVIDER_ENUM.OPENAI) {
23 |     apiKey = config['OCO_OPENAI_API_KEY'];
24 |     apiUrl = config['OCO_OPENAI_BASE_PATH'];
25 |   } else if (aiProvider === OCO_AI_PROVIDER_ENUM.AZURE) {
26 |     apiKey = config['OCO_AZURE_API_KEY'];
27 |     apiUrl = config['OCO_AZURE_ENDPOINT'];
28 |   } else if (aiProvider === OCO_AI_PROVIDER_ENUM.GEMINI) {
29 |     apiKey = config['OCO_GEMINI_API_KEY'];
30 |     apiUrl = config['OCO_GEMINI_BASE_PATH'];
31 |   } else if (aiProvider === OCO_AI_PROVIDER_ENUM.FLOWISE) {
32 |     apiKey = config['OCO_FLOWISE_API_KEY'];
33 |     apiUrl = config['OCO_FLOWISE_ENDPOINT'];
34 |   } else {
35 |     throw new Error(
36 |       `Migration failed, set AI provider first. Run "oco config set OCO_AI_PROVIDER=<provider>", where <provider> is one of: ${Object.values(
37 |         OCO_AI_PROVIDER_ENUM
38 |       ).join(', ')}`
39 |     );
40 |   }
41 | 
42 |   if (apiKey) setConfig([[CONFIG_KEYS.OCO_API_KEY, apiKey]]);
43 | 
44 |   if (apiUrl) setConfig([[CONFIG_KEYS.OCO_API_URL, apiUrl]]);
45 | }
46 | 


--------------------------------------------------------------------------------
/src/migrations/01_remove_obsolete_config_keys_from_global_file.ts:
--------------------------------------------------------------------------------
 1 | import { getGlobalConfig, setGlobalConfig } from '../commands/config';
 2 | 
 3 | export default function () {
 4 |   const obsoleteKeys = [
 5 |     'OCO_OLLAMA_API_KEY',
 6 |     'OCO_OLLAMA_API_URL',
 7 |     'OCO_ANTHROPIC_API_KEY',
 8 |     'OCO_ANTHROPIC_BASE_PATH',
 9 |     'OCO_OPENAI_API_KEY',
10 |     'OCO_OPENAI_BASE_PATH',
11 |     'OCO_AZURE_API_KEY',
12 |     'OCO_AZURE_ENDPOINT',
13 |     'OCO_GEMINI_API_KEY',
14 |     'OCO_GEMINI_BASE_PATH',
15 |     'OCO_FLOWISE_API_KEY',
16 |     'OCO_FLOWISE_ENDPOINT'
17 |   ];
18 | 
19 |   const globalConfig = getGlobalConfig();
20 | 
21 |   const configToOverride = { ...globalConfig };
22 | 
23 |   for (const key of obsoleteKeys) delete configToOverride[key];
24 | 
25 |   setGlobalConfig(configToOverride);
26 | }
27 | 


--------------------------------------------------------------------------------
/src/migrations/02_set_missing_default_values.ts:
--------------------------------------------------------------------------------
 1 | import {
 2 |   ConfigType,
 3 |   DEFAULT_CONFIG,
 4 |   getGlobalConfig,
 5 |   setConfig
 6 | } from '../commands/config';
 7 | 
 8 | export default function () {
 9 |   const setDefaultConfigValues = (config: ConfigType) => {
10 |     const entriesToSet: [key: string, value: string | boolean | number][] = [];
11 |     for (const entry of Object.entries(DEFAULT_CONFIG)) {
12 |       const [key, _value] = entry;
13 |       if (config[key] === 'undefined' || config[key] === undefined)
14 |         entriesToSet.push(entry);
15 |     }
16 | 
17 |     if (entriesToSet.length > 0) setConfig(entriesToSet);
18 |     console.log(entriesToSet);
19 |   };
20 | 
21 |   setDefaultConfigValues(getGlobalConfig());
22 | }
23 | 


--------------------------------------------------------------------------------
/src/migrations/_migrations.ts:
--------------------------------------------------------------------------------
 1 | import migration00 from './00_use_single_api_key_and_url';
 2 | import migration01 from './01_remove_obsolete_config_keys_from_global_file';
 3 | import migration02 from './02_set_missing_default_values';
 4 | 
 5 | export const migrations = [
 6 |   {
 7 |     name: '00_use_single_api_key_and_url',
 8 |     run: migration00
 9 |   },
10 |   {
11 |     name: '01_remove_obsolete_config_keys_from_global_file',
12 |     run: migration01
13 |   },
14 |   {
15 |     name: '02_set_missing_default_values',
16 |     run: migration02
17 |   }
18 | ];
19 | 


--------------------------------------------------------------------------------
/src/migrations/_run.ts:
--------------------------------------------------------------------------------
 1 | import fs from 'fs';
 2 | import { homedir } from 'os';
 3 | import { join as pathJoin } from 'path';
 4 | import { migrations } from './_migrations';
 5 | import { outro } from '@clack/prompts';
 6 | import chalk from 'chalk';
 7 | import {
 8 |   getConfig,
 9 |   getIsGlobalConfigFileExist,
10 |   OCO_AI_PROVIDER_ENUM
11 | } from '../commands/config';
12 | 
13 | const migrationsFile = pathJoin(homedir(), '.opencommit_migrations');
14 | 
15 | const getCompletedMigrations = (): string[] => {
16 |   if (!fs.existsSync(migrationsFile)) {
17 |     return [];
18 |   }
19 |   const data = fs.readFileSync(migrationsFile, 'utf-8');
20 |   return data ? JSON.parse(data) : [];
21 | };
22 | 
23 | const saveCompletedMigration = (migrationName: string) => {
24 |   const completedMigrations = getCompletedMigrations();
25 |   completedMigrations.push(migrationName);
26 |   fs.writeFileSync(
27 |     migrationsFile,
28 |     JSON.stringify(completedMigrations, null, 2)
29 |   );
30 | };
31 | 
32 | export const runMigrations = async () => {
33 |   // if no config file, we assume it's a new installation and no migrations are needed
34 |   if (!getIsGlobalConfigFileExist()) return;
35 | 
36 |   const config = getConfig();
37 |   if (config.OCO_AI_PROVIDER === OCO_AI_PROVIDER_ENUM.TEST) return;
38 | 
39 |   // skip unhandled providers in migration00
40 |   if (
41 |     [
42 |       OCO_AI_PROVIDER_ENUM.DEEPSEEK,
43 |       OCO_AI_PROVIDER_ENUM.GROQ,
44 |       OCO_AI_PROVIDER_ENUM.MISTRAL,
45 |       OCO_AI_PROVIDER_ENUM.MLX,
46 |       OCO_AI_PROVIDER_ENUM.OPENROUTER,
47 |     ].includes(config.OCO_AI_PROVIDER)
48 |   ) {
49 |     return;
50 |   }
51 | 
52 |   const completedMigrations = getCompletedMigrations();
53 | 
54 |   let isMigrated = false;
55 | 
56 |   for (const migration of migrations) {
57 |     if (!completedMigrations.includes(migration.name)) {
58 |       try {
59 |         console.log('Applying migration', migration.name);
60 |         migration.run();
61 |         console.log('Migration applied successfully', migration.name);
62 |         saveCompletedMigration(migration.name);
63 |       } catch (error) {
64 |         outro(
65 |           `${chalk.red('Failed to apply migration')} ${
66 |             migration.name
67 |           }: ${error}`
68 |         );
69 |         process.exit(1);
70 |       }
71 | 
72 |       isMigrated = true;
73 |     }
74 |   }
75 | 
76 |   if (isMigrated) {
77 |     outro(
78 |       `${chalk.green(
79 |         '✔'
80 |       )} Migrations to your config were applied successfully. Please rerun.`
81 |     );
82 |     process.exit(0);
83 |   }
84 | };
85 | 


--------------------------------------------------------------------------------
/src/modules/commitlint/config.ts:
--------------------------------------------------------------------------------
 1 | import { spinner } from '@clack/prompts';
 2 | 
 3 | import { getConfig } from '../../commands/config';
 4 | import { i18n, I18nLocals } from '../../i18n';
 5 | import { getEngine } from '../../utils/engine';
 6 | import { COMMITLINT_LLM_CONFIG_PATH } from './constants';
 7 | import { computeHash } from './crypto';
 8 | import { commitlintPrompts, inferPromptsFromCommitlintConfig } from './prompts';
 9 | import { getCommitLintPWDConfig } from './pwd-commitlint';
10 | import { CommitlintLLMConfig } from './types';
11 | import * as utils from './utils';
12 | 
13 | const config = getConfig();
14 | const translation = i18n[(config.OCO_LANGUAGE as I18nLocals) || 'en'];
15 | 
16 | export const configureCommitlintIntegration = async (force = false) => {
17 |   const spin = spinner();
18 |   spin.start('Loading @commitlint configuration');
19 | 
20 |   const fileExists = await utils.commitlintLLMConfigExists();
21 | 
22 |   const commitLintConfig = await getCommitLintPWDConfig();
23 |   if (commitLintConfig === null) {
24 |     throw new Error(
25 |       `Failed to load @commitlint config. Please check the following:
26 |       * @commitlint >= 9.0.0 is installed in the local directory.
27 |       * 'node_modules/@commitlint/load' package exists.
28 |       * A valid @commitlint configuration exists.
29 |       `
30 |     );
31 |   }
32 | 
33 |   // debug complete @commitlint configuration
34 |   // await fs.writeFile(
35 |   //   `${OPENCOMMIT_COMMITLINT_CONFIG}-commitlint-debug`,
36 |   //   JSON.stringify(commitLintConfig, null, 2)
37 |   // );
38 | 
39 |   const hash = await computeHash(JSON.stringify(commitLintConfig));
40 | 
41 |   spin.stop(`Read @commitlint configuration (hash: ${hash})`);
42 | 
43 |   if (fileExists) {
44 |     // Check if we need to update the prompts.
45 |     const { hash: existingHash } = await utils.getCommitlintLLMConfig();
46 |     if (hash === existingHash && !force) {
47 |       spin.stop(
48 |         'Hashes are the same, no need to update the config. Run "force" command to bypass.'
49 |       );
50 |       return;
51 |     }
52 |   }
53 | 
54 |   spin.start('Generating consistency with given @commitlint rules');
55 | 
56 |   const prompts = inferPromptsFromCommitlintConfig(commitLintConfig as any);
57 | 
58 |   const consistencyPrompts =
59 |     commitlintPrompts.GEN_COMMITLINT_CONSISTENCY_PROMPT(prompts);
60 | 
61 |   // debug prompt which will generate a consistency
62 |   // await fs.writeFile(
63 |   //   `${COMMITLINT_LLM_CONFIG}-debug`,
64 |   //   consistencyPrompts.map((p) => p.content)
65 |   // );
66 | 
67 |   const engine = getEngine();
68 |   let consistency =
69 |     (await engine.generateCommitMessage(consistencyPrompts)) || '{}';
70 | 
71 |   // Cleanup the consistency answer. Sometimes 'gpt-3.5-turbo' sends rule's back.
72 |   prompts.forEach((prompt) => (consistency = consistency.replace(prompt, '')));
73 | 
74 |   // sometimes consistency is preceded by explanatory text like "Here is your JSON:"
75 |   consistency = utils.getJSONBlock(consistency);
76 | 
77 |   // ... remaining might be extra set of "\n"
78 |   consistency = utils.removeDoubleNewlines(consistency);
79 | 
80 |   const commitlintLLMConfig: CommitlintLLMConfig = {
81 |     hash,
82 |     prompts,
83 |     consistency: {
84 |       [translation.localLanguage]: {
85 |         ...JSON.parse(consistency as string)
86 |       }
87 |     }
88 |   };
89 | 
90 |   await utils.writeCommitlintLLMConfig(commitlintLLMConfig);
91 | 
92 |   spin.stop(`Done - please review contents of ${COMMITLINT_LLM_CONFIG_PATH}`);
93 | };
94 | 


--------------------------------------------------------------------------------
/src/modules/commitlint/constants.ts:
--------------------------------------------------------------------------------
1 | export const COMMITLINT_LLM_CONFIG_PATH = `${process.env.PWD}/.opencommit-commitlint`;
2 | 


--------------------------------------------------------------------------------
/src/modules/commitlint/crypto.ts:
--------------------------------------------------------------------------------
 1 | import crypto from 'crypto';
 2 | 
 3 | export const computeHash = async (
 4 |   content: string,
 5 |   algorithm: string = 'sha256'
 6 | ): Promise<string> => {
 7 |   try {
 8 |     const hash = crypto.createHash(algorithm);
 9 |     hash.update(content);
10 |     return hash.digest('hex');
11 |   } catch (error) {
12 |     console.error('Error while computing hash:', error);
13 |     throw error;
14 |   }
15 | };
16 | 


--------------------------------------------------------------------------------
/src/modules/commitlint/prompts.ts:
--------------------------------------------------------------------------------
  1 | import chalk from 'chalk';
  2 | import { OpenAI } from 'openai';
  3 | 
  4 | import { outro } from '@clack/prompts';
  5 | import {
  6 |   PromptConfig,
  7 |   QualifiedConfig,
  8 |   RuleConfigSeverity,
  9 |   RuleConfigTuple
 10 | } from '@commitlint/types';
 11 | 
 12 | import { getConfig } from '../../commands/config';
 13 | import { i18n, I18nLocals } from '../../i18n';
 14 | import { IDENTITY, INIT_DIFF_PROMPT } from '../../prompts';
 15 | 
 16 | const config = getConfig();
 17 | const translation = i18n[(config.OCO_LANGUAGE as I18nLocals) || 'en'];
 18 | 
 19 | type DeepPartial<T> = {
 20 |   [P in keyof T]?: {
 21 |     [K in keyof T[P]]?: T[P][K];
 22 |   };
 23 | };
 24 | 
 25 | type PromptFunction = (
 26 |   applicable: string,
 27 |   value: any,
 28 |   prompt: DeepPartial<PromptConfig>
 29 | ) => string;
 30 | 
 31 | type PromptResolverFunction = (
 32 |   key: string,
 33 |   applicable: string,
 34 |   value: any,
 35 |   prompt?: DeepPartial<PromptConfig>
 36 | ) => string;
 37 | 
 38 | /**
 39 |  * Extracts more contexte for each type-enum.
 40 |  * IDEA: replicate the concept for scopes and refactor to a generic feature.
 41 |  */
 42 | const getTypeRuleExtraDescription = (
 43 |   type: string,
 44 |   prompt?: DeepPartial<PromptConfig>
 45 | ) => prompt?.questions?.type?.enum?.[type]?.description;
 46 | 
 47 | /*
 48 | IDEA: Compress llm readable prompt for each section of commit message: one line for header, one line for scope, etc.
 49 |   - 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.
 50 |   - The scope should not be empty and provide context for the change (e.g., module or file changed).
 51 |   - 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.
 52 | */
 53 | const llmReadableRules: {
 54 |   [ruleName: string]: PromptResolverFunction;
 55 | } = {
 56 |   blankline: (key, applicable) =>
 57 |     `There should ${applicable} be a blank line at the beginning of the ${key}.`,
 58 |   caseRule: (key, applicable, value: string | Array<string>) =>
 59 |     `The ${key} should ${applicable} be in ${
 60 |       Array.isArray(value)
 61 |         ? `one of the following case:
 62 |   - ${value.join('\n  - ')}.`
 63 |         : `${value} case.`
 64 |     }`,
 65 |   emptyRule: (key, applicable) => `The ${key} should ${applicable} be empty.`,
 66 |   enumRule: (key, applicable, value: string | Array<string>) =>
 67 |     `The ${key} should ${applicable} be one of the following values:
 68 |   - ${Array.isArray(value) ? value.join('\n  - ') : value}.`,
 69 |   enumTypeRule: (key, applicable, value: string | Array<string>, prompt) =>
 70 |     `The ${key} should ${applicable} be one of the following values:
 71 |   - ${
 72 |     Array.isArray(value)
 73 |       ? value
 74 |           .map((v) => {
 75 |             const description = getTypeRuleExtraDescription(v, prompt);
 76 |             if (description) {
 77 |               return `${v} (${description})`;
 78 |             } else return v;
 79 |           })
 80 |           .join('\n  - ')
 81 |       : value
 82 |   }.`,
 83 |   fullStopRule: (key, applicable, value: string) =>
 84 |     `The ${key} should ${applicable} end with '${value}'.`,
 85 |   maxLengthRule: (key, applicable, value: string) =>
 86 |     `The ${key} should ${applicable} have ${value} characters or less.`,
 87 |   minLengthRule: (key, applicable, value: string) =>
 88 |     `The ${key} should ${applicable} have ${value} characters or more.`
 89 | };
 90 | 
 91 | /**
 92 |  * TODO: Validate rules to every rule in the @commitlint configuration.
 93 |  * IDEA: Plugins can extend the list of rule. Provide user with a way to infer or extend when "No prompt handler for rule".
 94 |  */
 95 | const rulesPrompts: {
 96 |   [ruleName: string]: PromptFunction;
 97 | } = {
 98 |   'body-case': (applicable: string, value: string | Array<string>) =>
 99 |     llmReadableRules.caseRule('body', applicable, value),
100 |   'body-empty': (applicable: string) =>
101 |     llmReadableRules.emptyRule('body', applicable, undefined),
102 |   'body-full-stop': (applicable: string, value: string) =>
103 |     llmReadableRules.fullStopRule('body', applicable, value),
104 |   'body-leading-blank': (applicable: string) =>
105 |     llmReadableRules.blankline('body', applicable, undefined),
106 |   'body-max-length': (applicable: string, value: string) =>
107 |     llmReadableRules.maxLengthRule('body', applicable, value),
108 |   'body-max-line-length': (applicable: string, value: string) =>
109 |     `Each line of the body should ${applicable} have ${value} characters or less.`,
110 |   'body-min-length': (applicable: string, value: string) =>
111 |     llmReadableRules.minLengthRule('body', applicable, value),
112 |   'footer-case': (applicable: string, value: string | Array<string>) =>
113 |     llmReadableRules.caseRule('footer', applicable, value),
114 |   'footer-empty': (applicable: string) =>
115 |     llmReadableRules.emptyRule('footer', applicable, undefined),
116 |   'footer-leading-blank': (applicable: string) =>
117 |     llmReadableRules.blankline('footer', applicable, undefined),
118 |   'footer-max-length': (applicable: string, value: string) =>
119 |     llmReadableRules.maxLengthRule('footer', applicable, value),
120 |   'footer-max-line-length': (applicable: string, value: string) =>
121 |     `Each line of the footer should ${applicable} have ${value} characters or less.`,
122 |   'footer-min-length': (applicable: string, value: string) =>
123 |     llmReadableRules.minLengthRule('footer', applicable, value),
124 |   'header-case': (applicable: string, value: string | Array<string>) =>
125 |     llmReadableRules.caseRule('header', applicable, value),
126 |   'header-full-stop': (applicable: string, value: string) =>
127 |     llmReadableRules.fullStopRule('header', applicable, value),
128 |   'header-max-length': (applicable: string, value: string) =>
129 |     llmReadableRules.maxLengthRule('header', applicable, value),
130 |   'header-min-length': (applicable: string, value: string) =>
131 |     llmReadableRules.minLengthRule('header', applicable, value),
132 |   'references-empty': (applicable: string) =>
133 |     llmReadableRules.emptyRule('references section', applicable, undefined),
134 |   'scope-case': (applicable: string, value: string | Array<string>) =>
135 |     llmReadableRules.caseRule('scope', applicable, value),
136 |   'scope-empty': (applicable: string) =>
137 |     llmReadableRules.emptyRule('scope', applicable, undefined),
138 |   'scope-enum': (applicable: string, value: string | Array<string>) =>
139 |     llmReadableRules.enumRule('type', applicable, value),
140 |   'scope-max-length': (applicable: string, value: string) =>
141 |     llmReadableRules.maxLengthRule('scope', applicable, value),
142 |   'scope-min-length': (applicable: string, value: string) =>
143 |     llmReadableRules.minLengthRule('scope', applicable, value),
144 |   'signed-off-by': (applicable: string, value: string) =>
145 |     `The commit message should ${applicable} have a "Signed-off-by" line with the value "${value}".`,
146 |   'subject-case': (applicable: string, value: string | Array<string>) =>
147 |     llmReadableRules.caseRule('subject', applicable, value),
148 |   'subject-empty': (applicable: string) =>
149 |     llmReadableRules.emptyRule('subject', applicable, undefined),
150 |   'subject-full-stop': (applicable: string, value: string) =>
151 |     llmReadableRules.fullStopRule('subject', applicable, value),
152 |   'subject-max-length': (applicable: string, value: string) =>
153 |     llmReadableRules.maxLengthRule('subject', applicable, value),
154 |   'subject-min-length': (applicable: string, value: string) =>
155 |     llmReadableRules.minLengthRule('subject', applicable, value),
156 |   'type-case': (applicable: string, value: string | Array<string>) =>
157 |     llmReadableRules.caseRule('type', applicable, value),
158 |   'type-empty': (applicable: string) =>
159 |     llmReadableRules.emptyRule('type', applicable, undefined),
160 |   'type-enum': (applicable: string, value: string | Array<string>, prompt) =>
161 |     llmReadableRules.enumTypeRule('type', applicable, value, prompt),
162 |   'type-max-length': (applicable: string, value: string) =>
163 |     llmReadableRules.maxLengthRule('type', applicable, value),
164 |   'type-min-length': (applicable: string, value: string) =>
165 |     llmReadableRules.minLengthRule('type', applicable, value)
166 | };
167 | 
168 | const getPrompt = (
169 |   ruleName: string,
170 |   ruleConfig: RuleConfigTuple<unknown>,
171 |   prompt: DeepPartial<PromptConfig>
172 | ) => {
173 |   const [severity, applicable, value] = ruleConfig;
174 | 
175 |   // Should we exclude "Disabled" properties?
176 |   // Is this used to disable a subjacent rule when extending presets?
177 |   if (severity === RuleConfigSeverity.Disabled) return null;
178 | 
179 |   const promptFn = rulesPrompts[ruleName];
180 |   if (promptFn) {
181 |     return promptFn(applicable, value, prompt);
182 |   }
183 | 
184 |   // Plugins may add their custom rules.
185 |   // We might want to call OpenAI to build this rule's llm-readable prompt.
186 |   outro(`${chalk.red('✖')} No prompt handler for rule "${ruleName}".`);
187 |   return `Please manualy set the prompt for rule "${ruleName}".`;
188 | };
189 | 
190 | export const inferPromptsFromCommitlintConfig = (
191 |   config: QualifiedConfig
192 | ): string[] => {
193 |   const { rules, prompt } = config;
194 |   if (!rules) return [];
195 |   return Object.keys(rules)
196 |     .map((ruleName) =>
197 |       getPrompt(ruleName, rules[ruleName] as RuleConfigTuple<unknown>, prompt)
198 |     )
199 |     .filter((prompt) => prompt !== null) as string[];
200 | };
201 | 
202 | /**
203 |  * Breaking down commit message structure for conventional commit, and mapping bits with
204 |  * ubiquitous language from @commitlint.
205 |  * While gpt-4 does this on it self, gpt-3.5 can't map this on his own atm.
206 |  */
207 | const STRUCTURE_OF_COMMIT = config.OCO_OMIT_SCOPE
208 |   ? `
209 | - Header of commit is composed of type and subject: <type-of-commit>: <subject-of-commit>
210 | - Description of commit is composed of body and footer (optional): <body-of-commit>\n<footer(s)-of-commit>`
211 |   : `
212 | - Header of commit is composed of type, scope, subject: <type-of-commit>(<scope-of-commit>): <subject-of-commit>
213 | - Description of commit is composed of body and footer (optional): <body-of-commit>\n<footer(s)-of-commit>`;
214 | 
215 | // Prompt to generate LLM-readable rules based on @commitlint rules.
216 | const GEN_COMMITLINT_CONSISTENCY_PROMPT = (
217 |   prompts: string[]
218 | ): OpenAI.Chat.Completions.ChatCompletionMessageParam[] => [
219 |   {
220 |     role: 'system',
221 |     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.
222 | 
223 | Here are the specific requirements and conventions that should be strictly followed:
224 | 
225 | Commit Message Conventions:
226 | - The commit message consists of three parts: Header, Body, and Footer.
227 | - Header:
228 |   - Format: ${
229 |     config.OCO_OMIT_SCOPE
230 |       ? '`<type>: <subject>`'
231 |       : '`<type>(<scope>): <subject>`'
232 |   }
233 | - ${prompts.join('\n- ')}
234 | 
235 | JSON Output Format:
236 | - The JSON output should contain the commit messages for a bug fix and a new feature in the following format:
237 | \`\`\`json
238 | {
239 |   "localLanguage": "${translation.localLanguage}",
240 |   "commitFix": "<Header of commit for bug fix with scope>",
241 |   "commitFeat": "<Header of commit for feature with scope>",
242 |   "commitFixOmitScope": "<Header of commit for bug fix without scope>",
243 |   "commitFeatOmitScope": "<Header of commit for feature without scope>",
244 |   "commitDescription": "<Description of commit for both the bug fix and the feature>"
245 | }
246 | \`\`\`
247 | - The "commitDescription" should not include the commit message's header, only the description.
248 | - Description should not be more than 74 characters.
249 | 
250 | Additional Details:
251 | - Changing the variable 'port' to uppercase 'PORT' is considered a bug fix.
252 | - Allowing the server to listen on a port specified through the environment variable is considered a new feature.
253 | 
254 | Example Git Diff is to follow:`
255 |   },
256 |   INIT_DIFF_PROMPT
257 | ];
258 | 
259 | /**
260 |  * Prompt to have LLM generate a message using @commitlint rules.
261 |  *
262 |  * @param language
263 |  * @param prompts
264 |  * @returns
265 |  */
266 | const INIT_MAIN_PROMPT = (
267 |   language: string,
268 |   prompts: string[]
269 | ): OpenAI.Chat.Completions.ChatCompletionMessageParam => ({
270 |   role: 'system',
271 |   content: `${IDENTITY} Your mission is to create clean and comprehensive commit messages in the given @commitlint convention and explain WHAT were the changes ${
272 |     config.OCO_WHY ? 'and WHY the changes were done' : ''
273 |   }. I'll send you an output of 'git diff --staged' command, and you convert it into a commit message.
274 | ${
275 |   config.OCO_EMOJI
276 |     ? 'Use GitMoji convention to preface the commit.'
277 |     : 'Do not preface the commit with anything.'
278 | }
279 | ${
280 |   config.OCO_DESCRIPTION
281 |     ? '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.'
282 |     : "Don't add any descriptions to the commit, only commit message."
283 | }
284 | Use the present tense. Use ${language} to answer.
285 | ${
286 |   config.OCO_ONE_LINE_COMMIT
287 |     ? '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.'
288 |     : ''
289 | }
290 | ${
291 |   config.OCO_OMIT_SCOPE
292 |     ? 'Do not include a scope in the commit message format. Use the format: <type>: <subject>'
293 |     : ''
294 | }
295 | You will strictly follow the following conventions to generate the content of the commit message:
296 | - ${prompts.join('\n- ')}
297 | 
298 | The conventions refers to the following structure of commit message:
299 | ${STRUCTURE_OF_COMMIT}`
300 | });
301 | 
302 | export const commitlintPrompts = {
303 |   INIT_MAIN_PROMPT,
304 |   GEN_COMMITLINT_CONSISTENCY_PROMPT
305 | };
306 | 


--------------------------------------------------------------------------------
/src/modules/commitlint/pwd-commitlint.ts:
--------------------------------------------------------------------------------
 1 | import fs from 'fs/promises';
 2 | import path from 'path';
 3 | 
 4 | const findModulePath = (moduleName: string) => {
 5 |   const searchPaths = [
 6 |     path.join('node_modules', moduleName),
 7 |     path.join('node_modules', '.pnpm'),
 8 |     path.resolve(__dirname, '../..')
 9 |   ];
10 | 
11 |   for (const basePath of searchPaths) {
12 |     try {
13 |       const resolvedPath = require.resolve(moduleName, { paths: [basePath] });
14 |       return resolvedPath;
15 |     } catch {
16 |       // Continue to the next search path if the module is not found
17 |     }
18 |   }
19 | 
20 |   throw new Error(`Cannot find module ${moduleName}`);
21 | };
22 | 
23 | const getCommitLintModuleType = async (): Promise<'cjs' | 'esm'> => {
24 |   const packageFile = '@commitlint/load/package.json';
25 |   const packageJsonPath = findModulePath(packageFile);
26 |   const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
27 | 
28 |   if (!packageJson) {
29 |     throw new Error(`Failed to parse ${packageFile}`);
30 |   }
31 | 
32 |   return packageJson.type === 'module' ? 'esm' : 'cjs';
33 | };
34 | 
35 | /**
36 |  * QualifiedConfig from any version of @commitlint/types
37 |  * @see https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/types/src/load.ts
38 |  */
39 | type QualifiedConfigOnAnyVersion = { [key: string]: unknown };
40 | 
41 | /**
42 |  *  This code is loading the configuration for the `@commitlint` package from the current working
43 |  * directory (`process.env.PWD`) by requiring the `load` module from the `@commitlint` package.
44 |  *
45 |  * @returns
46 |  */
47 | export const getCommitLintPWDConfig =
48 |   async (): Promise<QualifiedConfigOnAnyVersion | null> => {
49 |     let load: Function, modulePath: string;
50 |     switch (await getCommitLintModuleType()) {
51 |       case 'cjs':
52 |         /**
53 |          * CommonJS (<= commitlint@v18.x.x.)
54 |          */
55 |         modulePath = findModulePath('@commitlint/load');
56 |         load = require(modulePath).default;
57 |         break;
58 |       case 'esm':
59 |         /**
60 |          * ES Module (commitlint@v19.x.x. <= )
61 |          * Directory import is not supported in ES Module resolution, so import the file directly
62 |          */
63 |         modulePath = findModulePath('@commitlint/load/lib/load.js');
64 |         load = (await import(modulePath)).default;
65 |         break;
66 |     }
67 | 
68 |     if (load && typeof load === 'function') {
69 |       return await load();
70 |     }
71 | 
72 |     // @commitlint/load is not a function
73 |     return null;
74 |   };
75 | 


--------------------------------------------------------------------------------
/src/modules/commitlint/types.ts:
--------------------------------------------------------------------------------
 1 | import { i18n } from '../../i18n';
 2 | 
 3 | export type ConsistencyPrompt = (typeof i18n)[keyof typeof i18n];
 4 | 
 5 | export type CommitlintLLMConfig = {
 6 |   hash: string;
 7 |   prompts: string[];
 8 |   consistency: {
 9 |     [key: string]: ConsistencyPrompt;
10 |   };
11 | };
12 | 


--------------------------------------------------------------------------------
/src/modules/commitlint/utils.ts:
--------------------------------------------------------------------------------
 1 | import fs from 'fs/promises';
 2 | 
 3 | import { COMMITLINT_LLM_CONFIG_PATH } from './constants';
 4 | import { CommitlintLLMConfig } from './types';
 5 | 
 6 | /**
 7 |  * Removes the "\n" only if occurring twice
 8 |  */
 9 | export const removeDoubleNewlines = (input: string): string => {
10 |   const pattern = /\\n\\n/g;
11 |   if (pattern.test(input)) {
12 |     const newInput = input.replace(pattern, '');
13 |     return removeDoubleNewlines(newInput);
14 |   }
15 | 
16 |   return input;
17 | };
18 | 
19 | export const getJSONBlock = (input: string): string => {
20 |   const jsonIndex = input.search('```json');
21 |   if (jsonIndex > -1) {
22 |     input = input.slice(jsonIndex + 8);
23 |     const endJsonIndex = input.search('```');
24 |     input = input.slice(0, endJsonIndex);
25 |   }
26 |   return input;
27 | };
28 | 
29 | export const commitlintLLMConfigExists = async (): Promise<boolean> => {
30 |   let exists;
31 |   try {
32 |     await fs.access(COMMITLINT_LLM_CONFIG_PATH);
33 |     exists = true;
34 |   } catch (e) {
35 |     exists = false;
36 |   }
37 | 
38 |   return exists;
39 | };
40 | 
41 | export const writeCommitlintLLMConfig = async (
42 |   commitlintLLMConfig: CommitlintLLMConfig
43 | ): Promise<void> => {
44 |   await fs.writeFile(
45 |     COMMITLINT_LLM_CONFIG_PATH,
46 |     JSON.stringify(commitlintLLMConfig, null, 2)
47 |   );
48 | };
49 | 
50 | export const getCommitlintLLMConfig =
51 |   async (): Promise<CommitlintLLMConfig> => {
52 |     const content = await fs.readFile(COMMITLINT_LLM_CONFIG_PATH);
53 |     const commitLintLLMConfig = JSON.parse(
54 |       content.toString()
55 |     ) as CommitlintLLMConfig;
56 |     return commitLintLLMConfig;
57 |   };
58 | 


--------------------------------------------------------------------------------
/src/prompts.ts:
--------------------------------------------------------------------------------
  1 | import { note } from '@clack/prompts';
  2 | import { OpenAI } from 'openai';
  3 | import { getConfig } from './commands/config';
  4 | import { i18n, I18nLocals } from './i18n';
  5 | import { configureCommitlintIntegration } from './modules/commitlint/config';
  6 | import { commitlintPrompts } from './modules/commitlint/prompts';
  7 | import { ConsistencyPrompt } from './modules/commitlint/types';
  8 | import * as utils from './modules/commitlint/utils';
  9 | import { removeConventionalCommitWord } from './utils/removeConventionalCommitWord';
 10 | 
 11 | const config = getConfig();
 12 | const translation = i18n[(config.OCO_LANGUAGE as I18nLocals) || 'en'];
 13 | 
 14 | export const IDENTITY =
 15 |   'You are to act as an author of a commit message in git.';
 16 | 
 17 | const GITMOJI_HELP = `Use GitMoji convention to preface the commit. Here are some help to choose the right emoji (emoji, description): 
 18 | 🐛, Fix a bug; 
 19 | ✨, Introduce new features; 
 20 | 📝, Add or update documentation; 
 21 | 🚀, Deploy stuff; 
 22 | ✅, Add, update, or pass tests; 
 23 | ♻️, Refactor code; 
 24 | ⬆️, Upgrade dependencies; 
 25 | 🔧, Add or update configuration files; 
 26 | 🌐, Internationalization and localization; 
 27 | 💡, Add or update comments in source code;`;
 28 | 
 29 | const FULL_GITMOJI_SPEC = `${GITMOJI_HELP}
 30 | 🎨, Improve structure / format of the code; 
 31 | ⚡️, Improve performance; 
 32 | 🔥, Remove code or files; 
 33 | 🚑️, Critical hotfix; 
 34 | 💄, Add or update the UI and style files; 
 35 | 🎉, Begin a project; 
 36 | 🔒️, Fix security issues; 
 37 | 🔐, Add or update secrets; 
 38 | 🔖, Release / Version tags; 
 39 | 🚨, Fix compiler / linter warnings; 
 40 | 🚧, Work in progress; 
 41 | 💚, Fix CI Build; 
 42 | ⬇️, Downgrade dependencies; 
 43 | 📌, Pin dependencies to specific versions; 
 44 | 👷, Add or update CI build system; 
 45 | 📈, Add or update analytics or track code; 
 46 | ➕, Add a dependency; 
 47 | ➖, Remove a dependency; 
 48 | 🔨, Add or update development scripts; 
 49 | ✏️, Fix typos; 
 50 | 💩, Write bad code that needs to be improved; 
 51 | ⏪️, Revert changes; 
 52 | 🔀, Merge branches; 
 53 | 📦️, Add or update compiled files or packages; 
 54 | 👽️, Update code due to external API changes; 
 55 | 🚚, Move or rename resources (e.g.: files, paths, routes); 
 56 | 📄, Add or update license; 
 57 | 💥, Introduce breaking changes; 
 58 | 🍱, Add or update assets; 
 59 | ♿️, Improve accessibility; 
 60 | 🍻, Write code drunkenly; 
 61 | 💬, Add or update text and literals; 
 62 | 🗃️, Perform database related changes; 
 63 | 🔊, Add or update logs; 
 64 | 🔇, Remove logs; 
 65 | 👥, Add or update contributor(s); 
 66 | 🚸, Improve user experience / usability; 
 67 | 🏗️, Make architectural changes; 
 68 | 📱, Work on responsive design; 
 69 | 🤡, Mock things; 
 70 | 🥚, Add or update an easter egg; 
 71 | 🙈, Add or update a .gitignore file; 
 72 | 📸, Add or update snapshots; 
 73 | ⚗️, Perform experiments; 
 74 | 🔍️, Improve SEO; 
 75 | 🏷️, Add or update types; 
 76 | 🌱, Add or update seed files; 
 77 | 🚩, Add, update, or remove feature flags; 
 78 | 🥅, Catch errors; 
 79 | 💫, Add or update animations and transitions; 
 80 | 🗑️, Deprecate code that needs to be cleaned up; 
 81 | 🛂, Work on code related to authorization, roles and permissions; 
 82 | 🩹, Simple fix for a non-critical issue; 
 83 | 🧐, Data exploration/inspection; 
 84 | ⚰️, Remove dead code; 
 85 | 🧪, Add a failing test; 
 86 | 👔, Add or update business logic; 
 87 | 🩺, Add or update healthcheck; 
 88 | 🧱, Infrastructure related changes; 
 89 | 🧑‍💻, Improve developer experience; 
 90 | 💸, Add sponsorships or money related infrastructure; 
 91 | 🧵, Add or update code related to multithreading or concurrency; 
 92 | 🦺, Add or update code related to validation.`;
 93 | 
 94 | const CONVENTIONAL_COMMIT_KEYWORDS =
 95 |   'Do not preface the commit with anything, except for the conventional commit keywords: fix, feat, build, chore, ci, docs, style, refactor, perf, test.';
 96 | 
 97 | const getCommitConvention = (fullGitMojiSpec: boolean) =>
 98 |   config.OCO_EMOJI
 99 |     ? fullGitMojiSpec
100 |       ? FULL_GITMOJI_SPEC
101 |       : GITMOJI_HELP
102 |     : CONVENTIONAL_COMMIT_KEYWORDS;
103 | 
104 | const getDescriptionInstruction = () =>
105 |   config.OCO_DESCRIPTION
106 |     ? '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.'
107 |     : "Don't add any descriptions to the commit, only commit message.";
108 | 
109 | const getOneLineCommitInstruction = () =>
110 |   config.OCO_ONE_LINE_COMMIT
111 |     ? 'Craft a concise, single sentence, 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 one single message.'
112 |     : '';
113 | 
114 | const getScopeInstruction = () =>
115 |   config.OCO_OMIT_SCOPE
116 |     ? 'Do not include a scope in the commit message format. Use the format: <type>: <subject>'
117 |     : '';
118 | 
119 | /**
120 |  * Get the context of the user input
121 |  * @param extraArgs - The arguments passed to the command line
122 |  * @example
123 |  *  $ oco -- This is a context used to generate the commit message
124 |  * @returns - The context of the user input
125 |  */
126 | const userInputCodeContext = (context: string) => {
127 |   if (context !== '' && context !== ' ') {
128 |     return `Additional context provided by the user: <context>${context}</context>\nConsider this context when generating the commit message, incorporating relevant information when appropriate.`;
129 |   }
130 |   return '';
131 | };
132 | 
133 | const INIT_MAIN_PROMPT = (
134 |   language: string,
135 |   fullGitMojiSpec: boolean,
136 |   context: string
137 | ): OpenAI.Chat.Completions.ChatCompletionMessageParam => ({
138 |   role: 'system',
139 |   content: (() => {
140 |     const commitConvention = fullGitMojiSpec
141 |       ? 'GitMoji specification'
142 |       : 'Conventional Commit Convention';
143 |     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.`;
144 |     const diffInstruction =
145 |       "I'll send you an output of 'git diff --staged' command, and you are to convert it into a commit message.";
146 |     const conventionGuidelines = getCommitConvention(fullGitMojiSpec);
147 |     const descriptionGuideline = getDescriptionInstruction();
148 |     const oneLineCommitGuideline = getOneLineCommitInstruction();
149 |     const scopeInstruction = getScopeInstruction();
150 |     const generalGuidelines = `Use the present tense. Lines must not be longer than 74 characters. Use ${language} for the commit message.`;
151 |     const userInputContext = userInputCodeContext(context);
152 | 
153 |     return `${missionStatement}\n${diffInstruction}\n${conventionGuidelines}\n${descriptionGuideline}\n${oneLineCommitGuideline}\n${scopeInstruction}\n${generalGuidelines}\n${userInputContext}`;
154 |   })()
155 | });
156 | 
157 | export const INIT_DIFF_PROMPT: OpenAI.Chat.Completions.ChatCompletionMessageParam =
158 |   {
159 |     role: 'user',
160 |     content: `diff --git a/src/server.ts b/src/server.ts
161 |     index ad4db42..f3b18a9 100644
162 |     --- a/src/server.ts
163 |     +++ b/src/server.ts
164 |     @@ -10,7 +10,7 @@
165 |     import {
166 |         initWinstonLogger();
167 |         
168 |         const app = express();
169 |         -const port = 7799;
170 |         +const PORT = 7799;
171 |         
172 |         app.use(express.json());
173 |         
174 |         @@ -34,6 +34,6 @@
175 |         app.use((_, res, next) => {
176 |             // ROUTES
177 |             app.use(PROTECTED_ROUTER_URL, protectedRouter);
178 |             
179 |             -app.listen(port, () => {
180 |                 -  console.log(\`Server listening on port \${port}\`);
181 |                 +app.listen(process.env.PORT || PORT, () => {
182 |                     +  console.log(\`Server listening on port \${PORT}\`);
183 |                 });`
184 |   };
185 | 
186 | const COMMIT_TYPES = {
187 |   fix: '🐛',
188 |   feat: '✨'
189 | } as const;
190 | 
191 | const generateCommitString = (
192 |   type: keyof typeof COMMIT_TYPES,
193 |   message: string
194 | ): string => {
195 |   const cleanMessage = removeConventionalCommitWord(message);
196 |   return config.OCO_EMOJI ? `${COMMIT_TYPES[type]} ${cleanMessage}` : message;
197 | };
198 | 
199 | const getConsistencyContent = (translation: ConsistencyPrompt) => {
200 |   const fixMessage =
201 |     config.OCO_OMIT_SCOPE && translation.commitFixOmitScope
202 |       ? translation.commitFixOmitScope
203 |       : translation.commitFix;
204 | 
205 |   const featMessage =
206 |     config.OCO_OMIT_SCOPE && translation.commitFeatOmitScope
207 |       ? translation.commitFeatOmitScope
208 |       : translation.commitFeat;
209 | 
210 |   const fix = generateCommitString('fix', fixMessage);
211 |   const feat = config.OCO_ONE_LINE_COMMIT
212 |     ? ''
213 |     : generateCommitString('feat', featMessage);
214 | 
215 |   const description = config.OCO_DESCRIPTION
216 |     ? translation.commitDescription
217 |     : '';
218 | 
219 |   return [fix, feat, description].filter(Boolean).join('\n');
220 | };
221 | 
222 | const INIT_CONSISTENCY_PROMPT = (
223 |   translation: ConsistencyPrompt
224 | ): OpenAI.Chat.Completions.ChatCompletionMessageParam => ({
225 |   role: 'assistant',
226 |   content: getConsistencyContent(translation)
227 | });
228 | 
229 | export const getMainCommitPrompt = async (
230 |   fullGitMojiSpec: boolean,
231 |   context: string
232 | ): Promise<Array<OpenAI.Chat.Completions.ChatCompletionMessageParam>> => {
233 |   switch (config.OCO_PROMPT_MODULE) {
234 |     case '@commitlint':
235 |       if (!(await utils.commitlintLLMConfigExists())) {
236 |         note(
237 |           `OCO_PROMPT_MODULE is @commitlint but you haven't generated consistency for this project yet.`
238 |         );
239 |         await configureCommitlintIntegration();
240 |       }
241 | 
242 |       // Replace example prompt with a prompt that's generated by OpenAI for the commitlint config.
243 |       const commitLintConfig = await utils.getCommitlintLLMConfig();
244 | 
245 |       return [
246 |         commitlintPrompts.INIT_MAIN_PROMPT(
247 |           translation.localLanguage,
248 |           commitLintConfig.prompts
249 |         ),
250 |         INIT_DIFF_PROMPT,
251 |         INIT_CONSISTENCY_PROMPT(
252 |           commitLintConfig.consistency[
253 |             translation.localLanguage
254 |           ] as ConsistencyPrompt
255 |         )
256 |       ];
257 | 
258 |     default:
259 |       return [
260 |         INIT_MAIN_PROMPT(translation.localLanguage, fullGitMojiSpec, context),
261 |         INIT_DIFF_PROMPT,
262 |         INIT_CONSISTENCY_PROMPT(translation)
263 |       ];
264 |   }
265 | };
266 | 


--------------------------------------------------------------------------------
/src/utils/checkIsLatestVersion.ts:
--------------------------------------------------------------------------------
 1 | import chalk from 'chalk';
 2 | 
 3 | import { outro } from '@clack/prompts';
 4 | 
 5 | import currentPackage from '../../package.json';
 6 | import { getOpenCommitLatestVersion } from '../version';
 7 | 
 8 | export const checkIsLatestVersion = async () => {
 9 |   const latestVersion = await getOpenCommitLatestVersion();
10 | 
11 |   if (latestVersion) {
12 |     const currentVersion = currentPackage.version;
13 | 
14 |     if (currentVersion !== latestVersion) {
15 |       outro(
16 |         chalk.yellow(
17 |           `
18 | You are not using the latest stable version of OpenCommit with new features and bug fixes.
19 | Current version: ${currentVersion}. Latest version: ${latestVersion}.
20 | 🚀 To update run: npm i -g opencommit@latest.
21 |         `
22 |         )
23 |       );
24 |     }
25 |   }
26 | };
27 | 


--------------------------------------------------------------------------------
/src/utils/engine.ts:
--------------------------------------------------------------------------------
 1 | import { getConfig, OCO_AI_PROVIDER_ENUM } from '../commands/config';
 2 | import { AnthropicEngine } from '../engine/anthropic';
 3 | import { AzureEngine } from '../engine/azure';
 4 | import { AiEngine } from '../engine/Engine';
 5 | import { FlowiseEngine } from '../engine/flowise';
 6 | import { GeminiEngine } from '../engine/gemini';
 7 | import { OllamaEngine } from '../engine/ollama';
 8 | import { OpenAiEngine } from '../engine/openAi';
 9 | import { MistralAiEngine } from '../engine/mistral';
10 | import { TestAi, TestMockType } from '../engine/testAi';
11 | import { GroqEngine } from '../engine/groq';
12 | import { MLXEngine } from '../engine/mlx';
13 | import { DeepseekEngine } from '../engine/deepseek';
14 | import { OpenRouterEngine } from '../engine/openrouter';
15 | 
16 | export function parseCustomHeaders(headers: any): Record<string, string> {
17 |   let parsedHeaders = {};
18 | 
19 |   if (!headers) {
20 |     return parsedHeaders;
21 |   }
22 | 
23 |   try {
24 |     if (typeof headers === 'object' && !Array.isArray(headers)) {
25 |       parsedHeaders = headers;
26 |     } else {
27 |       parsedHeaders = JSON.parse(headers);
28 |     }
29 |   } catch (error) {
30 |     console.warn(
31 |       'Invalid OCO_API_CUSTOM_HEADERS format, ignoring custom headers'
32 |     );
33 |   }
34 | 
35 |   return parsedHeaders;
36 | }
37 | 
38 | export function getEngine(): AiEngine {
39 |   const config = getConfig();
40 |   const provider = config.OCO_AI_PROVIDER;
41 | 
42 |   const customHeaders = parseCustomHeaders(config.OCO_API_CUSTOM_HEADERS);
43 | 
44 |   const DEFAULT_CONFIG = {
45 |     model: config.OCO_MODEL!,
46 |     maxTokensOutput: config.OCO_TOKENS_MAX_OUTPUT!,
47 |     maxTokensInput: config.OCO_TOKENS_MAX_INPUT!,
48 |     baseURL: config.OCO_API_URL!,
49 |     apiKey: config.OCO_API_KEY!,
50 |     customHeaders
51 |   };
52 | 
53 |   switch (provider) {
54 |     case OCO_AI_PROVIDER_ENUM.OLLAMA:
55 |       return new OllamaEngine(DEFAULT_CONFIG);
56 | 
57 |     case OCO_AI_PROVIDER_ENUM.ANTHROPIC:
58 |       return new AnthropicEngine(DEFAULT_CONFIG);
59 | 
60 |     case OCO_AI_PROVIDER_ENUM.TEST:
61 |       return new TestAi(config.OCO_TEST_MOCK_TYPE as TestMockType);
62 | 
63 |     case OCO_AI_PROVIDER_ENUM.GEMINI:
64 |       return new GeminiEngine(DEFAULT_CONFIG);
65 | 
66 |     case OCO_AI_PROVIDER_ENUM.AZURE:
67 |       return new AzureEngine(DEFAULT_CONFIG);
68 | 
69 |     case OCO_AI_PROVIDER_ENUM.FLOWISE:
70 |       return new FlowiseEngine(DEFAULT_CONFIG);
71 | 
72 |     case OCO_AI_PROVIDER_ENUM.GROQ:
73 |       return new GroqEngine(DEFAULT_CONFIG);
74 | 
75 |     case OCO_AI_PROVIDER_ENUM.MISTRAL:
76 |       return new MistralAiEngine(DEFAULT_CONFIG);
77 | 
78 |     case OCO_AI_PROVIDER_ENUM.MLX:
79 |       return new MLXEngine(DEFAULT_CONFIG);
80 | 
81 |     case OCO_AI_PROVIDER_ENUM.DEEPSEEK:
82 |       return new DeepseekEngine(DEFAULT_CONFIG);
83 | 
84 |     case OCO_AI_PROVIDER_ENUM.OPENROUTER:
85 |       return new OpenRouterEngine(DEFAULT_CONFIG);
86 | 
87 |     default:
88 |       return new OpenAiEngine(DEFAULT_CONFIG);
89 |   }
90 | }
91 | 


--------------------------------------------------------------------------------
/src/utils/git.ts:
--------------------------------------------------------------------------------
  1 | import { execa } from 'execa';
  2 | import { readFileSync } from 'fs';
  3 | import ignore, { Ignore } from 'ignore';
  4 | 
  5 | import { outro, spinner } from '@clack/prompts';
  6 | 
  7 | export const assertGitRepo = async () => {
  8 |   try {
  9 |     await execa('git', ['rev-parse']);
 10 |   } catch (error) {
 11 |     throw new Error(error as string);
 12 |   }
 13 | };
 14 | 
 15 | // const excludeBigFilesFromDiff = ['*-lock.*', '*.lock'].map(
 16 | //   (file) => `:(exclude)${file}`
 17 | // );
 18 | 
 19 | export const getOpenCommitIgnore = (): Ignore => {
 20 |   const ig = ignore();
 21 | 
 22 |   try {
 23 |     ig.add(readFileSync('.opencommitignore').toString().split('\n'));
 24 |   } catch (e) {}
 25 | 
 26 |   return ig;
 27 | };
 28 | 
 29 | export const getCoreHooksPath = async (): Promise<string> => {
 30 |   const { stdout } = await execa('git', ['config', 'core.hooksPath']);
 31 | 
 32 |   return stdout;
 33 | };
 34 | 
 35 | export const getStagedFiles = async (): Promise<string[]> => {
 36 |   const { stdout: gitDir } = await execa('git', [
 37 |     'rev-parse',
 38 |     '--show-toplevel'
 39 |   ]);
 40 | 
 41 |   const { stdout: files } = await execa('git', [
 42 |     'diff',
 43 |     '--name-only',
 44 |     '--cached',
 45 |     '--relative',
 46 |     gitDir
 47 |   ]);
 48 | 
 49 |   if (!files) return [];
 50 | 
 51 |   const filesList = files.split('\n');
 52 | 
 53 |   const ig = getOpenCommitIgnore();
 54 |   const allowedFiles = filesList.filter((file) => !ig.ignores(file));
 55 | 
 56 |   if (!allowedFiles) return [];
 57 | 
 58 |   return allowedFiles.sort();
 59 | };
 60 | 
 61 | export const getChangedFiles = async (): Promise<string[]> => {
 62 |   const { stdout: modified } = await execa('git', ['ls-files', '--modified']);
 63 |   const { stdout: others } = await execa('git', [
 64 |     'ls-files',
 65 |     '--others',
 66 |     '--exclude-standard'
 67 |   ]);
 68 | 
 69 |   const files = [...modified.split('\n'), ...others.split('\n')].filter(
 70 |     (file) => !!file
 71 |   );
 72 | 
 73 |   return files.sort();
 74 | };
 75 | 
 76 | export const gitAdd = async ({ files }: { files: string[] }) => {
 77 |   const gitAddSpinner = spinner();
 78 | 
 79 |   gitAddSpinner.start('Adding files to commit');
 80 | 
 81 |   await execa('git', ['add', ...files]);
 82 | 
 83 |   gitAddSpinner.stop(`Staged ${files.length} files`);
 84 | };
 85 | 
 86 | export const getDiff = async ({ files }: { files: string[] }) => {
 87 |   const lockFiles = files.filter(
 88 |     (file) =>
 89 |       file.includes('.lock') ||
 90 |       file.includes('-lock.') ||
 91 |       file.includes('.svg') ||
 92 |       file.includes('.png') ||
 93 |       file.includes('.jpg') ||
 94 |       file.includes('.jpeg') ||
 95 |       file.includes('.webp') ||
 96 |       file.includes('.gif')
 97 |   );
 98 | 
 99 |   if (lockFiles.length) {
100 |     outro(
101 |       `Some files are excluded by default from 'git diff'. No commit messages are generated for this files:\n${lockFiles.join(
102 |         '\n'
103 |       )}`
104 |     );
105 |   }
106 | 
107 |   const filesWithoutLocks = files.filter(
108 |     (file) => !file.includes('.lock') && !file.includes('-lock.')
109 |   );
110 | 
111 |   const { stdout: diff } = await execa('git', [
112 |     'diff',
113 |     '--staged',
114 |     '--',
115 |     ...filesWithoutLocks
116 |   ]);
117 | 
118 |   return diff;
119 | };
120 | 


--------------------------------------------------------------------------------
/src/utils/mergeDiffs.ts:
--------------------------------------------------------------------------------
 1 | import { tokenCount } from './tokenCount';
 2 | 
 3 | export function mergeDiffs(arr: string[], maxStringLength: number): string[] {
 4 |   const mergedArr: string[] = [];
 5 |   let currentItem: string = arr[0];
 6 |   for (const item of arr.slice(1)) {
 7 |     if (tokenCount(currentItem + item) <= maxStringLength) {
 8 |       currentItem += item;
 9 |     } else {
10 |       mergedArr.push(currentItem);
11 |       currentItem = item;
12 |     }
13 |   }
14 | 
15 |   mergedArr.push(currentItem);
16 | 
17 |   return mergedArr;
18 | }
19 | 


--------------------------------------------------------------------------------
/src/utils/randomIntFromInterval.ts:
--------------------------------------------------------------------------------
1 | export function randomIntFromInterval(min: number, max: number) {
2 |   // min and max included
3 |   return Math.floor(Math.random() * (max - min + 1) + min);
4 | }
5 | 


--------------------------------------------------------------------------------
/src/utils/removeContentTags.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * Removes content wrapped in specified tags from a string
 3 |  * @param content The content string to process
 4 |  * @param tag The tag name without angle brackets (e.g., 'think' for '<think></think>')
 5 |  * @returns The content with the specified tags and their contents removed, and trimmed
 6 |  */
 7 | export function removeContentTags<T extends string | null | undefined>(
 8 |   content: T,
 9 |   tag: string
10 | ): T {
11 |   if (!content || typeof content !== 'string') {
12 |     return content;
13 |   }
14 | 
15 |   // Dynamic implementation for other cases
16 |   const openTag = `<${tag}>`;
17 |   const closeTag = `</${tag}>`;
18 | 
19 |   // Parse the content and remove tags
20 |   let result = '';
21 |   let skipUntil: number | null = null;
22 |   let depth = 0;
23 | 
24 |   for (let i = 0; i < content.length; i++) {
25 |     // Check for opening tag
26 |     if (content.substring(i, i + openTag.length) === openTag) {
27 |       depth++;
28 |       if (depth === 1) {
29 |         skipUntil = content.indexOf(closeTag, i + openTag.length);
30 |         i = i + openTag.length - 1; // Skip the opening tag
31 |         continue;
32 |       }
33 |     }
34 |     // Check for closing tag
35 |     else if (
36 |       content.substring(i, i + closeTag.length) === closeTag &&
37 |       depth > 0
38 |     ) {
39 |       depth--;
40 |       if (depth === 0) {
41 |         i = i + closeTag.length - 1; // Skip the closing tag
42 |         skipUntil = null;
43 |         continue;
44 |       }
45 |     }
46 | 
47 |     // Only add character if not inside a tag
48 |     if (skipUntil === null) {
49 |       result += content[i];
50 |     }
51 |   }
52 | 
53 |   // Normalize multiple spaces/tabs into a single space (preserves newlines), then trim.
54 |   result = result.replace(/[ \t]+/g, ' ').trim();
55 | 
56 |   return result as unknown as T;
57 | }
58 | 


--------------------------------------------------------------------------------
/src/utils/removeConventionalCommitWord.ts:
--------------------------------------------------------------------------------
1 | export function removeConventionalCommitWord(message: string): string {
2 |   return message.replace(/^(fix|feat)\((.+?)\):/, '($2):');
3 | }
4 | 


--------------------------------------------------------------------------------
/src/utils/sleep.ts:
--------------------------------------------------------------------------------
1 | export function sleep(ms: number) {
2 |   return new Promise((resolve) => setTimeout(resolve, ms));
3 | }
4 | 


--------------------------------------------------------------------------------
/src/utils/tokenCount.ts:
--------------------------------------------------------------------------------
 1 | import cl100k_base from '@dqbd/tiktoken/encoders/cl100k_base.json';
 2 | import { Tiktoken } from '@dqbd/tiktoken/lite';
 3 | 
 4 | export function tokenCount(content: string): number {
 5 |   const encoding = new Tiktoken(
 6 |     cl100k_base.bpe_ranks,
 7 |     cl100k_base.special_tokens,
 8 |     cl100k_base.pat_str
 9 |   );
10 |   const tokens = encoding.encode(content);
11 |   encoding.free();
12 |   return tokens.length;
13 | }
14 | 


--------------------------------------------------------------------------------
/src/utils/trytm.ts:
--------------------------------------------------------------------------------
 1 | export const trytm = async <T>(
 2 |   promise: Promise<T>
 3 | ): Promise<[T, null] | [null, Error]> => {
 4 |   try {
 5 |     const data = await promise;
 6 |     return [data, null];
 7 |   } catch (throwable) {
 8 |     if (throwable instanceof Error) return [null, throwable];
 9 | 
10 |     throw throwable;
11 |   }
12 | };
13 | 


--------------------------------------------------------------------------------
/src/version.ts:
--------------------------------------------------------------------------------
 1 | import { outro } from '@clack/prompts';
 2 | import { execa } from 'execa';
 3 | 
 4 | export const getOpenCommitLatestVersion = async (): Promise<
 5 |   string | undefined
 6 | > => {
 7 |   try {
 8 |     const { stdout } = await execa('npm', ['view', 'opencommit', 'version']);
 9 |     return stdout;
10 |   } catch (_) {
11 |     outro('Error while getting the latest version of opencommit');
12 |     return undefined;
13 |   }
14 | };
15 | 


--------------------------------------------------------------------------------
/test/Dockerfile:
--------------------------------------------------------------------------------
 1 | FROM ubuntu:latest
 2 | 
 3 | RUN apt-get update && apt-get install -y curl git
 4 | 
 5 | # Install Node.js v20
 6 | RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
 7 | RUN apt-get install -y nodejs
 8 | 
 9 | # Setup git
10 | RUN git config --global user.email "test@example.com"
11 | RUN git config --global user.name "Test User"
12 | 
13 | WORKDIR /app
14 | COPY package.json /app/
15 | COPY package-lock.json /app/
16 | 
17 | RUN ls -la
18 | 
19 | RUN npm ci
20 | COPY . /app
21 | RUN ls -la
22 | RUN npm run build
23 | 


--------------------------------------------------------------------------------
/test/e2e/gitPush.test.ts:
--------------------------------------------------------------------------------
  1 | import path from 'path';
  2 | import 'cli-testing-library/extend-expect';
  3 | import { exec } from 'child_process';
  4 | import { prepareTempDir } from './utils';
  5 | import { promisify } from 'util';
  6 | import { render } from 'cli-testing-library';
  7 | import { resolve } from 'path';
  8 | import { rm } from 'fs';
  9 | const fsExec = promisify(exec);
 10 | const fsRemove = promisify(rm);
 11 | 
 12 | /**
 13 |  * git remote -v
 14 |  *
 15 |  * [no remotes]
 16 |  */
 17 | const prepareNoRemoteGitRepository = async (): Promise<{
 18 |   gitDir: string;
 19 |   cleanup: () => Promise<void>;
 20 | }> => {
 21 |   const tempDir = await prepareTempDir();
 22 |   await fsExec('git init test', { cwd: tempDir });
 23 |   const gitDir = path.resolve(tempDir, 'test');
 24 | 
 25 |   const cleanup = async () => {
 26 |     return fsRemove(tempDir, { recursive: true });
 27 |   };
 28 |   return {
 29 |     gitDir,
 30 |     cleanup
 31 |   };
 32 | };
 33 | 
 34 | /**
 35 |  * git remote -v
 36 |  *
 37 |  * origin  /tmp/remote.git (fetch)
 38 |  * origin  /tmp/remote.git (push)
 39 |  */
 40 | const prepareOneRemoteGitRepository = async (): Promise<{
 41 |   gitDir: string;
 42 |   cleanup: () => Promise<void>;
 43 | }> => {
 44 |   const tempDir = await prepareTempDir();
 45 |   await fsExec('git init --bare remote.git', { cwd: tempDir });
 46 |   await fsExec('git clone remote.git test', { cwd: tempDir });
 47 |   const gitDir = path.resolve(tempDir, 'test');
 48 | 
 49 |   const cleanup = async () => {
 50 |     return fsRemove(tempDir, { recursive: true });
 51 |   };
 52 |   return {
 53 |     gitDir,
 54 |     cleanup
 55 |   };
 56 | };
 57 | 
 58 | /**
 59 |  * git remote -v
 60 |  *
 61 |  * origin  /tmp/remote.git (fetch)
 62 |  * origin  /tmp/remote.git (push)
 63 |  * other   ../remote2.git (fetch)
 64 |  * other   ../remote2.git (push)
 65 |  */
 66 | const prepareTwoRemotesGitRepository = async (): Promise<{
 67 |   gitDir: string;
 68 |   cleanup: () => Promise<void>;
 69 | }> => {
 70 |   const tempDir = await prepareTempDir();
 71 |   await fsExec('git init --bare remote.git', { cwd: tempDir });
 72 |   await fsExec('git init --bare other.git', { cwd: tempDir });
 73 |   await fsExec('git clone remote.git test', { cwd: tempDir });
 74 |   const gitDir = path.resolve(tempDir, 'test');
 75 |   await fsExec('git remote add other ../other.git', { cwd: gitDir });
 76 | 
 77 |   const cleanup = async () => {
 78 |     return fsRemove(tempDir, { recursive: true });
 79 |   };
 80 |   return {
 81 |     gitDir,
 82 |     cleanup
 83 |   };
 84 | };
 85 | 
 86 | describe('cli flow to push git branch', () => {
 87 |   it('do nothing when OCO_GITPUSH is set to false', async () => {
 88 |     const { gitDir, cleanup } = await prepareNoRemoteGitRepository();
 89 | 
 90 |     await render('echo', [`'console.log("Hello World");' > index.ts`], {
 91 |       cwd: gitDir
 92 |     });
 93 |     await render('git', ['add index.ts'], { cwd: gitDir });
 94 | 
 95 |     const { queryByText, findByText, userEvent } = await render(
 96 |       `OCO_AI_PROVIDER='test' OCO_GITPUSH='false' node`,
 97 |       [resolve('./out/cli.cjs')],
 98 |       { cwd: gitDir }
 99 |     );
100 |     expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
101 |     userEvent.keyboard('[Enter]');
102 | 
103 |     expect(
104 |       await queryByText('Choose a remote to push to')
105 |     ).not.toBeInTheConsole();
106 |     expect(
107 |       await queryByText('Do you want to run `git push`?')
108 |     ).not.toBeInTheConsole();
109 |     expect(
110 |       await queryByText('Successfully pushed all commits to origin')
111 |     ).not.toBeInTheConsole();
112 |     expect(
113 |       await queryByText('Command failed with exit code 1')
114 |     ).not.toBeInTheConsole();
115 | 
116 |     await cleanup();
117 |   });
118 | 
119 |   it('push and cause error when there is no remote', async () => {
120 |     const { gitDir, cleanup } = await prepareNoRemoteGitRepository();
121 | 
122 |     await render('echo', [`'console.log("Hello World");' > index.ts`], {
123 |       cwd: gitDir
124 |     });
125 |     await render('git', ['add index.ts'], { cwd: gitDir });
126 | 
127 |     const { queryByText, findByText, userEvent } = await render(
128 |       `OCO_AI_PROVIDER='test' OCO_GITPUSH='true' node`,
129 |       [resolve('./out/cli.cjs')],
130 |       { cwd: gitDir }
131 |     );
132 |     expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
133 |     userEvent.keyboard('[Enter]');
134 | 
135 |     expect(
136 |       await queryByText('Choose a remote to push to')
137 |     ).not.toBeInTheConsole();
138 |     expect(
139 |       await queryByText('Do you want to run `git push`?')
140 |     ).not.toBeInTheConsole();
141 |     expect(
142 |       await queryByText('Successfully pushed all commits to origin')
143 |     ).not.toBeInTheConsole();
144 | 
145 |     expect(
146 |       await findByText('Command failed with exit code 1')
147 |     ).toBeInTheConsole();
148 | 
149 |     await cleanup();
150 |   });
151 | 
152 |   it('push when one remote is set', async () => {
153 |     const { gitDir, cleanup } = await prepareOneRemoteGitRepository();
154 | 
155 |     await render('echo', [`'console.log("Hello World");' > index.ts`], {
156 |       cwd: gitDir
157 |     });
158 |     await render('git', ['add index.ts'], { cwd: gitDir });
159 | 
160 |     const { findByText, userEvent } = await render(
161 |       `OCO_AI_PROVIDER='test' OCO_GITPUSH='true' node`,
162 |       [resolve('./out/cli.cjs')],
163 |       { cwd: gitDir }
164 |     );
165 |     expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
166 |     userEvent.keyboard('[Enter]');
167 | 
168 |     expect(
169 |       await findByText('Do you want to run `git push`?')
170 |     ).toBeInTheConsole();
171 |     userEvent.keyboard('[Enter]');
172 | 
173 |     expect(
174 |       await findByText('Successfully pushed all commits to origin')
175 |     ).toBeInTheConsole();
176 | 
177 |     await cleanup();
178 |   });
179 | 
180 |   it('push when two remotes are set', async () => {
181 |     const { gitDir, cleanup } = await prepareTwoRemotesGitRepository();
182 | 
183 |     await render('echo', [`'console.log("Hello World");' > index.ts`], {
184 |       cwd: gitDir
185 |     });
186 |     await render('git', ['add index.ts'], { cwd: gitDir });
187 | 
188 |     const { findByText, userEvent } = await render(
189 |       `OCO_AI_PROVIDER='test' OCO_GITPUSH='true' node`,
190 |       [resolve('./out/cli.cjs')],
191 |       { cwd: gitDir }
192 |     );
193 |     expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
194 |     userEvent.keyboard('[Enter]');
195 | 
196 |     expect(await findByText('Choose a remote to push to')).toBeInTheConsole();
197 |     userEvent.keyboard('[Enter]');
198 | 
199 |     expect(
200 |       await findByText('Successfully pushed all commits to origin')
201 |     ).toBeInTheConsole();
202 | 
203 |     await cleanup();
204 |   });
205 | });
206 | 


--------------------------------------------------------------------------------
/test/e2e/noChanges.test.ts:
--------------------------------------------------------------------------------
 1 | import { resolve } from 'path'
 2 | import { render } from 'cli-testing-library'
 3 | import 'cli-testing-library/extend-expect';
 4 | import { prepareEnvironment } from './utils';
 5 | 
 6 | it('cli flow when there are no changes', async () => {
 7 |   const { gitDir, cleanup } = await prepareEnvironment();
 8 |   const { findByText } = await render(`OCO_AI_PROVIDER='test' node`, [resolve('./out/cli.cjs')], { cwd: gitDir });
 9 |   expect(await findByText('No changes detected')).toBeInTheConsole();
10 | 
11 |   await cleanup();
12 | });
13 | 


--------------------------------------------------------------------------------
/test/e2e/oneFile.test.ts:
--------------------------------------------------------------------------------
 1 | import { resolve } from 'path'
 2 | import { render } from 'cli-testing-library'
 3 | import 'cli-testing-library/extend-expect';
 4 | import { prepareEnvironment } from './utils';
 5 | 
 6 | it('cli flow to generate commit message for 1 new file (staged)', async () => {
 7 |   const { gitDir, cleanup } = await prepareEnvironment();
 8 | 
 9 |   await render('echo' ,[`'console.log("Hello World");' > index.ts`], { cwd: gitDir });
10 |   await render('git' ,['add index.ts'], { cwd: gitDir });
11 | 
12 |   const { queryByText, findByText, userEvent } = await render(`OCO_AI_PROVIDER='test' OCO_GITPUSH='true' node`, [resolve('./out/cli.cjs')], { cwd: gitDir });
13 |   expect(await queryByText('No files are staged')).not.toBeInTheConsole();
14 |   expect(await queryByText('Do you want to stage all files and generate commit message?')).not.toBeInTheConsole();
15 | 
16 |   expect(await findByText('Generating the commit message')).toBeInTheConsole();
17 |   expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
18 |   userEvent.keyboard('[Enter]');
19 | 
20 |   expect(await findByText('Do you want to run `git push`?')).toBeInTheConsole();
21 |   userEvent.keyboard('[Enter]');
22 | 
23 |   expect(await findByText('Successfully pushed all commits to origin')).toBeInTheConsole();
24 | 
25 |   await cleanup();
26 | });
27 | 
28 | it('cli flow to generate commit message for 1 changed file (not staged)', async () => {
29 |   const { gitDir, cleanup } = await prepareEnvironment();
30 | 
31 |   await render('echo' ,[`'console.log("Hello World");' > index.ts`], { cwd: gitDir });
32 |   await render('git' ,['add index.ts'], { cwd: gitDir });
33 |   await render('git' ,[`commit -m 'add new file'`], { cwd: gitDir });
34 | 
35 |   await render('echo' ,[`'console.log("Good night World");' >> index.ts`], { cwd: gitDir });
36 | 
37 |   const { findByText, userEvent } = await render(`OCO_AI_PROVIDER='test' OCO_GITPUSH='true' node`, [resolve('./out/cli.cjs')], { cwd: gitDir });
38 | 
39 |   expect(await findByText('No files are staged')).toBeInTheConsole();
40 |   expect(await findByText('Do you want to stage all files and generate commit message?')).toBeInTheConsole();
41 |   userEvent.keyboard('[Enter]');
42 | 
43 |   expect(await findByText('Generating the commit message')).toBeInTheConsole();
44 |   expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
45 |   userEvent.keyboard('[Enter]');
46 | 
47 |   expect(await findByText('Successfully committed')).toBeInTheConsole();
48 | 
49 |   expect(await findByText('Do you want to run `git push`?')).toBeInTheConsole();
50 |   userEvent.keyboard('[Enter]');
51 | 
52 |   expect(await findByText('Successfully pushed all commits to origin')).toBeInTheConsole();
53 | 
54 |   await cleanup();
55 | });
56 | 


--------------------------------------------------------------------------------
/test/e2e/prompt-module/commitlint.test.ts:
--------------------------------------------------------------------------------
  1 | import { resolve } from 'path';
  2 | import { render } from 'cli-testing-library';
  3 | import 'cli-testing-library/extend-expect';
  4 | import { prepareEnvironment, wait } from '../utils';
  5 | import path from 'path';
  6 | 
  7 | function getAbsolutePath(relativePath: string) {
  8 |   // Use process.cwd() which should be the project root during test execution
  9 |   return path.resolve(process.cwd(), 'test/e2e/prompt-module', relativePath);
 10 | }
 11 | async function setupCommitlint(dir: string, ver: 9 | 18 | 19) {
 12 |   let packagePath, packageJsonPath, configPath;
 13 |   switch (ver) {
 14 |     case 9:
 15 |       packagePath = getAbsolutePath('./data/commitlint_9/node_modules');
 16 |       packageJsonPath = getAbsolutePath('./data/commitlint_9/package.json');
 17 |       configPath = getAbsolutePath('./data/commitlint_9/commitlint.config.js');
 18 |       break;
 19 |     case 18:
 20 |       packagePath = getAbsolutePath('./data/commitlint_18/node_modules');
 21 |       packageJsonPath = getAbsolutePath('./data/commitlint_18/package.json');
 22 |       configPath = getAbsolutePath('./data/commitlint_18/commitlint.config.js');
 23 |       break;
 24 |     case 19:
 25 |       packagePath = getAbsolutePath('./data/commitlint_19/node_modules');
 26 |       packageJsonPath = getAbsolutePath('./data/commitlint_19/package.json');
 27 |       configPath = getAbsolutePath('./data/commitlint_19/commitlint.config.js');
 28 |       break;
 29 |   }
 30 |   await render('cp', ['-r', packagePath, '.'], { cwd: dir });
 31 |   await render('cp', [packageJsonPath, '.'], { cwd: dir });
 32 |   await render('cp', [configPath, '.'], { cwd: dir });
 33 |   await wait(3000); // Avoid flakiness by waiting
 34 | }
 35 | 
 36 | describe('cli flow to run "oco commitlint force"', () => {
 37 |   it('on commitlint@9 using CJS', async () => {
 38 |     const { gitDir, cleanup } = await prepareEnvironment();
 39 | 
 40 |     await setupCommitlint(gitDir, 9);
 41 |     const npmList = await render('npm', ['list', '@commitlint/load'], {
 42 |       cwd: gitDir
 43 |     });
 44 |     expect(await npmList.findByText('@commitlint/load@9')).toBeInTheConsole();
 45 | 
 46 |     const { findByText } = await render(
 47 |       `
 48 |       OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
 49 |       OCO_PROMPT_MODULE='@commitlint'  \
 50 |       OCO_AI_PROVIDER='test' OCO_GITPUSH='true' \
 51 |       node ${resolve('./out/cli.cjs')} commitlint force \
 52 |     `,
 53 |       [],
 54 |       { cwd: gitDir }
 55 |     );
 56 | 
 57 |     expect(
 58 |       await findByText('opencommit — configure @commitlint')
 59 |     ).toBeInTheConsole();
 60 |     expect(
 61 |       await findByText('Read @commitlint configuration')
 62 |     ).toBeInTheConsole();
 63 | 
 64 |     expect(
 65 |       await findByText('Generating consistency with given @commitlint rules')
 66 |     ).toBeInTheConsole();
 67 |     expect(
 68 |       await findByText('Done - please review contents of')
 69 |     ).toBeInTheConsole();
 70 | 
 71 |     await cleanup();
 72 |   });
 73 |   it('on commitlint@18 using CJS', async () => {
 74 |     const { gitDir, cleanup } = await prepareEnvironment();
 75 | 
 76 |     await setupCommitlint(gitDir, 18);
 77 |     const npmList = await render('npm', ['list', '@commitlint/load'], {
 78 |       cwd: gitDir
 79 |     });
 80 |     expect(await npmList.findByText('@commitlint/load@18')).toBeInTheConsole();
 81 | 
 82 |     const { findByText } = await render(
 83 |       `
 84 |       OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
 85 |       OCO_PROMPT_MODULE='@commitlint'  \
 86 |       OCO_AI_PROVIDER='test' OCO_GITPUSH='true' \
 87 |       node ${resolve('./out/cli.cjs')} commitlint force \
 88 |     `,
 89 |       [],
 90 |       { cwd: gitDir }
 91 |     );
 92 | 
 93 |     expect(
 94 |       await findByText('opencommit — configure @commitlint')
 95 |     ).toBeInTheConsole();
 96 |     expect(
 97 |       await findByText('Read @commitlint configuration')
 98 |     ).toBeInTheConsole();
 99 | 
100 |     expect(
101 |       await findByText('Generating consistency with given @commitlint rules')
102 |     ).toBeInTheConsole();
103 |     expect(
104 |       await findByText('Done - please review contents of')
105 |     ).toBeInTheConsole();
106 | 
107 |     await cleanup();
108 |   });
109 |   it('on commitlint@19 using ESM', async () => {
110 |     const { gitDir, cleanup } = await prepareEnvironment();
111 | 
112 |     await setupCommitlint(gitDir, 19);
113 |     const npmList = await render('npm', ['list', '@commitlint/load'], {
114 |       cwd: gitDir
115 |     });
116 |     expect(await npmList.findByText('@commitlint/load@19')).toBeInTheConsole();
117 | 
118 |     const { findByText } = await render(
119 |       `
120 |       OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
121 |       OCO_PROMPT_MODULE='@commitlint'  \
122 |       OCO_AI_PROVIDER='test' OCO_GITPUSH='true' \
123 |       node ${resolve('./out/cli.cjs')} commitlint force \
124 |     `,
125 |       [],
126 |       { cwd: gitDir }
127 |     );
128 | 
129 |     expect(
130 |       await findByText('opencommit — configure @commitlint')
131 |     ).toBeInTheConsole();
132 |     expect(
133 |       await findByText('Read @commitlint configuration')
134 |     ).toBeInTheConsole();
135 | 
136 |     expect(
137 |       await findByText('Generating consistency with given @commitlint rules')
138 |     ).toBeInTheConsole();
139 |     expect(
140 |       await findByText('Done - please review contents of')
141 |     ).toBeInTheConsole();
142 | 
143 |     await cleanup();
144 |   });
145 | });
146 | 
147 | describe('cli flow to generate commit message using @commitlint prompt-module', () => {
148 |   it('on commitlint@19 using ESM', async () => {
149 |     const { gitDir, cleanup } = await prepareEnvironment();
150 | 
151 |     // Setup commitlint@19
152 |     await setupCommitlint(gitDir, 19);
153 |     const npmList = await render('npm', ['list', '@commitlint/load'], {
154 |       cwd: gitDir
155 |     });
156 |     expect(await npmList.findByText('@commitlint/load@19')).toBeInTheConsole();
157 | 
158 |     // Run `oco commitlint force`
159 |     const commitlintForce = await render(
160 |       `
161 |       OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
162 |       OCO_PROMPT_MODULE='@commitlint'  \
163 |       OCO_AI_PROVIDER='test' OCO_GITPUSH='true' \
164 |       node ${resolve('./out/cli.cjs')} commitlint force \
165 |     `,
166 |       [],
167 |       { cwd: gitDir }
168 |     );
169 |     expect(
170 |       await commitlintForce.findByText('Done - please review contents of')
171 |     ).toBeInTheConsole();
172 | 
173 |     // Run `oco commitlint get`
174 |     const commitlintGet = await render(
175 |       `
176 |       OCO_TEST_MOCK_TYPE='prompt-module-commitlint-config' \
177 |       OCO_PROMPT_MODULE='@commitlint'  \
178 |       OCO_AI_PROVIDER='test' OCO_GITPUSH='true' \
179 |       node ${resolve('./out/cli.cjs')} commitlint get \
180 |     `,
181 |       [],
182 |       { cwd: gitDir }
183 |     );
184 |     expect(await commitlintGet.findByText('consistency')).toBeInTheConsole();
185 | 
186 |     // Run 'oco' using .opencommit-commitlint
187 |     await render('echo', [`'console.log("Hello World");' > index.ts`], {
188 |       cwd: gitDir
189 |     });
190 |     await render('git', ['add index.ts'], { cwd: gitDir });
191 | 
192 |     const oco = await render(
193 |       `
194 |       OCO_TEST_MOCK_TYPE='commit-message' \
195 |       OCO_PROMPT_MODULE='@commitlint'  \
196 |       OCO_AI_PROVIDER='test' OCO_GITPUSH='true' \
197 |       node ${resolve('./out/cli.cjs')} \
198 |     `,
199 |       [],
200 |       { cwd: gitDir }
201 |     );
202 | 
203 |     expect(
204 |       await oco.findByText('Generating the commit message')
205 |     ).toBeInTheConsole();
206 |     expect(
207 |       await oco.findByText('Confirm the commit message?')
208 |     ).toBeInTheConsole();
209 |     oco.userEvent.keyboard('[Enter]');
210 | 
211 |     expect(
212 |       await oco.findByText('Do you want to run `git push`?')
213 |     ).toBeInTheConsole();
214 |     oco.userEvent.keyboard('[Enter]');
215 | 
216 |     expect(
217 |       await oco.findByText('Successfully pushed all commits to origin')
218 |     ).toBeInTheConsole();
219 | 
220 |     await cleanup();
221 |   });
222 | });
223 | 


--------------------------------------------------------------------------------
/test/e2e/prompt-module/data/commitlint_18/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |   extends: ['@commitlint/config-conventional']
3 | };
4 | 


--------------------------------------------------------------------------------
/test/e2e/prompt-module/data/commitlint_18/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "commitlint-test",
 3 |   "version": "1.0.0",
 4 |   "description": "",
 5 |   "main": "index.js",
 6 |   "scripts": {
 7 |     "test": "echo \"Error: no test specified\" && exit 1"
 8 |   },
 9 |   "author": "",
10 |   "license": "ISC",
11 |   "devDependencies": {
12 |     "@commitlint/cli": "^18.0.0",
13 |     "@commitlint/config-conventional": "^18.0.0"
14 |   }
15 | }
16 | 


--------------------------------------------------------------------------------
/test/e2e/prompt-module/data/commitlint_19/commitlint.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 |   extends: ['@commitlint/config-conventional']
3 | };
4 | 


--------------------------------------------------------------------------------
/test/e2e/prompt-module/data/commitlint_19/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "commitlint-test",
 3 |   "version": "1.0.0",
 4 |   "description": "",
 5 |   "main": "index.js",
 6 |   "type": "module",
 7 |   "scripts": {
 8 |     "test": "echo \"Error: no test specified\" && exit 1"
 9 |   },
10 |   "author": "",
11 |   "license": "ISC",
12 |   "devDependencies": {
13 |     "@commitlint/cli": "^19.0.0",
14 |     "@commitlint/config-conventional": "^19.0.0"
15 |   }
16 | }
17 | 


--------------------------------------------------------------------------------
/test/e2e/prompt-module/data/commitlint_9/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |   extends: ['@commitlint/config-conventional']
3 | };
4 | 


--------------------------------------------------------------------------------
/test/e2e/prompt-module/data/commitlint_9/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "commitlint-test",
 3 |   "version": "1.0.0",
 4 |   "description": "",
 5 |   "main": "index.js",
 6 |   "scripts": {
 7 |     "test": "echo \"Error: no test specified\" && exit 1"
 8 |   },
 9 |   "author": "",
10 |   "license": "ISC",
11 |   "devDependencies": {
12 |     "@commitlint/cli": "^9.0.0",
13 |     "@commitlint/config-conventional": "^9.0.0"
14 |   }
15 | }
16 | 


--------------------------------------------------------------------------------
/test/e2e/setup.sh:
--------------------------------------------------------------------------------
 1 | #!/bin/sh
 2 | 
 3 | current_dir=$(pwd)
 4 | setup_dir="$(cd "$(dirname "$0")" && pwd)"
 5 | 
 6 | # Set up for prompt-module/commitlint
 7 | cd $setup_dir && cd prompt-module/data/commitlint_9 && npm ci
 8 | cd $setup_dir && cd prompt-module/data/commitlint_18 && npm ci
 9 | cd $setup_dir && cd prompt-module/data/commitlint_19 && npm ci
10 | 
11 | cd $current_dir
12 | 


--------------------------------------------------------------------------------
/test/e2e/utils.ts:
--------------------------------------------------------------------------------
 1 | import path from 'path'
 2 | import { mkdtemp, rm } from 'fs'
 3 | import { promisify } from 'util';
 4 | import { tmpdir } from 'os';
 5 | import { exec } from 'child_process';
 6 | const fsMakeTempDir = promisify(mkdtemp);
 7 | const fsExec = promisify(exec);
 8 | const fsRemove = promisify(rm);
 9 | 
10 | /**
11 |  * Prepare the environment for the test
12 |  * Create a temporary git repository in the temp directory
13 |  */
14 | export const prepareEnvironment = async (): Promise<{
15 |   gitDir: string;
16 |   cleanup: () => Promise<void>;
17 | }> => {
18 |   const tempDir = await prepareTempDir();
19 |   // Create a remote git repository int the temp directory. This is necessary to execute the `git push` command
20 |   await fsExec('git init --bare remote.git', { cwd: tempDir }); 
21 |   await fsExec('git clone remote.git test', { cwd: tempDir });
22 |   const gitDir = path.resolve(tempDir, 'test');
23 | 
24 |   const cleanup = async () => {
25 |     return fsRemove(tempDir, { recursive: true });
26 |   }
27 |   return {
28 |     gitDir,
29 |     cleanup,
30 |   }
31 | }
32 | 
33 | export const prepareTempDir = async(): Promise<string> => {
34 |   return await fsMakeTempDir(path.join(tmpdir(), 'opencommit-test-'));
35 | }
36 | 
37 | export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
38 | 


--------------------------------------------------------------------------------
/test/jest-setup.ts:
--------------------------------------------------------------------------------
 1 | import { jest } from '@jest/globals';
 2 | import 'cli-testing-library/extend-expect';
 3 | import { configure } from 'cli-testing-library';
 4 | 
 5 | // Make Jest available globally
 6 | global.jest = jest;
 7 | 
 8 | /**
 9 |  * Adjusted the wait time for waitFor/findByText to 2000ms, because the default 1000ms makes the test results flaky
10 |  */
11 | configure({ asyncUtilTimeout: 2000 });
12 | 


--------------------------------------------------------------------------------
/test/unit/config.test.ts:
--------------------------------------------------------------------------------
  1 | import { existsSync, readFileSync, rmSync } from 'fs';
  2 | import {
  3 |   CONFIG_KEYS,
  4 |   DEFAULT_CONFIG,
  5 |   getConfig,
  6 |   setConfig
  7 | } from '../../src/commands/config';
  8 | import { prepareFile } from './utils';
  9 | import { dirname } from 'path';
 10 | 
 11 | describe('config', () => {
 12 |   const originalEnv = { ...process.env };
 13 |   let globalConfigFile: { filePath: string; cleanup: () => Promise<void> };
 14 |   let envConfigFile: { filePath: string; cleanup: () => Promise<void> };
 15 | 
 16 |   function resetEnv(env: NodeJS.ProcessEnv) {
 17 |     Object.keys(process.env).forEach((key) => {
 18 |       if (!(key in env)) {
 19 |         delete process.env[key];
 20 |       } else {
 21 |         process.env[key] = env[key];
 22 |       }
 23 |     });
 24 |   }
 25 | 
 26 |   beforeEach(async () => {
 27 |     resetEnv(originalEnv);
 28 |     if (globalConfigFile) await globalConfigFile.cleanup();
 29 |     if (envConfigFile) await envConfigFile.cleanup();
 30 |   });
 31 | 
 32 |   afterEach(async () => {
 33 |     if (globalConfigFile) await globalConfigFile.cleanup();
 34 |     if (envConfigFile) await envConfigFile.cleanup();
 35 |   });
 36 | 
 37 |   afterAll(() => {
 38 |     resetEnv(originalEnv);
 39 |   });
 40 | 
 41 |   const generateConfig = async (
 42 |     fileName: string,
 43 |     content: Record<string, string>
 44 |   ) => {
 45 |     const fileContent = Object.entries(content)
 46 |       .map(([key, value]) => `${key}="${value}"`)
 47 |       .join('\n');
 48 |     return await prepareFile(fileName, fileContent);
 49 |   };
 50 | 
 51 |   describe('getConfig', () => {
 52 |     it('should prioritize local .env over global .opencommit config', async () => {
 53 |       globalConfigFile = await generateConfig('.opencommit', {
 54 |         OCO_API_KEY: 'global-key',
 55 |         OCO_MODEL: 'gpt-3.5-turbo',
 56 |         OCO_LANGUAGE: 'en'
 57 |       });
 58 | 
 59 |       envConfigFile = await generateConfig('.env', {
 60 |         OCO_API_KEY: 'local-key',
 61 |         OCO_LANGUAGE: 'fr'
 62 |       });
 63 | 
 64 |       const config = getConfig({
 65 |         globalPath: globalConfigFile.filePath,
 66 |         envPath: envConfigFile.filePath
 67 |       });
 68 | 
 69 |       expect(config).not.toEqual(null);
 70 |       expect(config.OCO_API_KEY).toEqual('local-key');
 71 |       expect(config.OCO_MODEL).toEqual('gpt-3.5-turbo');
 72 |       expect(config.OCO_LANGUAGE).toEqual('fr');
 73 |     });
 74 | 
 75 |     it('should fallback to global config when local config is not set', async () => {
 76 |       globalConfigFile = await generateConfig('.opencommit', {
 77 |         OCO_API_KEY: 'global-key',
 78 |         OCO_MODEL: 'gpt-4',
 79 |         OCO_LANGUAGE: 'de',
 80 |         OCO_DESCRIPTION: 'true'
 81 |       });
 82 | 
 83 |       envConfigFile = await generateConfig('.env', {
 84 |         OCO_API_URL: 'local-api-url'
 85 |       });
 86 | 
 87 |       const config = getConfig({
 88 |         globalPath: globalConfigFile.filePath,
 89 |         envPath: envConfigFile.filePath
 90 |       });
 91 | 
 92 |       expect(config).not.toEqual(null);
 93 |       expect(config.OCO_API_KEY).toEqual('global-key');
 94 |       expect(config.OCO_API_URL).toEqual('local-api-url');
 95 |       expect(config.OCO_MODEL).toEqual('gpt-4');
 96 |       expect(config.OCO_LANGUAGE).toEqual('de');
 97 |       expect(config.OCO_DESCRIPTION).toEqual(true);
 98 |     });
 99 | 
100 |     it('should handle boolean and numeric values correctly', async () => {
101 |       globalConfigFile = await generateConfig('.opencommit', {
102 |         OCO_TOKENS_MAX_INPUT: '4096',
103 |         OCO_TOKENS_MAX_OUTPUT: '500',
104 |         OCO_GITPUSH: 'true'
105 |       });
106 | 
107 |       envConfigFile = await generateConfig('.env', {
108 |         OCO_TOKENS_MAX_INPUT: '8192',
109 |         OCO_ONE_LINE_COMMIT: 'false',
110 |         OCO_OMIT_SCOPE: 'true'
111 |       });
112 | 
113 |       const config = getConfig({
114 |         globalPath: globalConfigFile.filePath,
115 |         envPath: envConfigFile.filePath
116 |       });
117 | 
118 |       expect(config).not.toEqual(null);
119 |       expect(config.OCO_TOKENS_MAX_INPUT).toEqual(8192);
120 |       expect(config.OCO_TOKENS_MAX_OUTPUT).toEqual(500);
121 |       expect(config.OCO_GITPUSH).toEqual(true);
122 |       expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
123 |       expect(config.OCO_OMIT_SCOPE).toEqual(true);
124 |     });
125 |     
126 |     it('should handle custom HTTP headers correctly', async () => {
127 |       globalConfigFile = await generateConfig('.opencommit', {
128 |         OCO_API_CUSTOM_HEADERS: '{"X-Global-Header": "global-value"}'
129 |       });
130 | 
131 |       envConfigFile = await generateConfig('.env', {
132 |         OCO_API_CUSTOM_HEADERS: '{"Authorization": "Bearer token123", "X-Custom-Header": "test-value"}'
133 |       });
134 | 
135 |       const config = getConfig({
136 |         globalPath: globalConfigFile.filePath,
137 |         envPath: envConfigFile.filePath
138 |       });
139 | 
140 |       expect(config).not.toEqual(null);
141 |       expect(config.OCO_API_CUSTOM_HEADERS).toEqual({"Authorization": "Bearer token123", "X-Custom-Header": "test-value"});
142 |       
143 |       // No need to parse JSON again since it's already an object
144 |       const parsedHeaders = config.OCO_API_CUSTOM_HEADERS;
145 |       expect(parsedHeaders).toHaveProperty('Authorization', 'Bearer token123');
146 |       expect(parsedHeaders).toHaveProperty('X-Custom-Header', 'test-value');
147 |       expect(parsedHeaders).not.toHaveProperty('X-Global-Header');
148 |     });
149 | 
150 |     it('should handle empty local config correctly', async () => {
151 |       globalConfigFile = await generateConfig('.opencommit', {
152 |         OCO_API_KEY: 'global-key',
153 |         OCO_MODEL: 'gpt-4',
154 |         OCO_LANGUAGE: 'es'
155 |       });
156 | 
157 |       envConfigFile = await generateConfig('.env', {});
158 | 
159 |       const config = getConfig({
160 |         globalPath: globalConfigFile.filePath,
161 |         envPath: envConfigFile.filePath
162 |       });
163 | 
164 |       expect(config).not.toEqual(null);
165 |       expect(config.OCO_API_KEY).toEqual('global-key');
166 |       expect(config.OCO_MODEL).toEqual('gpt-4');
167 |       expect(config.OCO_LANGUAGE).toEqual('es');
168 |     });
169 | 
170 |     it('should override global config with null values in local .env', async () => {
171 |       globalConfigFile = await generateConfig('.opencommit', {
172 |         OCO_API_KEY: 'global-key',
173 |         OCO_MODEL: 'gpt-4',
174 |         OCO_LANGUAGE: 'es'
175 |       });
176 | 
177 |       envConfigFile = await generateConfig('.env', {
178 |         OCO_API_KEY: 'null'
179 |       });
180 | 
181 |       const config = getConfig({
182 |         globalPath: globalConfigFile.filePath,
183 |         envPath: envConfigFile.filePath
184 |       });
185 | 
186 |       expect(config).not.toEqual(null);
187 |       expect(config.OCO_API_KEY).toEqual(null);
188 |     });
189 | 
190 |     it('should handle empty global config', async () => {
191 |       globalConfigFile = await generateConfig('.opencommit', {});
192 |       envConfigFile = await generateConfig('.env', {});
193 | 
194 |       const config = getConfig({
195 |         globalPath: globalConfigFile.filePath,
196 |         envPath: envConfigFile.filePath
197 |       });
198 | 
199 |       expect(config).not.toEqual(null);
200 |       expect(config.OCO_API_KEY).toEqual(undefined);
201 |     });
202 |   });
203 | 
204 |   describe('setConfig', () => {
205 |     beforeEach(async () => {
206 |       // we create and delete the file to have the parent directory, but not the file, to test the creation of the file
207 |       globalConfigFile = await generateConfig('.opencommit', {});
208 |       rmSync(globalConfigFile.filePath);
209 |     });
210 | 
211 |     it('should create .opencommit file with DEFAULT CONFIG if it does not exist on first setConfig run', async () => {
212 |       const isGlobalConfigFileExist = existsSync(globalConfigFile.filePath);
213 |       expect(isGlobalConfigFileExist).toBe(false);
214 | 
215 |       await setConfig(
216 |         [[CONFIG_KEYS.OCO_API_KEY, 'persisted-key_1']],
217 |         globalConfigFile.filePath
218 |       );
219 | 
220 |       const fileContent = readFileSync(globalConfigFile.filePath, 'utf8');
221 |       expect(fileContent).toContain('OCO_API_KEY=persisted-key_1');
222 |       Object.entries(DEFAULT_CONFIG).forEach(([key, value]) => {
223 |         expect(fileContent).toContain(`${key}=${value}`);
224 |       });
225 |     });
226 | 
227 |     it('should set new config values', async () => {
228 |       globalConfigFile = await generateConfig('.opencommit', {});
229 |       await setConfig(
230 |         [
231 |           [CONFIG_KEYS.OCO_API_KEY, 'new-key'],
232 |           [CONFIG_KEYS.OCO_MODEL, 'gpt-4']
233 |         ],
234 |         globalConfigFile.filePath
235 |       );
236 | 
237 |       const config = getConfig({
238 |         globalPath: globalConfigFile.filePath
239 |       });
240 |       expect(config.OCO_API_KEY).toEqual('new-key');
241 |       expect(config.OCO_MODEL).toEqual('gpt-4');
242 |     });
243 | 
244 |     it('should update existing config values', async () => {
245 |       globalConfigFile = await generateConfig('.opencommit', {
246 |         OCO_API_KEY: 'initial-key'
247 |       });
248 |       await setConfig(
249 |         [[CONFIG_KEYS.OCO_API_KEY, 'updated-key']],
250 |         globalConfigFile.filePath
251 |       );
252 | 
253 |       const config = getConfig({
254 |         globalPath: globalConfigFile.filePath
255 |       });
256 |       expect(config.OCO_API_KEY).toEqual('updated-key');
257 |     });
258 | 
259 |     it('should handle boolean and numeric values correctly', async () => {
260 |       globalConfigFile = await generateConfig('.opencommit', {});
261 |       await setConfig(
262 |         [
263 |           [CONFIG_KEYS.OCO_TOKENS_MAX_INPUT, '8192'],
264 |           [CONFIG_KEYS.OCO_DESCRIPTION, 'true'],
265 |           [CONFIG_KEYS.OCO_ONE_LINE_COMMIT, 'false']
266 |         ],
267 |         globalConfigFile.filePath
268 |       );
269 | 
270 |       const config = getConfig({
271 |         globalPath: globalConfigFile.filePath
272 |       });
273 |       expect(config.OCO_TOKENS_MAX_INPUT).toEqual(8192);
274 |       expect(config.OCO_DESCRIPTION).toEqual(true);
275 |       expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
276 |     });
277 | 
278 |     it('should throw an error for unsupported config keys', async () => {
279 |       globalConfigFile = await generateConfig('.opencommit', {});
280 | 
281 |       try {
282 |         await setConfig(
283 |           [['UNSUPPORTED_KEY', 'value']],
284 |           globalConfigFile.filePath
285 |         );
286 |         throw new Error('NEVER_REACHED');
287 |       } catch (error) {
288 |         expect(error.message).toContain(
289 |           'Unsupported config key: UNSUPPORTED_KEY'
290 |         );
291 |         expect(error.message).not.toContain('NEVER_REACHED');
292 |       }
293 |     });
294 | 
295 |     it('should persist changes to the config file', async () => {
296 |       const isGlobalConfigFileExist = existsSync(globalConfigFile.filePath);
297 |       expect(isGlobalConfigFileExist).toBe(false);
298 | 
299 |       await setConfig(
300 |         [[CONFIG_KEYS.OCO_API_KEY, 'persisted-key']],
301 |         globalConfigFile.filePath
302 |       );
303 | 
304 |       const fileContent = readFileSync(globalConfigFile.filePath, 'utf8');
305 |       expect(fileContent).toContain('OCO_API_KEY=persisted-key');
306 |     });
307 | 
308 |     it('should set multiple configs in a row and keep the changes', async () => {
309 |       const isGlobalConfigFileExist = existsSync(globalConfigFile.filePath);
310 |       expect(isGlobalConfigFileExist).toBe(false);
311 | 
312 |       await setConfig(
313 |         [[CONFIG_KEYS.OCO_API_KEY, 'persisted-key']],
314 |         globalConfigFile.filePath
315 |       );
316 | 
317 |       const fileContent1 = readFileSync(globalConfigFile.filePath, 'utf8');
318 |       expect(fileContent1).toContain('OCO_API_KEY=persisted-key');
319 | 
320 |       await setConfig(
321 |         [[CONFIG_KEYS.OCO_MODEL, 'gpt-4']],
322 |         globalConfigFile.filePath
323 |       );
324 | 
325 |       const fileContent2 = readFileSync(globalConfigFile.filePath, 'utf8');
326 |       expect(fileContent2).toContain('OCO_MODEL=gpt-4');
327 |     });
328 |   });
329 | });
330 | 


--------------------------------------------------------------------------------
/test/unit/gemini.test.ts:
--------------------------------------------------------------------------------
 1 | import { GeminiEngine } from '../../src/engine/gemini';
 2 | 
 3 | import { GenerativeModel, GoogleGenerativeAI } from '@google/generative-ai';
 4 | import {
 5 |   ConfigType,
 6 |   getConfig,
 7 |   OCO_AI_PROVIDER_ENUM
 8 | } from '../../src/commands/config';
 9 | import { OpenAI } from 'openai';
10 | 
11 | describe('Gemini', () => {
12 |   let gemini: GeminiEngine;
13 |   let mockConfig: ConfigType;
14 |   let mockGoogleGenerativeAi: GoogleGenerativeAI;
15 |   let mockGenerativeModel: GenerativeModel;
16 |   let mockExit: jest.SpyInstance<never, [code?: number | undefined], any>;
17 | 
18 |   const noop: (...args: any[]) => any = (...args: any[]) => {};
19 | 
20 |   const mockGemini = () => {
21 |     mockConfig = getConfig() as ConfigType;
22 | 
23 |     gemini = new GeminiEngine({
24 |       apiKey: mockConfig.OCO_API_KEY,
25 |       model: mockConfig.OCO_MODEL
26 |     });
27 |   };
28 | 
29 |   const oldEnv = process.env;
30 | 
31 |   beforeEach(() => {
32 |     jest.resetModules();
33 |     process.env = { ...oldEnv };
34 | 
35 |     jest.mock('@google/generative-ai');
36 |     jest.mock('../src/commands/config');
37 | 
38 |     jest.mock('@clack/prompts', () => ({
39 |       intro: jest.fn(),
40 |       outro: jest.fn()
41 |     }));
42 | 
43 |     mockExit = jest.spyOn(process, 'exit').mockImplementation();
44 | 
45 |     mockConfig = getConfig() as ConfigType;
46 | 
47 |     mockConfig.OCO_AI_PROVIDER = OCO_AI_PROVIDER_ENUM.GEMINI;
48 |     mockConfig.OCO_API_KEY = 'mock-api-key';
49 |     mockConfig.OCO_MODEL = 'gemini-1.5-flash';
50 | 
51 |     mockGoogleGenerativeAi = new GoogleGenerativeAI(mockConfig.OCO_API_KEY);
52 |     mockGenerativeModel = mockGoogleGenerativeAi.getGenerativeModel({
53 |       model: mockConfig.OCO_MODEL
54 |     });
55 |   });
56 | 
57 |   afterEach(() => {
58 |     gemini = undefined as any;
59 |   });
60 | 
61 |   afterAll(() => {
62 |     mockExit.mockRestore();
63 |     process.env = oldEnv;
64 |   });
65 | 
66 |   it.skip('should exit process if OCO_GEMINI_API_KEY is not set and command is not config', () => {
67 |     process.env.OCO_GEMINI_API_KEY = undefined;
68 |     process.env.OCO_AI_PROVIDER = 'gemini';
69 | 
70 |     mockGemini();
71 | 
72 |     expect(mockExit).toHaveBeenCalledWith(1);
73 |   });
74 | 
75 |   it('should generate commit message', async () => {
76 |     const mockGenerateContent = jest
77 |       .fn()
78 |       .mockResolvedValue({ response: { text: () => 'generated content' } });
79 |     mockGenerativeModel.generateContent = mockGenerateContent;
80 | 
81 |     mockGemini();
82 | 
83 |     const messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam> =
84 |       [
85 |         { role: 'system', content: 'system message' },
86 |         { role: 'assistant', content: 'assistant message' }
87 |       ];
88 | 
89 |     jest
90 |       .spyOn(gemini, 'generateCommitMessage')
91 |       .mockImplementation(async () => 'generated content');
92 |     const result = await gemini.generateCommitMessage(messages);
93 | 
94 |     expect(result).toEqual('generated content');
95 |   });
96 | });
97 | 


--------------------------------------------------------------------------------
/test/unit/removeContentTags.test.ts:
--------------------------------------------------------------------------------
 1 | import { removeContentTags } from '../../src/utils/removeContentTags';
 2 | 
 3 | describe('removeContentTags', () => {
 4 |   it('should remove content wrapped in specified tags', () => {
 5 |     const content = 'This is <think>something to hide</think> visible content';
 6 |     const result = removeContentTags(content, 'think');
 7 |     expect(result).toBe('This is visible content');
 8 |   });
 9 | 
10 |   it('should handle multiple tag occurrences', () => {
11 |     const content = '<think>hidden</think> visible <think>also hidden</think> text';
12 |     const result = removeContentTags(content, 'think');
13 |     expect(result).toBe('visible text');
14 |   });
15 | 
16 |   it('should handle multiline content within tags', () => {
17 |     const content = 'Start <think>hidden\nover multiple\nlines</think> End';
18 |     const result = removeContentTags(content, 'think');
19 |     expect(result).toBe('Start End');
20 |   });
21 | 
22 |   it('should return content as is when tag is not found', () => {
23 |     const content = 'Content without any tags';
24 |     const result = removeContentTags(content, 'think');
25 |     expect(result).toBe('Content without any tags');
26 |   });
27 | 
28 |   it('should work with different tag names', () => {
29 |     const content = 'This is <custom>something to hide</custom> visible content';
30 |     const result = removeContentTags(content, 'custom');
31 |     expect(result).toBe('This is visible content');
32 |   });
33 | 
34 |   it('should handle null content', () => {
35 |     const content = null;
36 |     const result = removeContentTags(content, 'think');
37 |     expect(result).toBe(null);
38 |   });
39 | 
40 |   it('should handle undefined content', () => {
41 |     const content = undefined;
42 |     const result = removeContentTags(content, 'think');
43 |     expect(result).toBe(undefined);
44 |   });
45 | 
46 |   it('should trim the result', () => {
47 |     const content = '  <think>hidden</think> visible  ';
48 |     const result = removeContentTags(content, 'think');
49 |     expect(result).toBe('visible');
50 |   });
51 | 
52 |   it('should handle nested tags correctly', () => {
53 |     const content = 'Outside <think>Inside <think>Nested</think></think> End';
54 |     const result = removeContentTags(content, 'think');
55 |     expect(result).toBe('Outside End');
56 |   });
57 | });
58 | 


--------------------------------------------------------------------------------
/test/unit/utils.ts:
--------------------------------------------------------------------------------
 1 | import { existsSync, mkdtemp, rm, writeFile } from 'fs';
 2 | import { tmpdir } from 'os';
 3 | import path from 'path';
 4 | import { promisify } from 'util';
 5 | const fsMakeTempDir = promisify(mkdtemp);
 6 | const fsRemove = promisify(rm);
 7 | const fsWriteFile = promisify(writeFile);
 8 | 
 9 | /**
10 |  * Prepare tmp file for the test
11 |  */
12 | export async function prepareFile(
13 |   fileName: string,
14 |   content: string
15 | ): Promise<{
16 |   filePath: string;
17 |   cleanup: () => Promise<void>;
18 | }> {
19 |   const tempDir = await fsMakeTempDir(path.join(tmpdir(), 'opencommit-test-'));
20 |   const filePath = path.resolve(tempDir, fileName);
21 |   await fsWriteFile(filePath, content);
22 |   const cleanup = async () => {
23 |     if (existsSync(tempDir)) {
24 |       await fsRemove(tempDir, { recursive: true });
25 |     }
26 |   };
27 | 
28 |   return {
29 |     filePath,
30 |     cleanup
31 |   };
32 | }
33 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2020",
 4 |     "lib": ["ES6", "ES2020"],
 5 | 
 6 |     "module": "NodeNext",
 7 | 
 8 |     "resolveJsonModule": true,
 9 |     "moduleResolution": "NodeNext",
10 | 
11 |     "allowJs": true,
12 | 
13 |     "outDir": "./out",
14 | 
15 |     "esModuleInterop": true,
16 |     "forceConsistentCasingInFileNames": true,
17 | 
18 |     "strict": true,
19 |     "noUnusedLocals": true,
20 |     "noUnusedParameters": true,
21 | 
22 |     "skipLibCheck": true
23 |   },
24 |   "include": ["test/jest-setup.ts"],
25 |   "exclude": ["node_modules"],
26 |   "ts-node": {
27 |     "esm": true,
28 |     "experimentalSpecifierResolution": "node"
29 |   }
30 | }
31 | 


--------------------------------------------------------------------------------