diff --git a/.editorconfig b/.editorconfig index 1c6314a3..a500985f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,7 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +quote_type = single [*.yml] indent_style = space diff --git a/.vscode/settings.json b/.vscode/settings.json index af6434b6..4d360cbc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { "typescript.tsdk": "node_modules/typescript/lib" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 05c4bc09..7afcfaaf 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,53 @@ aicommits --dry-run > 👉 **Tip:** Use the `aic` alias if `aicommits` is too long for you. +### Flags + +#### `--generate ` (or `--g `) + +Generate multiple commit messages at once. + +```sh +aicommits --generate 3 +``` + +#### `--gitmoji` (or `--m`) + +(Experimental) Generate a commit message with a gitmoji: https://gitmoji.dev/. + +```sh +aicommits --gitmoji +aicommits --m +``` + +#### `--conventional` (or `--c`) + +(Experimental) Generate a commit message based on the conventional commit standard: https://www.conventionalcommits.org/en/v1.0.0/. + +```sh +aicommits --conventional +aicommits --c +``` + +### Combine flags + +You can combine flags to generate a commit message with a gitmoji and a conventional commit. + +```sh +aicommits --gitmoji --conventional --generate 3 +aicommits --m --c --g 3 +``` + +### Configure default flags + +You can configure default flags to be used every time you run `aicommits` by running: + + +```sh +aicommits config set gitmoji=true +aicommits config set conventional=true +``` + ### Git hook You can also integrate _aicommits_ with Git via the [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) hook. This lets you use Git like you normally would, and edit the commit message before committing. @@ -125,4 +172,4 @@ Video coming soon where I rebuild it from scratch to show you how to easily buil ## Contributing -If you want to help fix a bug or implement a feature in [Issues](https://github.com/Nutlope/aicommits/issues), checkout the [Contribution Guide](CONTRIBUTING.md) to learn how to setup and test the project. \ No newline at end of file +If you want to help fix a bug or implement a feature in [Issues](https://github.com/Nutlope/aicommits/issues), checkout the [Contribution Guide](CONTRIBUTING.md) to learn how to setup and test the project. diff --git a/package.json b/package.json index a57c0ab0..c4bc3c98 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "build": "pkgroll --minify", "lint": "eslint --cache .", "type-check": "tsc", - "test": "tsx tests", + "test": "vitest", "prepack": "pnpm build && clean-pkg-json" }, "simple-git-hooks": { @@ -32,9 +32,6 @@ "lint-staged": { "*.ts": "eslint --cache" }, - "dependencies": { - "@dqbd/tiktoken": "^0.4.0" - }, "devDependencies": { "@clack/prompts": "^0.6.1", "@pvtnbr/eslint-config": "^0.33.0", @@ -49,12 +46,12 @@ "ini": "^3.0.1", "kolorist": "^1.7.0", "lint-staged": "^13.1.2", - "manten": "^0.7.0", "openai": "^3.2.1", "pkgroll": "^1.9.0", "simple-git-hooks": "^2.8.1", "tsx": "^3.12.3", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vitest": "^0.29.7" }, "eslintConfig": { "extends": "@pvtnbr", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0be6c5e6..fff75ef5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,6 @@ patchedDependencies: specifiers: '@clack/prompts': ^0.6.1 - '@dqbd/tiktoken': ^0.4.0 '@pvtnbr/eslint-config': ^0.33.0 '@types/ini': ^1.3.31 '@types/inquirer': ^9.0.3 @@ -20,15 +19,12 @@ specifiers: ini: ^3.0.1 kolorist: ^1.7.0 lint-staged: ^13.1.2 - manten: ^0.7.0 openai: ^3.2.1 pkgroll: ^1.9.0 simple-git-hooks: ^2.8.1 tsx: ^3.12.3 typescript: ^4.9.5 - -dependencies: - '@dqbd/tiktoken': 0.4.0 + vitest: ^0.29.7 devDependencies: '@clack/prompts': 0.6.1_seqcoud6rtee7vmn7zfu7zbwcy @@ -44,12 +40,12 @@ devDependencies: ini: 3.0.1 kolorist: 1.7.0 lint-staged: 13.1.2 - manten: 0.7.0 openai: 3.2.1 pkgroll: 1.9.0_typescript@4.9.5 simple-git-hooks: 2.8.1 tsx: 3.12.3 typescript: 4.9.5 + vitest: 0.29.7 packages: @@ -92,10 +88,6 @@ packages: - is-unicode-supported patched: true - /@dqbd/tiktoken/0.4.0: - resolution: {integrity: sha512-iaHgmwKAOqowBFZKxelyszoeGLoNw62eOULcmyme1aA1Ymr3JgYl0V7jwpuUm7fksalycZajx3loFn9TRUaviw==} - dev: false - /@esbuild-kit/cjs-loader/2.4.2: resolution: {integrity: sha512-BDXFbYOJzT/NBEtp71cvsrGPwGAMGRB/349rwKuoxNSiKjPraNNnlK6MIIabViCjqZugu6j+xeMDlEkWdHHJSg==} dependencies: @@ -357,32 +349,6 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true - /@jest/expect-utils/29.5.0: - resolution: {integrity: sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - jest-get-type: 29.4.3 - dev: true - - /@jest/schemas/29.4.3: - resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@sinclair/typebox': 0.25.24 - dev: true - - /@jest/types/29.5.0: - resolution: {integrity: sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/schemas': 29.4.3 - '@types/istanbul-lib-coverage': 2.0.4 - '@types/istanbul-reports': 3.0.1 - '@types/node': 18.14.2 - '@types/yargs': 17.0.22 - chalk: 4.1.2 - dev: true - /@jridgewell/sourcemap-codec/1.4.14: resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} dev: true @@ -554,8 +520,14 @@ packages: rollup: 2.79.1 dev: true - /@sinclair/typebox/0.25.24: - resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} + /@types/chai-subset/1.3.3: + resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} + dependencies: + '@types/chai': 4.3.4 + dev: true + + /@types/chai/4.3.4: + resolution: {integrity: sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==} dev: true /@types/estree/1.0.0: @@ -573,22 +545,6 @@ packages: rxjs: 7.8.0 dev: true - /@types/istanbul-lib-coverage/2.0.4: - resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} - dev: true - - /@types/istanbul-lib-report/3.0.0: - resolution: {integrity: sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==} - dependencies: - '@types/istanbul-lib-coverage': 2.0.4 - dev: true - - /@types/istanbul-reports/3.0.1: - resolution: {integrity: sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==} - dependencies: - '@types/istanbul-lib-report': 3.0.0 - dev: true - /@types/json-schema/7.0.11: resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} dev: true @@ -619,10 +575,6 @@ packages: resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} dev: true - /@types/stack-utils/2.0.1: - resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} - dev: true - /@types/through/0.0.30: resolution: {integrity: sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg==} dependencies: @@ -633,16 +585,6 @@ packages: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} dev: true - /@types/yargs-parser/21.0.0: - resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} - dev: true - - /@types/yargs/17.0.22: - resolution: {integrity: sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g==} - dependencies: - '@types/yargs-parser': 21.0.0 - dev: true - /@typescript-eslint/eslint-plugin/5.52.0_h4p7dqjdloyt5dk25hzsjnx4fi: resolution: {integrity: sha512-lHazYdvYVsBokwCdKOppvYJKaJ4S41CgKBcPvyd0xjZNbvQdhn/pnJlGtQksQ/NhInzdaeaSarlBjDXHuclEbg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -773,6 +715,37 @@ packages: eslint-visitor-keys: 3.3.0 dev: true + /@vitest/expect/0.29.7: + resolution: {integrity: sha512-UtG0tW0DP6b3N8aw7PHmweKDsvPv4wjGvrVZW7OSxaFg76ShtVdMiMcUkZJgCE8QWUmhwaM0aQhbbVLo4F4pkA==} + dependencies: + '@vitest/spy': 0.29.7 + '@vitest/utils': 0.29.7 + chai: 4.3.7 + dev: true + + /@vitest/runner/0.29.7: + resolution: {integrity: sha512-Yt0+csM945+odOx4rjZSjibQfl2ymxqVsmYz6sO2fiO5RGPYDFCo60JF6tLL9pz4G/kjY4irUxadeB1XT+H1jg==} + dependencies: + '@vitest/utils': 0.29.7 + p-limit: 4.0.0 + pathe: 1.1.0 + dev: true + + /@vitest/spy/0.29.7: + resolution: {integrity: sha512-IalL0iO6A6Xz8hthR8sctk6ZS//zVBX48EiNwQguYACdgdei9ZhwMaBFV70mpmeYAFCRAm+DpoFHM5470Im78A==} + dependencies: + tinyspy: 1.1.1 + dev: true + + /@vitest/utils/0.29.7: + resolution: {integrity: sha512-vNgGadp2eE5XKCXtZXL5UyNEDn68npSct75OC9AlELenSK0DiV1Mb9tfkwJHKjRb69iek+e79iipoJx8+s3SdA==} + dependencies: + cli-truncate: 3.1.0 + diff: 5.1.0 + loupe: 2.3.6 + pretty-format: 27.5.1 + dev: true + /acorn-jsx/5.3.2_acorn@8.8.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -781,6 +754,11 @@ packages: acorn: 8.8.2 dev: true + /acorn-walk/8.2.0: + resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} + engines: {node: '>=0.4.0'} + dev: true + /acorn/8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} engines: {node: '>=0.4.0'} @@ -895,6 +873,10 @@ packages: get-intrinsic: 1.2.0 dev: true + /assertion-error/1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + dev: true + /astral-regex/2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -954,6 +936,11 @@ packages: engines: {node: '>=6'} dev: true + /cac/6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + dev: true + /call-bind/1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: @@ -966,6 +953,19 @@ packages: engines: {node: '>=6'} dev: true + /chai/4.3.7: + resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==} + engines: {node: '>=4'} + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.2 + deep-eql: 4.1.3 + get-func-name: 2.0.0 + loupe: 2.3.6 + pathval: 1.1.1 + type-detect: 4.0.8 + dev: true + /chalk/2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -995,6 +995,10 @@ packages: resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} dev: true + /check-error/1.0.2: + resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} + dev: true + /ci-info/3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} @@ -1139,6 +1143,13 @@ packages: ms: 2.1.2 dev: true + /deep-eql/4.1.3: + resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} + engines: {node: '>=6'} + dependencies: + type-detect: 4.0.8 + dev: true + /deep-is/0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -1166,9 +1177,9 @@ packages: engines: {node: '>=0.4.0'} dev: true - /diff-sequences/29.4.3: - resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + /diff/5.1.0: + resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} + engines: {node: '>=0.3.1'} dev: true /dir-glob/3.0.1: @@ -1316,11 +1327,6 @@ packages: engines: {node: '>=0.8.0'} dev: true - /escape-string-regexp/2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - dev: true - /escape-string-regexp/4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1759,17 +1765,6 @@ packages: strip-final-newline: 3.0.0 dev: true - /expect/29.5.0: - resolution: {integrity: sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/expect-utils': 29.5.0 - jest-get-type: 29.4.3 - jest-matcher-utils: 29.5.0 - jest-message-util: 29.5.0 - jest-util: 29.5.0 - dev: true - /fast-deep-equal/3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -1901,6 +1896,10 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /get-func-name/2.0.0: + resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==} + dev: true + /get-intrinsic/1.2.0: resolution: {integrity: sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==} dependencies: @@ -2350,58 +2349,6 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true - /jest-diff/29.5.0: - resolution: {integrity: sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - chalk: 4.1.2 - diff-sequences: 29.4.3 - jest-get-type: 29.4.3 - pretty-format: 29.5.0 - dev: true - - /jest-get-type/29.4.3: - resolution: {integrity: sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dev: true - - /jest-matcher-utils/29.5.0: - resolution: {integrity: sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - chalk: 4.1.2 - jest-diff: 29.5.0 - jest-get-type: 29.4.3 - pretty-format: 29.5.0 - dev: true - - /jest-message-util/29.5.0: - resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@babel/code-frame': 7.18.6 - '@jest/types': 29.5.0 - '@types/stack-utils': 2.0.1 - chalk: 4.1.2 - graceful-fs: 4.2.10 - micromatch: 4.0.5 - pretty-format: 29.5.0 - slash: 3.0.0 - stack-utils: 2.0.6 - dev: true - - /jest-util/29.5.0: - resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dependencies: - '@jest/types': 29.5.0 - '@types/node': 18.14.2 - chalk: 4.1.2 - ci-info: 3.8.0 - graceful-fs: 4.2.10 - picomatch: 2.3.1 - dev: true - /js-sdsl/4.3.0: resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} dev: true @@ -2457,6 +2404,10 @@ packages: semver: 7.3.8 dev: true + /jsonc-parser/3.2.0: + resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + dev: true + /jsx-ast-utils/3.3.3: resolution: {integrity: sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==} engines: {node: '>=4.0'} @@ -2528,6 +2479,11 @@ packages: wrap-ansi: 7.0.0 dev: true + /local-pkg/0.4.3: + resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} + engines: {node: '>=14'} + dev: true + /locate-path/5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2567,6 +2523,12 @@ packages: js-tokens: 4.0.0 dev: true + /loupe/2.3.6: + resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==} + dependencies: + get-func-name: 2.0.0 + dev: true + /lowercase-keys/1.0.1: resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==} engines: {node: '>=0.10.0'} @@ -2593,12 +2555,6 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /manten/0.7.0: - resolution: {integrity: sha512-CTGbnhYI9Y8nVUm9BQXuv8KW55Q9FxZMmZYtedMxHQJsBLXMMntdvZJefRAxBCISCFkQk1+/7vBwh/8z22BZ+Q==} - dependencies: - expect: 29.5.0 - dev: true - /mdast-util-from-markdown/0.8.5: resolution: {integrity: sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==} dependencies: @@ -2685,6 +2641,15 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true + /mlly/1.2.0: + resolution: {integrity: sha512-+c7A3CV0KGdKcylsI6khWyts/CYrGTrRVo4R/I7u/cUsy0Conxa6LUhiEzVKIw14lc2L5aiO4+SeVe4TeGRKww==} + dependencies: + acorn: 8.8.2 + pathe: 1.1.0 + pkg-types: 1.0.2 + ufo: 1.1.1 + dev: true + /ms/2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} dev: true @@ -2693,6 +2658,12 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + /natural-compare-lite/1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} dev: true @@ -2855,6 +2826,13 @@ packages: yocto-queue: 0.1.0 dev: true + /p-limit/4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + yocto-queue: 1.0.0 + dev: true + /p-locate/4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -2938,6 +2916,14 @@ packages: engines: {node: '>=8'} dev: true + /pathe/1.1.0: + resolution: {integrity: sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w==} + dev: true + + /pathval/1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + dev: true + /picocolors/1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true @@ -2953,6 +2939,14 @@ packages: hasBin: true dev: true + /pkg-types/1.0.2: + resolution: {integrity: sha512-hM58GKXOcj8WTqUXnsQyJYXdeAPbythQgEF3nTcEo+nkD49chjQ9IKm/QJy9xf6JakXptz86h7ecP2024rrLaQ==} + dependencies: + jsonc-parser: 3.2.0 + mlly: 1.2.0 + pathe: 1.1.0 + dev: true + /pkgroll/1.9.0_typescript@4.9.5: resolution: {integrity: sha512-/DU749mcLLo3NHvCNi87nIIQkzk0nh3ZIq4fRWmgr1V+2UTJRgVCvg/nxn2Hz+Mv4j7CTxutdAOOgmu+wSi5bw==} hasBin: true @@ -2988,18 +2982,27 @@ packages: util-deprecate: 1.0.2 dev: true + /postcss/8.4.21: + resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + /prelude-ls/1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} dev: true - /pretty-format/29.5.0: - resolution: {integrity: sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + /pretty-format/27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: - '@jest/schemas': 29.4.3 + ansi-regex: 5.0.1 ansi-styles: 5.2.0 - react-is: 18.2.0 + react-is: 17.0.2 dev: true /prop-types/15.8.1: @@ -3028,8 +3031,8 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true - /react-is/18.2.0: - resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + /react-is/17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} dev: true /read-pkg-up/7.0.1: @@ -3145,6 +3148,14 @@ packages: fsevents: 2.3.2 dev: true + /rollup/3.20.1: + resolution: {integrity: sha512-sz2w8cBJlWQ2E17RcpvHuf4sk2BQx4tfKDnjNPikEpLEevrbIAR7CH3PGa2hpPwWbNgPaA9yh9Jzljds5bc9zg==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /run-parallel/1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -3217,6 +3228,10 @@ packages: object-inspect: 1.12.3 dev: true + /siginfo/2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + /signal-exit/3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true @@ -3267,6 +3282,11 @@ packages: is-fullwidth-code-point: 4.0.0 dev: true + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + /source-map-support/0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} dependencies: @@ -3301,11 +3321,12 @@ packages: resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} dev: true - /stack-utils/2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - dependencies: - escape-string-regexp: 2.0.0 + /stackback/0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + + /std-env/3.3.2: + resolution: {integrity: sha512-uUZI65yrV2Qva5gqE0+A7uVAvO40iPo6jGhs7s8keRfHCmtg+uB2X6EiLGCI9IgL1J17xGhvoOqSz79lzICPTA==} dev: true /string-argv/0.3.1: @@ -3396,6 +3417,12 @@ packages: engines: {node: '>=8'} dev: true + /strip-literal/1.0.1: + resolution: {integrity: sha512-QZTsipNpa2Ppr6v1AmJHESqJ3Uz247MUS0OjrnnZjFAvEoWqxuyFuXn2xLgMtRnijJShAa1HL0gtJyUs7u7n3Q==} + dependencies: + acorn: 8.8.2 + dev: true + /supports-color/5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -3447,6 +3474,20 @@ packages: globrex: 0.1.2 dev: true + /tinybench/2.4.0: + resolution: {integrity: sha512-iyziEiyFxX4kyxSp+MtY1oCH/lvjH3PxFN8PGCDeqcZWAJ/i+9y+nL85w99PxVzrIvew/GSkSbDYtiGVa85Afg==} + dev: true + + /tinypool/0.4.0: + resolution: {integrity: sha512-2ksntHOKf893wSAH4z/+JbPpi92esw8Gn9N2deXX+B0EO92hexAVI9GIZZPx7P5aYo5KULfeOSt3kMOmSOy6uA==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy/1.1.1: + resolution: {integrity: sha512-UVq5AXt/gQlti7oxoIg5oi/9r0WpF7DGEVwXgqWSMmyN16+e3tl5lIvTaOpJ3TAtu5xFzWccFRM4R5NaWHF+4g==} + engines: {node: '>=14.0.0'} + dev: true + /to-regex-range/5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3499,6 +3540,11 @@ packages: prelude-ls: 1.2.1 dev: true + /type-detect/4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + dev: true + /type-fest/0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -3537,6 +3583,10 @@ packages: hasBin: true dev: true + /ufo/1.1.1: + resolution: {integrity: sha512-MvlCc4GHrmZdAllBc0iUDowff36Q9Ndw/UzqmEKyrfSzokTd9ZCy1i+IIk5hrYKkjoYVQyNbrw7/F8XJ2rEwTg==} + dev: true + /unbox-primitive/1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: @@ -3569,6 +3619,122 @@ packages: spdx-expression-parse: 3.0.1 dev: true + /vite-node/0.29.7_@types+node@18.14.2: + resolution: {integrity: sha512-PakCZLvz37yFfUPWBnLa1OYHPCGm5v4pmRrTcFN4V/N/T3I6tyP3z07S//9w+DdeL7vVd0VSeyMZuAh+449ZWw==} + engines: {node: '>=v14.16.0'} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4 + mlly: 1.2.0 + pathe: 1.1.0 + picocolors: 1.0.0 + vite: 4.2.1_@types+node@18.14.2 + transitivePeerDependencies: + - '@types/node' + - less + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /vite/4.2.1_@types+node@18.14.2: + resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 18.14.2 + esbuild: 0.17.8 + postcss: 8.4.21 + resolve: 1.22.1 + rollup: 3.20.1 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /vitest/0.29.7: + resolution: {integrity: sha512-aWinOSOu4jwTuZHkb+cCyrqQ116Q9TXaJrNKTHudKBknIpR0VplzeaOUuDF9jeZcrbtQKZQt6yrtd+eakbaxHg==} + engines: {node: '>=v14.16.0'} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@vitest/browser': '*' + '@vitest/ui': '*' + happy-dom: '*' + jsdom: '*' + safaridriver: '*' + webdriverio: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + dependencies: + '@types/chai': 4.3.4 + '@types/chai-subset': 1.3.3 + '@types/node': 18.14.2 + '@vitest/expect': 0.29.7 + '@vitest/runner': 0.29.7 + '@vitest/spy': 0.29.7 + '@vitest/utils': 0.29.7 + acorn: 8.8.2 + acorn-walk: 8.2.0 + cac: 6.7.14 + chai: 4.3.7 + debug: 4.3.4 + local-pkg: 0.4.3 + pathe: 1.1.0 + picocolors: 1.0.0 + source-map: 0.6.1 + std-env: 3.3.2 + strip-literal: 1.0.1 + tinybench: 2.4.0 + tinypool: 0.4.0 + tinyspy: 1.1.1 + vite: 4.2.1_@types+node@18.14.2 + vite-node: 0.29.7_@types+node@18.14.2 + why-is-node-running: 2.2.2 + transitivePeerDependencies: + - less + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vue-eslint-parser/9.1.0_eslint@8.35.0: resolution: {integrity: sha512-NGn/iQy8/Wb7RrRa4aRkokyCZfOUWk19OP5HP6JEozQFX5AoS/t+Z0ZN7FY4LlmWc4FNI922V7cvX28zctN8dQ==} engines: {node: ^14.17.0 || >=16.0.0} @@ -3617,6 +3783,15 @@ packages: isexe: 2.0.0 dev: true + /why-is-node-running/2.2.2: + resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + /word-wrap/1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} engines: {node: '>=0.10.0'} @@ -3662,3 +3837,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /yocto-queue/1.0.0: + resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} + engines: {node: '>=12.20'} + dev: true diff --git a/src/cli.ts b/src/cli.ts index d30a3c35..631f11bd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,16 @@ cli( description: 'Number of messages to generate. (Warning: generating multiple costs more) (default: 1)', alias: 'g', }, + gitmoji: { + type: Boolean, + description: '(Experimental) Enable the use of Gitmoji', + alias: 'm', + }, + conventional: { + type: Boolean, + description: '(Experimental) Enable the use of Conventional Commits', + alias: 'c', + }, }, commands: [ @@ -44,6 +54,8 @@ cli( aicommits( argv.flags.generate, rawArgv, + argv.flags.gitmoji, + argv.flags.conventional, ); } }, diff --git a/src/commands/aicommits.ts b/src/commands/aicommits.ts index 99023488..639bdf40 100644 --- a/src/commands/aicommits.ts +++ b/src/commands/aicommits.ts @@ -11,12 +11,14 @@ import { getDetectedMessage, } from '../utils/git.js'; import { getConfig } from '../utils/config.js'; -import { generateCommitMessage } from '../utils/openai.js'; +import { CommitMessage, generateCommitMessage } from '../utils/openai.js'; import { KnownError, handleCliError } from '../utils/error.js'; export default async ( generate: number | undefined, rawArgv: string[], + gitmoji: boolean | undefined, + conventional: boolean | undefined, ) => (async () => { intro(bgCyan(black(' aicommits '))); @@ -38,31 +40,35 @@ export default async ( const config = await getConfig({ OPENAI_KEY: process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY, generate: generate?.toString(), + gitmoji: gitmoji?.toString(), + conventional: conventional?.toString(), }); const s = spinner(); s.start('The AI is analyzing your changes'); - let messages: string[]; + let commitMessages: CommitMessage[]; try { - messages = await generateCommitMessage( + commitMessages = await generateCommitMessage( config.OPENAI_KEY, config.locale, staged.diff, config.generate, + config.conventional, + config.gitmoji, ); } finally { s.stop('Changes analyzed'); } - if (messages.length === 0) { + if (commitMessages.length === 0) { throw new KnownError('No commit messages were generated. Try again.'); } - let message: string; - if (messages.length === 1) { - [message] = messages; + let selectedCommitMessage: CommitMessage; + if (commitMessages.length === 1) { + [selectedCommitMessage] = commitMessages; const confirmed = await confirm({ - message: `Use this commit message?\n\n ${message}\n`, + message: `Use this commit message?\n\n${selectedCommitMessage.title}\n\n${selectedCommitMessage.description}\n`, }); if (!confirmed || isCancel(confirmed)) { @@ -72,7 +78,11 @@ export default async ( } else { const selected = await select({ message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`, - options: messages.map(value => ({ label: value, value })), + options: commitMessages.map(value => ({ + label: value.title, + hint: `description: ${value.description}`, + value, + })), }); if (isCancel(selected)) { @@ -80,10 +90,16 @@ export default async ( return; } - message = selected; + selectedCommitMessage = selected; } - await execa('git', ['commit', '-m', message, ...rawArgv]); + const commitMessage = sanitizeMessage(`${selectedCommitMessage.title}\n\n${selectedCommitMessage.description}`); + + function sanitizeMessage(message: string): string { + return message.replace(/
/g, '\n').replace(/\n{3,}/g, '\n'); + } + + await execa('git', ['commit', '-m', commitMessage, ...rawArgv]); outro(`${green('✔')} Successfully committed!`); })().catch((error) => { diff --git a/src/commands/prepare-commit-msg-hook.ts b/src/commands/prepare-commit-msg-hook.ts index 57d3ae88..81a0c7c5 100644 --- a/src/commands/prepare-commit-msg-hook.ts +++ b/src/commands/prepare-commit-msg-hook.ts @@ -41,6 +41,8 @@ export default () => (async () => { config.locale, staged!.diff, config.generate, + config.conventional, + config.gitmoji, ); } finally { s.stop('Changes analyzed'); diff --git a/src/utils/config.ts b/src/utils/config.ts index d029e9a0..7fce04dc 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -49,6 +49,26 @@ const configParsers = { parseAssert('generate', parsed > 0, 'Must be greater than 0'); parseAssert('generate', parsed <= 5, 'Must be less or equal to 5'); + return parsed; + }, + conventional(conventional?: string) { + if (!conventional) { + return false; + } + + parseAssert('conventional', /^true|false$/.test(conventional), 'Must be a boolean'); + const parsed = Boolean(conventional); + + return parsed; + }, + gitmoji(gitmoji?: string) { + if (!gitmoji) { + return false; + } + + parseAssert('gitmoji', /^true|false$/.test(gitmoji), 'Must be a boolean'); + const parsed = Boolean(gitmoji); + return parsed; }, } as const; diff --git a/src/utils/gitmoji.ts b/src/utils/gitmoji.ts new file mode 100644 index 00000000..ab419f13 --- /dev/null +++ b/src/utils/gitmoji.ts @@ -0,0 +1,15 @@ +async function retrieveGitmojis(): Promise< + Array<{ emoji: string; description: string }> + > { + const response = await fetch('https://gitmoji.dev/api/gitmojis'); + const data = await response.json(); + const gitmojis = data.gitmojis.map( + (gitmoji: { emoji: string; description: string }) => ({ + emoji: gitmoji.emoji, + description: gitmoji.description, + }), + ); + return gitmojis; +} + +export { retrieveGitmojis }; diff --git a/src/utils/openai.ts b/src/utils/openai.ts index 16966ade..33c9ba73 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -1,142 +1,208 @@ -import https from 'https'; -import type { ClientRequest, IncomingMessage } from 'http'; -import type { CreateChatCompletionRequest, CreateChatCompletionResponse } from 'openai'; -import { encoding_for_model as encodingForModel } from '@dqbd/tiktoken'; +import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from 'openai'; import { KnownError } from './error.js'; - -const httpsPost = async ( - hostname: string, - path: string, - headers: Record, - json: unknown, -) => new Promise<{ - request: ClientRequest; - response: IncomingMessage; - data: string; -}>((resolve, reject) => { - const postContent = JSON.stringify(json); - const request = https.request( - { - port: 443, - hostname, - path, - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postContent), - }, - timeout: 10_000, // 10s - }, - (response) => { - const body: Buffer[] = []; - response.on('data', chunk => body.push(chunk)); - response.on('end', () => { - resolve({ - request, - response, - data: Buffer.concat(body).toString(), - }); - }); - }, - ); - request.on('error', reject); - request.on('timeout', () => { - request.destroy(); - reject(new KnownError('Request timed out')); +import { retrieveGitmojis } from './gitmoji.js'; + +export type CommitMessage = { + title: string; + description: string; +} + +const deduplicateMessages = (array: CommitMessage[]) => { + // deduplicate array on title and description + const seen = new Set(); + return array.filter((item) => { + const key = `${item.title}${item.description}`; + const duplicate = seen.has(key); + seen.add(key); + return !duplicate; }); +}; - request.write(postContent); - request.end(); -}); +const getBasePrompt = (locale: string) => ` +I want you to act as the author with language ${locale} of a commit message in git. +I'll enter a git diff and your job is to convert create a useful commit message based on the diff in the present tense.`; + +const getOutputFormat = (locale: string) => ` +I want you to output the result in the following format and language ${locale}: +{ + "title": "", + "description": "", +} +`; + +const getCommitMessageExtraContext = (locale: string) => ` +The must be in the language: ${locale}. +The must be in the language: ${locale}. +The should be no longer than 50 characters. +The must be a full sentence. +`; + +const getCommitTitleFormatPrompt = (useConventionalCommits: boolean, useGitmoji: boolean) => { + const commitTitleParts = []; + + if (useConventionalCommits) { + commitTitleParts.push('():'); + } -const createChatCompletion = async ( - apiKey: string, - json: CreateChatCompletionRequest, -) => { - const { response, data } = await httpsPost( - 'api.openai.com', - '/v1/chat/completions', - { - Authorization: `Bearer ${apiKey}`, - }, - json, - ); + if (useGitmoji) { + commitTitleParts.push(''); + } - if ( - !response.statusCode - || response.statusCode < 200 - || response.statusCode > 299 - ) { - let errorMessage = `OpenAI API Error: ${response.statusCode} - ${response.statusMessage}`; + commitTitleParts.push(''); - if (data) { - errorMessage += `\n\n${data}`; - } + return commitTitleParts.join(' '); +}; - if (response.statusCode === 500) { - errorMessage += '\n\nCheck the API status: https://status.openai.com'; - } +const getCommitMessageFormatPrompt = (useConventionalCommits: boolean, useGitmoji: boolean) => { + const commitTitleFormat = getCommitTitleFormatPrompt(useConventionalCommits, useGitmoji); - throw new KnownError(errorMessage); + return `I want you to always output a single example in the following valid JSON format: + { + "title": "${commitTitleFormat}", + "description": "" + }`; +}; + +const getExtraContextForConventionalCommits = () => { + // Based on https://medium.com/neudesic-innovation/conventional-commits-a-better-way-78d6785c2e08 + const conventionalCommitTypes: Record = { + /* + Commented out feat: and fix: because they are too common and + will cause the model to generate them too often. + */ + // feat: 'The commit implements a new feature for the application.', + // fix: 'The commit fixes a defect in the application.', + build: 'alters the build system or external dependencies of the product', + chore: 'includes a technical or preventative maintenance task', + ci: 'continuous integration or continuous delivery scripts or configuration files', + deprecate: 'deprecates existing functionality', + docs: 'changes to README files and markdown (*.md) files', + perf: 'improve the performance of algorithms or general execution', + remove: 'removes a feature or dependency', + refactor: 'code refactoring', + revert: 'reverts one or more commits', + security: 'improves security', + style: 'updates or reformats the style of the source code', + test: 'changes to the suite of automated tests', + change: 'changes the implementation of an existing feature', + }; + + let conventionalCommitDescription = ''; + // eslint-disable-next-line guard-for-in + for (const key in conventionalCommitTypes) { + const value = conventionalCommitTypes[key]; + conventionalCommitDescription += `${key}: ${value}\n`; } - return JSON.parse(data) as CreateChatCompletionResponse; + return `Choose the primary used conventional commit type from the list below based on the git diff:\n${conventionalCommitDescription}`; }; -const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1'); - -const deduplicateMessages = (array: string[]) => Array.from(new Set(array)); +const getExtraContextGitmoji = async () => { + try { + const gitmojis = await retrieveGitmojis(); + let gitmojiDescriptions = ''; + for (const gitmoji of gitmojis) { + gitmojiDescriptions += `${gitmoji.emoji}: ${gitmoji.description}\n`; + } -const getPrompt = (locale: string, diff: string) => `Write an insightful but concise Git commit message in a complete sentence in present tense for the following diff without prefacing it with anything, the response must be in the language ${locale}:\n${diff}`; + return `Choose a gitmoji from the list below based on the conventional commit scope and git diff:\n${gitmojiDescriptions}`; + } catch { + throw new KnownError('Error connecting to the Gitmoji API. Are you connected to the internet?'); + } +}; const model = 'gpt-3.5-turbo'; -// TODO: update for the new gpt-3.5 model -const encoder = encodingForModel('text-davinci-003'); export const generateCommitMessage = async ( apiKey: string, locale: string, diff: string, completions: number, -) => { - const prompt = getPrompt(locale, diff); - - /** - * text-davinci-003 has a token limit of 4000 - * https://platform.openai.com/docs/models/overview#:~:text=to%20Sep%202021-,text%2Ddavinci%2D003,-Can%20do%20any - */ - if (encoder.encode(prompt).length > 4000) { - throw new KnownError('The diff is too large for the OpenAI API. Try reducing the number of staged changes, or write your own commit message.'); - } + useConventionalCommits: boolean, + useGitmoji: boolean, +): Promise => { + const basePrompt = getBasePrompt(locale); + const commitMessageFormatPrompt = getCommitMessageFormatPrompt( + useConventionalCommits, + useGitmoji, + ); + + const systemPrompt = `${basePrompt} ${commitMessageFormatPrompt}`; + + const outputFormat = getOutputFormat(locale); + const commitMessageExtraContext = getCommitMessageExtraContext(locale); + const conventionalCommitsExtraContext = useConventionalCommits + ? getExtraContextForConventionalCommits() + : ''; + + const gitMojiExtraContext = useGitmoji ? await getExtraContextGitmoji() : ''; + + const completionMessages: ChatCompletionRequestMessage[] = [ + { + role: 'system', + content: systemPrompt, + }, + { + role: 'assistant', + content: commitMessageExtraContext, + }, + { + role: 'assistant', + content: conventionalCommitsExtraContext, + }, + { + role: 'assistant', + content: gitMojiExtraContext, + }, + { + role: 'assistant', + content: outputFormat, + }, + { + role: 'user', + content: diff, + }, + ]; + + const configuration = new Configuration({ + apiKey, + }); + + const openai = new OpenAIApi(configuration); try { - const completion = await createChatCompletion(apiKey, { + const completion = await openai.createChatCompletion({ model, - messages: [{ - role: 'user', - content: prompt, - }], + messages: completionMessages, temperature: 0.7, top_p: 1, frequency_penalty: 0, presence_penalty: 0, - max_tokens: 200, stream: false, n: completions, }); - return deduplicateMessages( - completion.choices - .filter(choice => choice.message?.content) - .map(choice => sanitizeMessage(choice.message!.content)), - ); - } catch (error) { - const errorAsAny = error as any; - if (errorAsAny.code === 'ENOTFOUND') { - throw new KnownError(`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`); + const commitMessages: CommitMessage[] = completion.data.choices + .filter(choice => choice.message?.content) + .map(choice => JSON.parse(choice.message!.content)); + + return deduplicateMessages(commitMessages); + } catch (error: any) { + if (error.code === 'ENOTFOUND') { + throw new KnownError(`Error connecting to ${error.hostname} (${error.syscall}). Are you connected to the internet?`); + } + + const statusCode = error?.response?.status; + if (statusCode === 400) { + throw new KnownError( + 'The diff is too large for the OpenAI API. Try reducing the number of staged changes, or write your own commit message.', + ); } - throw errorAsAny; + if (statusCode === 401) { + throw new KnownError('Unauthorized: The OPENAI_KEY that you configured is invalid.'); + } } + + throw new Error('An unknown error occured, Please try again.'); }; diff --git a/tests/index.ts b/tests/index.ts index d5164092..11497373 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -1,6 +1,7 @@ import { describe } from 'manten'; describe('aicommits', ({ runTestSuite }) => { - runTestSuite(import('./specs/cli.js')); - runTestSuite(import('./specs/config.js')); + runTestSuite(import('./specs/openai.spec.js')); + runTestSuite(import('./specs/cli.spec.js')); + runTestSuite(import('./specs/config.spec.js')); }); diff --git a/tests/specs/cli.spec.ts b/tests/specs/cli.spec.ts new file mode 100644 index 00000000..22eb3d80 --- /dev/null +++ b/tests/specs/cli.spec.ts @@ -0,0 +1,314 @@ +import { createFixture, FsFixture } from 'fs-fixture'; +import { ExecaChildProcess, Options } from 'execa'; +import { describe, it, beforeAll } from 'vitest'; +import { createAicommits, createGit } from '../utils.js'; + +const { OPENAI_KEY } = process.env; + +type GitType = (command: string, args?: string[] | undefined, + options?: Options | undefined) => ExecaChildProcess; + +describe('cli', () => { + beforeAll(async () => { + if (process.platform === 'win32') { + // https://github.com/nodejs/node/issues/31409 + console.warn('Skipping tests on Windows because Node.js spawn cant open TTYs'); + return; + } + + if (!OPENAI_KEY) { + console.warn('⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...'); + } + }); + + async function createAiCommitsFixture(fixture: FsFixture) + : Promise> { + const aicommits = createAicommits({ + cwd: fixture.path, + home: fixture.path, + }); + + await aicommits(['config', 'set', `OPENAI_KEY=${OPENAI_KEY}`]); + + return aicommits; + } + + it.concurrent('Fails on non-Git project', async ({ expect }) => { + const fixture = await createFixture(); + + const aicommits = await createAiCommitsFixture(fixture); + + const { stdout, exitCode } = await (aicommits([], { reject: false })); + expect(exitCode).toBe(1); + expect(stdout).toMatch('The current directory must be a Git repository!'); + }); + + it.concurrent('Fails on no staged files', async ({ expect }) => { + const fixture = await createFixture(); + + await createGit(fixture.path); + + const aicommits = await createAiCommitsFixture(fixture); + + const { stdout, exitCode } = (await aicommits([], { reject: false })); + expect(exitCode).toBe(1); + expect(stdout).toMatch('No staged changes found. Make sure to stage your changes with `git add`.'); + }); + + it.concurrent('Generates default commit message', async ({ expect }) => { + const data: Record = { + firstName: 'Hiroki', + }; + + const fixture = await createFixture({ + 'data.json': JSON.stringify(data), + }); + + const git = await createGit(fixture.path); + await git('add', ['data.json']); + + expect(await getGitStatus(git)).toBe('A data.json'); + + const aicommits = await createAiCommitsFixture(fixture); + const committing = aicommits(); + selectYesOptionAICommit(committing); + await committing; + + expect(await getGitStatus(git)).toBe(''); + + const { stdout } = await git('log', ['--oneline']); + console.log('Committed with:', stdout); + + // Default commit message should not include conventional commit prefix + expect(stdout).not.toMatch(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):/); + }); + + it.concurrent('Accepts --generate flag, overriding config', async ({ expect }) => { + const data: Record = { + firstName: 'Hiroki', + lastName: 'Osame', + moreChanges: 'Adds more changes to the mix', + }; + + const fixture = await createFixture({ + 'data.json': JSON.stringify(data), + 'data2.json': JSON.stringify(data), + }); + + const git = await createGit(fixture.path); + await git('add', ['data.json', 'data2.json']); + + expect(await getGitStatus(git)).toContain('A data.json'); + expect(await getGitStatus(git)).toContain('A data2.json'); + + // Generate flag should override generate config + const aicommits = await createAiCommitsFixture(fixture); + await aicommits(['config', 'set', 'generate=4']); + + const committing = aicommits(['--generate', '2']); + assertAmountOfChoices(committing, 2, expect); + selectFirstAICommitFromChoices(committing); + await committing; + + expect(await getGitStatus(git)).toBe(''); + + const { stdout } = await git('log', ['--oneline']); + console.log('Committed with:', stdout); + }); + + it.concurrent('Generates Japanese commit message via locale config', async ({ expect }) => { + const data: Record = { + username: 'privatenumber', + }; + + const fixture = await createFixture({ + 'data.json': JSON.stringify(data), + }); + + const git = await createGit(fixture.path); + await git('add', ['data.json']); + + expect(await getGitStatus(git)).toBe('A data.json'); + + const aicommits = await createAiCommitsFixture(fixture); + await aicommits(['config', 'set', 'locale=ja']); + + const committing = aicommits(); + selectYesOptionAICommit(committing); + await committing; + + expect(await getGitStatus(git)).toBe(''); + + const { stdout } = await git('log', ['--oneline']); + console.log('Committed with:', stdout); + + const japanesePattern = /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/; + expect(stdout).toMatch(japanesePattern); + }); + + it.concurrent('Should not translate conventional commit to locale (Japanese)', async ({ expect }) => { + const data: Record = { + username: 'privatenumber', + }; + + const fixture = await createFixture({ + 'data.json': JSON.stringify(data), + }); + + const git = await createGit(fixture.path); + await git('add', ['data.json']); + + expect(await getGitStatus(git)).toBe('A data.json'); + + const aicommits = await createAiCommitsFixture(fixture); + await aicommits(['config', 'set', 'conventional=true']); + await aicommits(['config', 'set', 'locale=ja']); + + const committing = aicommits(); + selectYesOptionAICommit(committing); + await committing; + + expect(await getGitStatus(git)).toBe(''); + + const { stdout } = await git('log', ['--oneline']); + console.log('Committed with:', stdout); + + const japanesePattern = /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/; + expect(stdout).toMatch(japanesePattern); + expect(stdout).toMatch(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):/); + }); + + it.concurrent('Generates convential commit message', async ({ expect }) => { + const data: Record = { + firstName: 'Hiroki', + }; + + const fixture = await createFixture({ + 'data.json': JSON.stringify(data), + }); + + const git = await createGit(fixture.path); + await git('add', ['data.json']); + + expect(await getGitStatus(git)).toBe('A data.json'); + + const aicommits = await createAiCommitsFixture(fixture); + await aicommits(['config', 'set', 'conventional=true']); + + const committing = aicommits(); + selectYesOptionAICommit(committing); + await committing; + + expect(await getGitStatus(git)).toBe(''); + + const { stdout } = await git('log', ['--oneline']); + console.log('Committed with:', stdout); + + // Regex should not match conventional commit messages + expect(stdout).toMatch(/(feat):/); + }); + + it.concurrent('Generates message with gitmoji', async ({ expect }) => { + const fixture = await createFixture({ + 'README.md': '', + }); + + const git = await createGit(fixture.path); + await git('add', ['README.md']); + + await git('commit', ['-m', 'Initial commit']); + + await fixture.rm('README.md'); + + await git('add', ['.']); + + expect(await getGitStatus(git)).toBe('D README.md'); + + const aicommits = await createAiCommitsFixture(fixture); + await aicommits(['config', 'set', 'gitmoji=true']); + + const committing = aicommits(); + selectYesOptionAICommit(committing); + await committing; + + expect(await getGitStatus(git)).toBe(''); + + const { stdout } = await git('log', ['--oneline']); + console.log('Committed with:', stdout); + + // Regex should not match conventional commit messages + expect(stdout).toMatch(/(🔥|🗑️)/); + }); + + it.concurrent('Generates message with conventional commits & gitmoji', async ({ expect }) => { + const fixture = await createFixture({ + 'README.md': '', + }); + + const git = await createGit(fixture.path); + await git('add', ['README.md']); + + await git('commit', ['-m', 'Initial commit']); + + await fixture.rm('README.md'); + + await git('add', ['.']); + + expect(await getGitStatus(git)).toBe('D README.md'); + + const aicommits = await createAiCommitsFixture(fixture); + await aicommits(['config', 'set', 'gitmoji=true']); + await aicommits(['config', 'set', 'conventional=true']); + + const committing = aicommits(); + selectYesOptionAICommit(committing); + await committing; + + expect(await getGitStatus(git)).toBe(''); + + const { stdout } = await git('log', ['--oneline']); + console.log('Committed with:', stdout); + + // Regex should not match conventional commit messages + expect(stdout).toMatch(/(feat|remove): (🔥|🗑️) /); + }); + + function selectYesOptionAICommit(committing: ExecaChildProcess) { + committing.stdout!.on('data', (buffer: Buffer) => { + const stdout = buffer.toString(); + if (stdout.match('└')) { + committing.stdin?.write('y'); + committing.stdin?.end(); + } + }); + } + + function assertAmountOfChoices(committing: ExecaChildProcess, amount: number, + expect: any) { + committing.stdout!.on('data', function onPrompt(buffer: Buffer) { + const stdout = buffer.toString(); + if (stdout.match('└')) { + const countChoices = stdout.match(/ {2}[●○]/g)?.length ?? 0; + + // 2 choices should be generated + expect(countChoices).toBe(amount); + committing.stdout?.off('data', onPrompt); + } + }); + } + + function selectFirstAICommitFromChoices(committing: ExecaChildProcess) { + committing.stdout!.on('data', (buffer: Buffer) => { + const stdout = buffer.toString(); + if (stdout.match('└')) { + committing.stdin!.write('\r'); + committing.stdin!.end(); + } + }); + } + + async function getGitStatus(git: GitType): Promise { + const statusBefore = await git('status', ['--porcelain', '--untracked-files=no']); + return statusBefore.stdout; + } +}); diff --git a/tests/specs/cli.ts b/tests/specs/cli.ts deleted file mode 100644 index 3d6dab69..00000000 --- a/tests/specs/cli.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { testSuite, expect } from 'manten'; -import { createFixture } from 'fs-fixture'; -import { createAicommits, createGit } from '../utils.js'; - -const { OPENAI_KEY } = process.env; - -export default testSuite(({ describe }) => { - if (process.platform === 'win32') { - // https://github.com/nodejs/node/issues/31409 - console.warn('Skipping tests on Windows because Node.js spawn cant open TTYs'); - return; - } - - if (!OPENAI_KEY) { - console.warn('⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...'); - return; - } - - describe('CLI', async ({ test }) => { - const data: Record = { - firstName: 'Hiroki', - }; - const fixture = await createFixture({ - 'data.json': JSON.stringify(data), - }); - - const aicommits = createAicommits({ - cwd: fixture.path, - home: fixture.path, - }); - - await test('Fails on non-Git project', async () => { - const { stdout, exitCode } = await aicommits([], { reject: false }); - expect(exitCode).toBe(1); - expect(stdout).toMatch('The current directory must be a Git repository!'); - }); - - const git = await createGit(fixture.path); - - await test('Fails on no staged files', async () => { - const { stdout, exitCode } = await aicommits([], { reject: false }); - expect(exitCode).toBe(1); - expect(stdout).toMatch('No staged changes found. Make sure to stage your changes with `git add`.'); - }); - - await test('Generates commit message', async () => { - await git('add', ['data.json']); - - await aicommits([ - 'config', - 'set', - `OPENAI_KEY=${OPENAI_KEY}`, - ]); - - const statusBefore = await git('status', ['--porcelain', '--untracked-files=no']); - expect(statusBefore.stdout).toBe('A data.json'); - - const committing = aicommits(); - committing.stdout!.on('data', (buffer: Buffer) => { - const stdout = buffer.toString(); - if (stdout.match('└')) { - committing.stdin!.write('y'); - committing.stdin!.end(); - } - }); - - await committing; - - const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); - expect(statusAfter.stdout).toBe(''); - - const { stdout } = await git('log', ['--oneline']); - console.log('Committed with:', stdout); - }); - - await test('Accepts --generate flag, overriding config', async () => { - data.lastName = 'Osame'; - await fixture.writeJson('data.json', data); - - await git('add', ['data.json']); - - const statusBefore = await git('status', ['--porcelain', '--untracked-files=no']); - expect(statusBefore.stdout).toBe('M data.json'); - - await aicommits([ - 'config', - 'set', - 'generate=4', - ]); - - // Generate flag should override generate config - const committing = aicommits(['--generate', '2']); - - committing.stdout!.on('data', function onPrompt(buffer: Buffer) { - const stdout = buffer.toString(); - if (stdout.match('└')) { - const countChoices = stdout.match(/ {2}[●○]/g)?.length ?? 0; - - // 2 choices should be generated - expect(countChoices).toBe(2); - - committing.stdin!.write('\r'); - committing.stdin!.end(); - committing.stdout?.off('data', onPrompt); - } - }); - - await committing; - - const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); - expect(statusAfter.stdout).toBe(''); - - const { stdout } = await git('log', ['--oneline']); - console.log('Committed with:', stdout); - - await aicommits([ - 'config', - 'set', - 'generate=1', - ]); - }); - - await test('Generates Japanese commit message via locale config', async () => { - // https://stackoverflow.com/a/15034560/911407 - const japanesePattern = /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/; - - data.username = 'privatenumber'; - await fixture.writeJson('data.json', data); - - await git('add', ['data.json']); - - const statusBefore = await git('status', ['--porcelain', '--untracked-files=no']); - expect(statusBefore.stdout).toBe('M data.json'); - - await aicommits([ - 'config', - 'set', - 'locale=ja', - ]); - - // Generate flag should override generate config - const committing = aicommits(['--generate', '1']); - - committing.stdout!.on('data', (buffer: Buffer) => { - const stdout = buffer.toString(); - if (stdout.match('└')) { - committing.stdin!.write('y'); - committing.stdin!.end(); - } - }); - - await committing; - - const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); - expect(statusAfter.stdout).toBe(''); - - const { stdout } = await git('log', ['--oneline']); - console.log('Committed with:', stdout); - - expect(stdout).toMatch(japanesePattern); - }); - - await fixture.rm(); - }); -}); diff --git a/tests/specs/config.spec.ts b/tests/specs/config.spec.ts new file mode 100644 index 00000000..d78cb1d2 --- /dev/null +++ b/tests/specs/config.spec.ts @@ -0,0 +1,94 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { createFixture } from 'fs-fixture'; +import { + describe, it, beforeAll, +} from 'vitest'; +import { createAicommits } from '../utils.js'; + +const { OPENAI_KEY } = process.env; + +describe('config', () => { + beforeAll(async () => { + if (!OPENAI_KEY) { + console.warn( + '⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...', + ); + } + }); + + it.concurrent('set unknown config file', async ({ expect }) => { + const fixture = await createFixture(); + const aicommits = createAicommits({ + home: fixture.path, + }); + + const { stderr } = await aicommits(['config', 'set', 'UNKNOWN=1'], { + reject: false, + }); + + expect(stderr).toMatch('Invalid config property: UNKNOWN'); + }); + + it.concurrent('set invalid OPENAI_KEY', async ({ expect }) => { + const fixture = await createFixture(); + const aicommits = createAicommits({ + home: fixture.path, + }); + + const { stderr } = await aicommits(['config', 'set', 'OPENAI_KEY=abc'], { + reject: false, + }); + + expect(stderr).toMatch( + 'Invalid config property OPENAI_KEY: Must start with "sk-"', + ); + }); + + it.concurrent('set config file', async ({ expect }) => { + const fixture = await createFixture(); + const aicommits = createAicommits({ + home: fixture.path, + }); + const configPath = path.join(fixture.path, '.aicommits'); + + const config = `OPENAI_KEY=${OPENAI_KEY}`; + await aicommits(['config', 'set', config]); + + const configFile = await fs.readFile(configPath, 'utf8'); + expect(configFile).toMatch(config); + }); + + it.concurrent('get config file', async ({ expect }) => { + const fixture = await createFixture(); + const aicommits = createAicommits({ + home: fixture.path, + }); + + const config = `OPENAI_KEY=${OPENAI_KEY}`; + + await aicommits(['config', 'set', config]); + const { stdout } = await aicommits(['config', 'get', 'OPENAI_KEY']); + expect(stdout).toBe(config); + }); + + it.concurrent('reading unknown config', async ({ expect }) => { + const fixture = await createFixture(); + const aicommits = createAicommits({ + home: fixture.path, + }); + const configPath = path.join(fixture.path, '.aicommits'); + + const config = `OPENAI_KEY=${OPENAI_KEY}`; + await aicommits(['config', 'set', config]); + + await fs.appendFile(configPath, 'UNKNOWN=1'); + + const { stdout, stderr } = await aicommits(['config', 'get', 'UNKNOWN'], { + reject: false, + }); + + expect(stdout).toBe(''); + expect(stderr).toBe(''); + }); +}); diff --git a/tests/specs/config.ts b/tests/specs/config.ts deleted file mode 100644 index 1e04c03b..00000000 --- a/tests/specs/config.ts +++ /dev/null @@ -1,58 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import { testSuite, expect } from 'manten'; -import { createFixture } from 'fs-fixture'; -import { createAicommits } from '../utils.js'; - -export default testSuite(({ describe }) => { - describe('config', async ({ test }) => { - const fixture = await createFixture(); - const aicommits = createAicommits({ - home: fixture.path, - }); - const configPath = path.join(fixture.path, '.aicommits'); - const openAiToken = 'OPENAI_KEY=sk-abc'; - - test('set unknown config file', async () => { - const { stderr } = await aicommits(['config', 'set', 'UNKNOWN=1'], { - reject: false, - }); - - expect(stderr).toMatch('Invalid config property: UNKNOWN'); - }); - - test('set invalid OPENAI_KEY', async () => { - const { stderr } = await aicommits(['config', 'set', 'OPENAI_KEY=abc'], { - reject: false, - }); - - expect(stderr).toMatch('Invalid config property OPENAI_KEY: Must start with "sk-"'); - }); - - await test('set config file', async () => { - await aicommits(['config', 'set', openAiToken]); - - const configFile = await fs.readFile(configPath, 'utf8'); - expect(configFile).toMatch(openAiToken); - }); - - await test('get config file', async () => { - const { stdout } = await aicommits(['config', 'get', 'OPENAI_KEY']); - - expect(stdout).toBe(openAiToken); - }); - - await test('reading unknown config', async () => { - await fs.appendFile(configPath, 'UNKNOWN=1'); - - const { stdout, stderr } = await aicommits(['config', 'get', 'UNKNOWN'], { - reject: false, - }); - - expect(stdout).toBe(''); - expect(stderr).toBe(''); - }); - - await fixture.rm(); - }); -}); diff --git a/tests/specs/diffs/README.md b/tests/specs/diffs/README.md new file mode 100644 index 00000000..cedac647 --- /dev/null +++ b/tests/specs/diffs/README.md @@ -0,0 +1,11 @@ +# Generating diffs + +1. Instruct ChatGPT with the following command: +``` +I want you to act as a git cli +I will give you the type of content and you will generate a random git diff based on that +``` + +2. Insert the type of change + +ChatGPT will generate a fictional git diff based on the type of change you inserted. diff --git a/tests/specs/diffs/documentation-changes.txt b/tests/specs/diffs/documentation-changes.txt new file mode 100644 index 00000000..b9bbff45 --- /dev/null +++ b/tests/specs/diffs/documentation-changes.txt @@ -0,0 +1,30 @@ +diff --git a/README.md b/README.md +index a0c3e1b..9d1b6f8 100644 +--- a/README.md ++++ b/README.md +@@ -1,6 +1,11 @@ + # My Awesome Project + ++## Overview ++ ++My Awesome Project is a web application that allows users to manage their tasks and projects in a simple and intuitive way. The project is built with React and Node.js and uses MongoDB for data storage. ++ + ## Installation + ++To install and run My Awesome Project, follow these steps: ++ + 1. Clone the repository: `git clone https://github.com/username/my-awesome-project.git` + 2. Install dependencies: `npm install` + 3. Start the development server: `npm start` +@@ -13,6 +18,11 @@ To install and run My Awesome Project, follow these steps: + ## Usage + + To use My Awesome Project, follow these steps: ++ ++1. Open your web browser and navigate to `http://localhost:3000` ++2. Sign up for a new account or log in to an existing one ++3. Create a new task or project and start managing your work! ++ + ## Contributing + + We welcome contributions from anyone and everyone. To contribute to My Awesome Project, follow these steps: diff --git a/tests/specs/diffs/fix-nullpointer-exception.txt b/tests/specs/diffs/fix-nullpointer-exception.txt new file mode 100644 index 00000000..c81d7b21 --- /dev/null +++ b/tests/specs/diffs/fix-nullpointer-exception.txt @@ -0,0 +1,14 @@ +diff --git a/src/main/java/com/example/MyClass.java b/src/main/java/com/example/MyClass.java +index e7d8f38..caab7f1 100644 +--- a/src/main/java/com/example/MyClass.java ++++ b/src/main/java/com/example/MyClass.java +@@ -23,7 +23,10 @@ public class MyClass { + public void processItems(List items) { + for (Item item : items) { +- if (item.getValue().equalsIgnoreCase("example")) { ++ // Fixing NullPointerException by adding a null check ++ String itemValue = item.getValue(); ++ if (itemValue != null && itemValue.equalsIgnoreCase("example")) { + processExampleItem(item); + } + } diff --git a/tests/specs/diffs/github-action-build-pipeline.txt b/tests/specs/diffs/github-action-build-pipeline.txt new file mode 100644 index 00000000..7010ee10 --- /dev/null +++ b/tests/specs/diffs/github-action-build-pipeline.txt @@ -0,0 +1,21 @@ +diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml +index 1d07d31..085eb64 100644 +--- a/.github/workflows/build.yml ++++ b/.github/workflows/build.yml +@@ -10,6 +10,8 @@ jobs: + - uses: actions/setup-node@v1 + with: + node-version: 12.x ++ - name: Install dependencies ++ run: npm install + - name: Build and test + run: | + npm run build +@@ -22,3 +24,7 @@ jobs: + if: always() + uses: actions/upload-artifact@v1 + with: ++ name: Build artifact ++ path: build ++ - name: Deploy to production ++ uses: some-third-party/deploy-action@v1 diff --git a/tests/specs/diffs/testing-react-application.txt b/tests/specs/diffs/testing-react-application.txt new file mode 100644 index 00000000..79957e79 --- /dev/null +++ b/tests/specs/diffs/testing-react-application.txt @@ -0,0 +1,22 @@ +diff --git a/src/components/MyComponent.test.js b/src/components/MyComponent.test.js +index 37eabf2..976c6bf 100644 +--- a/src/components/MyComponent.test.js ++++ b/src/components/MyComponent.test.js +@@ -10,6 +10,7 @@ describe("MyComponent", () => { + }); + + it("renders the component correctly", () => { ++ const props = { name: "John Doe", age: 25 }; + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); +@@ -25,6 +26,11 @@ describe("MyComponent", () => { + expect(wrapper.find("h1").text()).toEqual("Hello, John Doe!"); + }); + ++ it("displays the correct age", () => { ++ const props = { name: "Jane Doe", age: 30 }; ++ const wrapper = shallow(); ++ expect(wrapper.find("p").text()).toEqual("Age: 30"); ++ }); + }); diff --git a/tests/specs/openai.spec.ts b/tests/specs/openai.spec.ts new file mode 100644 index 00000000..7f77d1fe --- /dev/null +++ b/tests/specs/openai.spec.ts @@ -0,0 +1,95 @@ +import { readFile } from 'fs/promises'; +// eslint-disable-next-line unicorn/import-style +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; +// import { Configuration, OpenAIApi } from 'openai'; +import { describe, it, beforeEach } from 'vitest'; +import { + CommitMessage, + generateCommitMessage, +} from '../../src/utils/openai.js'; + +const { OPENAI_KEY } = process.env; + +// The two tests marked with concurrent will be run in parallel +describe('openai', () => { + beforeEach(() => { + if (!OPENAI_KEY) { + console.warn( + '⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...', + ); + } + }); + + it.concurrent( + 'Should use "test:" conventional commit when change relate to testing a React application', + async ({ expect }) => { + const gitDiff = await readDiffFromFile('testing-react-application.txt'); + + const commitMessage = await runGenerateCommitMessage(gitDiff); + + expect(commitMessage.title).toMatch(/(test(\(.*\))?):/); + }, + ); + + it.concurrent( + 'Should use "build:" conventional commit when change relate to github action build pipeline', + async ({ expect }) => { + const gitDiff = await readDiffFromFile( + 'github-action-build-pipeline.txt', + ); + + const commitMessage = await runGenerateCommitMessage(gitDiff); + + expect(commitMessage.title).toMatch(/(build):/); + }, + ); + + it.concurrent( + 'Should use "docs:" conventional commit when change relate to documentation changes', + async ({ expect }) => { + const gitDiff = await readDiffFromFile('documentation-changes.txt'); + const commitMessage = await runGenerateCommitMessage(gitDiff); + + expect(commitMessage.title).toMatch(/(docs):/); + }, + ); + + it.concurrent( + 'Should use "(fix|change):" conventional commit when change relate to fixing code', + async ({ expect }) => { + const gitDiff = await readDiffFromFile('fix-nullpointer-exception.txt'); + const commitMessage = await runGenerateCommitMessage(gitDiff); + + expect(commitMessage.title).toMatch(/(fix):/); + }, + ); + + async function runGenerateCommitMessage( + gitDiff: string, + ): Promise { + const commitMessages = await generateCommitMessage( + OPENAI_KEY!, + 'en', + gitDiff, + 1, + true, + false, + ); + + return commitMessages[0]; + } + + /* + * See ./README.md in order to generate diff files + */ + async function readDiffFromFile(filename: string): Promise { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const gitDiff = await readFile( + path.resolve(__dirname, `./diffs/${filename}`), + 'utf8', + ); + return gitDiff; + } +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..93a4ce64 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 20_000, + }, +});