├── .prettierignore ├── example ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── src │ ├── App.js │ ├── setupTests.js │ ├── components │ │ ├── message.js │ │ ├── __test__ │ │ │ ├── chat-container.test.js │ │ │ ├── message.test.js │ │ │ └── chat-input.test.js │ │ ├── chat-input.js │ │ └── chat-container.js │ ├── App.test.js │ ├── utils │ │ ├── __test__ │ │ │ ├── three-sum.test.ts │ │ │ ├── pagination.test.ts │ │ │ └── filter-table-base.test.ts │ │ ├── tow-sum.ts │ │ ├── bug-palindrome.ts │ │ ├── three-sum.ts │ │ ├── longest-palindrome.ts │ │ ├── longest-palindromes.ts │ │ ├── pagination.ts │ │ └── filter-table-base.ts │ ├── index.css │ ├── reportWebVitals.js │ ├── index.js │ ├── App.css │ └── logo.svg ├── i18n │ ├── test.json │ └── test.js ├── package.json └── README.md ├── src ├── webhook │ ├── constant.ts │ └── index.ts ├── huskygpt │ ├── index.ts │ ├── base.ts │ ├── modify.ts │ ├── create.ts │ ├── review.ts │ ├── translate.ts │ └── test.ts ├── utils │ ├── read-prompt-file.ts │ ├── simply-result.ts │ ├── __tests__ │ │ ├── extract-code-prompts.test.ts │ │ ├── __mock__ │ │ │ └── test-data.ts │ │ └── write-conflict.test.ts │ ├── index.ts │ ├── extract-code-prompts.ts │ ├── code-context.ts │ ├── write-conflict.ts │ └── extract-modify-funcs.ts ├── create │ ├── constant.ts │ ├── index.ts │ └── code-generator.ts ├── reader │ ├── reader-directory.ts │ ├── reader-git-stage.ts │ └── index.ts ├── index.ts ├── chatgpt │ ├── send-message.ts │ ├── prompt.ts │ ├── __tests__ │ │ └── send-message.test.ts │ └── index.ts ├── types.ts ├── modify │ └── index.ts └── constant.ts ├── prompt ├── modify.txt ├── create-components.txt ├── create-pages.txt ├── create-sections.txt ├── create-services.txt ├── tests_v1.txt ├── create-mock.txt ├── translate.txt ├── create.txt ├── create-models.txt ├── review.txt ├── review_v1.txt └── tests.txt ├── tsup.config.ts ├── jest.config.ts ├── .gitignore ├── .npmignore ├── .prettierrc.cjs ├── tsconfig.json ├── package.json ├── .env ├── bin └── cli.js └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | .snapshots/ 2 | build/ 3 | dist/ 4 | node_modules/ 5 | .next/ 6 | .vercel/ 7 | third-party/ -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luffy-xu/huskygpt/HEAD/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luffy-xu/huskygpt/HEAD/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luffy-xu/huskygpt/HEAD/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/webhook/constant.ts: -------------------------------------------------------------------------------- 1 | export interface ISeatalkNoticeOptions { 2 | channel?: string; 3 | userEmail?: string; 4 | } 5 | 6 | export interface INoticeTask { 7 | message: string; 8 | filePath: string; 9 | } 10 | -------------------------------------------------------------------------------- /prompt/modify.txt: -------------------------------------------------------------------------------- 1 | As an expert programmer, your task is to modify code by my requirements that I will provided to you. 2 | Note that you only need to reply the code in single markdown code block without any additional explanation. 3 | -------------------------------------------------------------------------------- /prompt/create-components.txt: -------------------------------------------------------------------------------- 1 | Here is a simple example of how to write a "components": 2 | 3 | const Footer: React.FC = () => { 4 | return ( 5 | 8 | ); 9 | }; 10 | 11 | export default Footer; 12 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import ChatContainer from './components/chat-container'; 3 | 4 | function App() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/huskygpt/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HuskyGPTTest } from './test'; 2 | export { default as HuskyGPTReview } from './review'; 3 | export { default as HuskyGPTCreate } from './create'; 4 | export { default as HuskyGPTModify } from './modify'; 5 | export { default as HuskyGPTTranslate } from './translate'; 6 | -------------------------------------------------------------------------------- /prompt/create-pages.txt: -------------------------------------------------------------------------------- 1 | Here is a simple example of how to write a "pages": 2 | import {{modelName}} from './{{modelName}}'; 3 | 4 | const Welcome: React.FC = () => { 5 | return ( 6 | 7 | {{"sections" code from {{modelName}}}} 8 | 9 | ); 10 | }; 11 | 12 | export default Welcome; 13 | -------------------------------------------------------------------------------- /example/src/components/message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Message = ({ message, author }) => ( 4 |
8 |

{message}

9 |
10 | ); 11 | 12 | export default Message; 13 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ['src/index.ts'], 6 | outDir: 'build', 7 | target: 'node14', 8 | platform: 'node', 9 | format: ['esm'], 10 | splitting: false, 11 | sourcemap: true, 12 | minify: false, 13 | shims: true, 14 | dts: true 15 | } 16 | ]) 17 | -------------------------------------------------------------------------------- /example/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | describe('App', () => { 6 | it('should render the chat interface', () => { 7 | render(); 8 | const chatContainer = screen.getByTestId('chat-container'); 9 | expect(chatContainer).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | verbose: true, 5 | preset: 'ts-jest', 6 | moduleNameMapper: { 7 | '^src/(.*)$': '/src/$1', 8 | }, 9 | // only src files are tested 10 | testMatch: ['/src/**/*.test.ts'], 11 | maxWorkers: 1, 12 | rootDir: './', 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /example/src/utils/__test__/three-sum.test.ts: -------------------------------------------------------------------------------- 1 | import threeSum from '../three-sum'; 2 | 3 | describe('threeSum', () => { 4 | it('should return the correct result for a given array of numbers', () => { 5 | const nums = [-1, 0, 1, 2, -1, -4]; 6 | const expectedResult = [ 7 | [-1, -1, 2], 8 | [-1, 0, 1], 9 | ]; 10 | expect(threeSum(nums)).toEqual(expectedResult); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /prompt/create-sections.txt: -------------------------------------------------------------------------------- 1 | Here is a simple example of how to write a "sections": 2 | import { useModel } from 'umi'; 3 | 4 | const {{ModelName}}: React.FC = () => { 5 | const {{#if need call model, import api here}} = useModel({{model file name under the "models"}}); 6 | 7 | return
user
8 | }; 9 | 10 | export default {{ModelName}}; 11 | 12 | Please note that "sections" is a module of "pages" 13 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = (onPerfEntry) => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /example/src/utils/tow-sum.ts: -------------------------------------------------------------------------------- 1 | // tow sum 2 | // https://leetcode.com/problems/two-sum/description/ 3 | function towSum(nums: number[], target: number): number[] { 4 | const map = new Map(); 5 | for (let i = 0; i < nums.length; i++) { 6 | const num = nums[i]; 7 | const diff = target - num; 8 | if (map.has(diff)) { 9 | return [map.get(diff), i]; 10 | } 11 | map.set(num, i); 12 | } 13 | return []; 14 | } 15 | 16 | export default towSum; 17 | -------------------------------------------------------------------------------- /src/huskygpt/base.ts: -------------------------------------------------------------------------------- 1 | import { ChatgptProxyAPI } from 'src/chatgpt'; 2 | import { IReadFileResult } from 'src/types'; 3 | 4 | /** 5 | * Base class for HuskyGPT 6 | */ 7 | abstract class HuskyGPTBase { 8 | public openai: ChatgptProxyAPI; 9 | 10 | constructor() { 11 | // Create a new OpenAI API client 12 | this.openai = new ChatgptProxyAPI(); 13 | } 14 | 15 | abstract run(fileResult: IReadFileResult): Promise; 16 | } 17 | 18 | export default HuskyGPTBase; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # review 4 | .huskygpt_review.md 5 | 6 | # dependencies 7 | node_modules 8 | /.pnp 9 | .pnp.js 10 | .husky 11 | 12 | # testing 13 | /coverage 14 | 15 | # production 16 | build 17 | lib/ 18 | 19 | # misc 20 | .vscode 21 | .DS_Store 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore the test directory and its contents 2 | /test 3 | 4 | # Ignore the docs directory and its contents 5 | /docs 6 | 7 | # Ignore the config file 8 | config.json 9 | .gitignore 10 | .env 11 | .env.local 12 | .env.development 13 | .prettierrc.* 14 | .eslintrc.* 15 | tsconfig.json 16 | tsup.config.* 17 | .husky 18 | 19 | # Ignore example files 20 | /example 21 | 22 | # Ignore the package-lock.json file 23 | package-lock.json 24 | 25 | # Ignore the .DS_Store file on macOS 26 | .DS_Store -------------------------------------------------------------------------------- /prompt/create-services.txt: -------------------------------------------------------------------------------- 1 | Here is a simple example of how to write a "services": 2 | import { request } from '@umijs/max'; 3 | 4 | export async function {{apiName}}(body: API.LoginParams, options?: { [key: string]: any }): Promise> { 5 | return request('/api/login/account', { 6 | method: 'POST', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | data: body, 11 | ...(options || {}), 12 | }); 13 | } 14 | 15 | {{More api methods}} 16 | -------------------------------------------------------------------------------- /src/utils/read-prompt-file.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { ROOT_SRC_DIR_PATH } from 'src/constant'; 4 | 5 | export const readPromptFile = (fileName: string): string => { 6 | const userLocalPath = path.join(process.cwd(), 'prompt', fileName); 7 | if (fs.existsSync(userLocalPath)) { 8 | return fs.readFileSync(userLocalPath, 'utf-8'); 9 | } 10 | 11 | return fs.readFileSync( 12 | path.join(ROOT_SRC_DIR_PATH, 'prompt', fileName), 13 | 'utf-8', 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('@trivago/prettier-plugin-sort-imports')], 3 | singleQuote: true, 4 | jsxSingleQuote: true, 5 | semi: true, 6 | useTabs: false, 7 | tabWidth: 2, 8 | bracketSpacing: true, 9 | bracketSameLine: false, 10 | arrowParens: 'always', 11 | trailingComma: 'all', 12 | importOrder: ['^node:.*', '', '^[./]'], 13 | importOrderSeparation: true, 14 | importOrderSortSpecifiers: true, 15 | importOrderGroupNamespaceSpecifiers: true, 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "baseUrl": ".", 15 | "outDir": "build", 16 | "noEmit": true 17 | }, 18 | "exclude": ["node_modules", "build"], 19 | "include": ["**/*.ts", "bin/cli.js", "bin/cli.js"] 20 | } 21 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/i18n/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "app": { 4 | "welcome": "Welcome to my app", 5 | "login": "Log In", 6 | "logout": "Log Out", 7 | "menu": { 8 | "home": "Home", 9 | "about": "About Us", 10 | "contact": "Contact Us" 11 | } 12 | } 13 | }, 14 | "fr": { 15 | "app": { 16 | "welcome": "Bienvenue sur mon application", 17 | "login": "Se connecter", 18 | "logout": "Se déconnecter", 19 | "menu": { 20 | "home": "Accueil", 21 | "about": "À propos de nous", 22 | "contact": "Nous contacter" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/src/components/__test__/chat-container.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import ChatContainer from '../chat-container'; 4 | 5 | describe('ChatContainer', () => { 6 | it('should add a message when the user submits a message', () => { 7 | render(); 8 | const input = screen.getByTestId('chat-input'); 9 | const submitButton = screen.getByText('Send'); 10 | fireEvent.change(input, { target: { value: 'Hello, world!' } }); 11 | fireEvent.click(submitButton); 12 | expect(screen.getByText('Hello, world!')).toBeInTheDocument(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/src/components/chat-input.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | const ChatInput = ({ onSubmit }) => { 4 | const [inputValue, setInputValue] = useState(''); 5 | 6 | const handleSubmit = (e) => { 7 | e.preventDefault(); 8 | if (inputValue.trim()) { 9 | onSubmit(inputValue); 10 | setInputValue(''); 11 | } 12 | }; 13 | 14 | return ( 15 |
16 | setInputValue(e.target.value)} 21 | placeholder="Type your message..." 22 | /> 23 | 24 |
25 | ); 26 | }; 27 | 28 | export default ChatInput; 29 | -------------------------------------------------------------------------------- /example/src/utils/bug-palindrome.ts: -------------------------------------------------------------------------------- 1 | // longest palindromic substring 2 | // https://leetcode.com/problems/longest-palindromic-substring/ 3 | export function longestPalindrome(s: string): string { 4 | let longest = ''; 5 | for (let i = 0; i < s.length; i++) { 6 | const odd = getLongestPalindrome(s, i, i); 7 | const even = getLongestPalindrome(s, i, i); 8 | const longestPalindrome = odd.length > even.length ? odd : even; 9 | longest = 10 | longestPalindrome.length > longest.length ? longestPalindrome : longest; 11 | } 12 | return longest; 13 | } 14 | 15 | export function getLongestPalindrome( 16 | s: string, 17 | left: number, 18 | right: number 19 | ): string { 20 | return s.slice(left, right); 21 | } 22 | 23 | export default longestPalindrome; 24 | -------------------------------------------------------------------------------- /prompt/tests_v1.txt: -------------------------------------------------------------------------------- 1 | I want you to act as a expert programmer, I will type code and you will reply with unit tests code according to these guidelines: 2 | 3 | 1. Reply at least one tests case for each function or class. 4 | 2. I want you to only reply with the unit tests code inside one unique code block not markdown block, and nothing else. Do not write explanations. 5 | 3. Do not reply tests for functions that solely return a value. 6 | 4. Use Jest to reply unit tests if the code syntax is typeScript. 7 | 5. Do not reply import statements or any comments in your unit tests. 8 | 6. When I need to tell you something in english, I will do so by putting text inside curly brackets {like this}. 9 | 7. I want you be rigorous that you should consider more about edge cases and corner cases in your reply unit tests. 10 | -------------------------------------------------------------------------------- /src/huskygpt/modify.ts: -------------------------------------------------------------------------------- 1 | import { codeBlocksRegex } from 'src/constant'; 2 | import { IReadFileResult } from 'src/types'; 3 | import { getAllCodeBlock } from 'src/utils'; 4 | 5 | import HuskyGPTBase from './base'; 6 | 7 | /** 8 | * Translate for a given file path 9 | */ 10 | class HuskyGPTModify extends HuskyGPTBase { 11 | /** 12 | * Generate a test case for a given file 13 | */ 14 | public async run(fileResult: IReadFileResult): Promise { 15 | const message = await this.openai.run(fileResult); 16 | if (!message?.length) return []; 17 | // If the message doesn't contain code blocks, return 18 | if (!codeBlocksRegex.test(message.join(''))) return []; 19 | 20 | const extractTestsCode = message.map((m) => getAllCodeBlock(m)); 21 | 22 | return extractTestsCode; 23 | } 24 | } 25 | 26 | export default HuskyGPTModify; 27 | -------------------------------------------------------------------------------- /src/huskygpt/create.ts: -------------------------------------------------------------------------------- 1 | import { codeBlocksRegex } from 'src/constant'; 2 | import { IReadFileResult } from 'src/types'; 3 | import { getAllCodeBlock } from 'src/utils'; 4 | 5 | import HuskyGPTBase from './base'; 6 | 7 | /** 8 | * Generate a test case for a given file path 9 | */ 10 | class HuskyGPTCreate extends HuskyGPTBase { 11 | /** 12 | * Generate a test case for a given file 13 | */ 14 | public async run(fileResult: IReadFileResult): Promise { 15 | const message = await this.openai.run(fileResult); 16 | if (!message?.length) return []; 17 | // If the message doesn't contain code blocks, return 18 | if (!codeBlocksRegex.test(message.join(''))) return []; 19 | 20 | const extractTestsCode = message.map((m) => getAllCodeBlock(m)); 21 | 22 | return extractTestsCode; 23 | } 24 | } 25 | 26 | export default HuskyGPTCreate; 27 | -------------------------------------------------------------------------------- /example/src/utils/three-sum.ts: -------------------------------------------------------------------------------- 1 | // three sum 2 | export default function threeSum(nums: number[]): number[][] { 3 | const result: number[][] = []; 4 | nums.sort((a, b) => a - b); 5 | for (let i = 0; i < nums.length; i++) { 6 | const num = nums[i]; 7 | if (num > 0) break; 8 | if (i > 0 && num === nums[i - 1]) continue; 9 | let left = i + 1; 10 | let right = nums.length - 1; 11 | while (left < right) { 12 | const sum = num + nums[left] + nums[right]; 13 | if (sum === 0) { 14 | result.push([num, nums[left], nums[right]]); 15 | while (nums[left] === nums[left + 1]) left++; 16 | while (nums[right] === nums[right - 1]) right--; 17 | left++; 18 | right--; 19 | } else if (sum < 0) { 20 | left++; 21 | } else { 22 | right--; 23 | } 24 | } 25 | } 26 | return result; 27 | } 28 | -------------------------------------------------------------------------------- /example/src/utils/longest-palindrome.ts: -------------------------------------------------------------------------------- 1 | // longest palindromic substring 2 | // https://leetcode.com/problems/longest-palindromic-substring/ 3 | export function longestPalindrome(s: string): string { 4 | let longest = ''; 5 | for (let i = 0; i < s.length; i++) { 6 | const odd = getLongestPalindrome(s, i, i); 7 | const even = getLongestPalindrome(s, i, i + 1); 8 | const longestPalindrome = odd.length > even.length ? odd : even; 9 | longest = 10 | longestPalindrome.length > longest.length ? longestPalindrome : longest; 11 | } 12 | return longest; 13 | } 14 | 15 | export function getLongestPalindrome( 16 | s: string, 17 | left: number, 18 | right: number 19 | ): string { 20 | while (left >= 0 && right < s.length && s[left] === s[right]) { 21 | left--; 22 | right++; 23 | } 24 | return s.slice(left + 1, right); 25 | } 26 | 27 | export default longestPalindrome; 28 | -------------------------------------------------------------------------------- /example/src/utils/longest-palindromes.ts: -------------------------------------------------------------------------------- 1 | // longest palindromic substring 2 | // https://leetcode.com/problems/longest-palindromic-substring/ 3 | export function longestPalindrome(s: string): string { 4 | let longest = ''; 5 | for (let i = 0; i < s.length; i++) { 6 | const odd = getLongestPalindrome(s, i, i); 7 | const even = getLongestPalindrome(s, i, i + 1); 8 | const longestPalindrome = odd.length > even.length ? odd : even; 9 | longest = 10 | longestPalindrome.length > longest.length ? longestPalindrome : longest; 11 | } 12 | return longest; 13 | } 14 | 15 | export function getLongestPalindrome( 16 | s: string, 17 | left: number, 18 | right: number 19 | ): string { 20 | while (left >= 0 && right < s.length && s[left] === s[right]) { 21 | left -= 1; 22 | right++; 23 | } 24 | return s.slice(left + 1, right); 25 | } 26 | 27 | export default longestPalindrome; 28 | -------------------------------------------------------------------------------- /example/src/components/__test__/message.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import Message from '../message'; 4 | 5 | describe('Message', () => { 6 | it('should render a message with the correct author', () => { 7 | render(); 8 | const messageContainer = screen.getByTestId('message-container'); 9 | expect(messageContainer).toHaveClass('user-message'); 10 | expect(messageContainer).toHaveTextContent('Hello, world!'); 11 | }); 12 | 13 | it('should render a bot message with the correct author', () => { 14 | render(); 15 | const messageContainer = screen.getByTestId('message-container'); 16 | expect(messageContainer).toHaveClass('bot-message'); 17 | expect(messageContainer).toHaveTextContent('Hello, world!'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /prompt/create-mock.txt: -------------------------------------------------------------------------------- 1 | 2 | Here is a simple example of how to write a "mock": 3 | import { Request, Response } from 'express'; 4 | 5 | {{here are mock logic functions}} 6 | 7 | {{here are mock api responses}} 8 | export default { 9 | {{#if Complexity case by user requirements}} 10 | 'GET {{api pathname}}': (req: Request, res: Response) => { 11 | if ({{logic condition}}) { 12 | return res.status(401).send({{response json}}); 13 | } 14 | 15 | return res.send({{response json}}); 16 | }, 17 | // Simple case by user requirements 18 | 'GET {{api pathname}}': {{response json, or mock logic function}}, 19 | }; 20 | 21 | Please note that you need send response json by following structure, the "data" field type T I will provide later: 22 | interface ResponseStructure { 23 | success: boolean; 24 | data: T; 25 | errorCode?: number; 26 | errorMessage?: string; 27 | showType?: ErrorShowType; 28 | } 29 | -------------------------------------------------------------------------------- /example/src/components/chat-container.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, memo } from 'react'; 2 | import Message from './message'; 3 | import ChatInput from './chat-input'; 4 | 5 | const MemoizedMessage = memo(Message); 6 | 7 | const ChatContainer = () => { 8 | const [messages, setMessages] = useState([]); 9 | 10 | const addMessage = useCallback( 11 | (message) => { 12 | setMessages((prevMessages) => [ 13 | ...prevMessages, 14 | { message, author: 'user' }, 15 | ]); 16 | }, 17 | [setMessages] 18 | ); 19 | 20 | return ( 21 |
22 |
23 | {messages.map((m, i) => ( 24 | 25 | ))} 26 |
27 | 28 |
29 | ); 30 | }; 31 | 32 | export default ChatContainer; 33 | -------------------------------------------------------------------------------- /prompt/translate.txt: -------------------------------------------------------------------------------- 1 | As a language translate master, your task is provide translate results for the content that I will provide to you. 2 | 3 | - Note you should keep same data structure and syntax with my content and reply in one markdown block without any additional explanation. 4 | - Note if the content is json or js object, you should merge result in one object or json. 5 | For example: if my content is { en: {{content}}, ... }, and target languages is "zh,fr" then you need reply { en: {{content}}, zh: {{translate to zh based on en}}, fr: {{translate to fr based on en}} }. 6 | - Note the language key like "en" in my content is a language key in i18n 7 | - Note that you only need translate string type, no need translate html tags or other type. 8 | - Additional, you should reply "not found translate content" if you can not found any language key in user's content. 9 | 10 | Your goal is to provide translated content to me based on the content and target languages that I provided to you. 11 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app-test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "react": "^18.2.0", 10 | "react-dom": "^18.2.0", 11 | "react-scripts": "5.0.1", 12 | "web-vitals": "^2.1.4" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": [ 22 | "react-app", 23 | "react-app/jest" 24 | ] 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/simply-result.ts: -------------------------------------------------------------------------------- 1 | import { 2 | codeBlocksMdSymbolRegex, 3 | codeBlocksRegex, 4 | reviewFileName, 5 | } from 'src/constant'; 6 | 7 | export const replaceCodeBlock = ( 8 | data: string, 9 | placeholder: string = `check your local __${reviewFileName}__`, 10 | ) => { 11 | return data.replace(codeBlocksRegex, placeholder); 12 | }; 13 | 14 | // Math all code blocks 15 | export const getAllCodeBlock = (data: string): string => { 16 | const codeBlocks = data.match(codeBlocksRegex); 17 | return codeBlocks 18 | ? codeBlocks 19 | ?.map((t) => 20 | codeBlocksMdSymbolRegex.test(t) 21 | ? t.replace(codeBlocksMdSymbolRegex, '') 22 | : t, 23 | ) 24 | .join('') 25 | : data; 26 | }; 27 | 28 | // Send simple data, remove code blocks and replace with a string 29 | export const simplyReviewData = (data: string) => { 30 | return replaceCodeBlock(data) 31 | .replace(/'/g, '') 32 | .replace(/`/g, '__') 33 | .replace(/\n/g, '\\r'); 34 | }; 35 | -------------------------------------------------------------------------------- /prompt/create.txt: -------------------------------------------------------------------------------- 1 | As an expert front-end engineer, your task is to provide code for the requirements I will type to you later. 2 | 3 | You need write code by following guidelines: 4 | - The tech architecture is base on umi@4 framework with @ant-design/pro-components, antd@5, and we have "pages", "sections", "models", "components" and "services" under the src directory and "mock" under the root directory. 5 | - I will type to you one of the "mock", "pages", "sections", "models", "components" and "services" requirement at one time, so you only need to reply code in one markdown block without any explanations for this part. 6 | - Note that Icon need use @ant-design/icons@4. And use components api from antd@5 such as Tabs, Menu should use "items" property instead of children items. 7 | - Note that you no need reply import types form "@/types/*" statements. 8 | - Note that you should reply by typescript functional component. 9 | - Note that you no need consider all i18n, do not reply any code about intl, useIntl from umi, do not reply any code about "intl.formatMessage". 10 | 11 | Your goal is to provide code with chunk functions to make code more readable and maintainable. 12 | -------------------------------------------------------------------------------- /prompt/create-models.txt: -------------------------------------------------------------------------------- 1 | Here is a simple example of how to write a "models": 2 | import { useState, useCallback } from 'react' 3 | import { signIn, signOut } from '@/services/{{services modelName}}' 4 | 5 | export default function use{{modelName}}() { 6 | {{manage api response data, and set to state}} 7 | const [user, setUser] = useState() 8 | {{#if useLoading}}} 9 | const [loading, setLoading] = useState(false); 10 | {{here are some other state}}} 11 | 12 | const signIn = useCallback(async ({{payload}}: API.LoginParams): Promise => { 13 | // signIn implementation 14 | {{Call API from "services"}} 15 | try { 16 | const {{response}} = await signIn({{payload}}); 17 | {{setUser({{response.data}})}} 18 | return {{response.data}}; 19 | } catch(error) { 20 | console.log(error); 21 | } 22 | }, []) 23 | 24 | const signOut = useCallback(async () => { 25 | // signOut implementation 26 | {{Call API from "services"}} 27 | await signOut(); 28 | {{setUser(undefined)}} 29 | }, []) 30 | 31 | return { 32 | user, 33 | signIn, 34 | signOut 35 | } 36 | } 37 | 38 | Please note it's following umi useModel design. No need replay me any explain or usage. 39 | -------------------------------------------------------------------------------- /src/create/constant.ts: -------------------------------------------------------------------------------- 1 | export enum OptionType { 2 | Components = 'components', 3 | Pages = 'pages', 4 | Sections = 'sections', 5 | Models = 'models', 6 | Services = 'services', 7 | Mock = 'mock', 8 | } 9 | 10 | export const OptionTypeExtension = { 11 | [OptionType.Components]: 'tsx', 12 | [OptionType.Pages]: 'tsx', 13 | [OptionType.Sections]: 'tsx', 14 | [OptionType.Models]: 'ts', 15 | [OptionType.Services]: 'ts', 16 | [OptionType.Mock]: 'ts', 17 | }; 18 | 19 | export const optionShortcuts = { 20 | [OptionType.Models]: '1', 21 | [OptionType.Sections]: '2', 22 | [OptionType.Pages]: '3', 23 | [OptionType.Components]: '4', 24 | }; 25 | 26 | export interface IOptionCreated { 27 | option: OptionType; 28 | description: string; 29 | dirName: string; 30 | name: string; 31 | } 32 | 33 | export const messages = { 34 | selectOption: 'Select an option:', 35 | enterDirectoryName: 'Enter a name for module (Directory Name):', 36 | enterName: (option: string) => `Enter a name for the ${option}:`, 37 | nameEmpty: 'Name cannot be empty.', 38 | enterDescription: (option: string) => 39 | `Enter a description for the ${option}:`, 40 | descriptionEmpty: 'Description cannot be empty.', 41 | continueOrFinish: 'Do you want to continue or finish?', 42 | }; 43 | -------------------------------------------------------------------------------- /example/i18n/test.js: -------------------------------------------------------------------------------- 1 | console.log('some other code...'); 2 | 3 | const Note2x1 = { 4 | en: () => { 5 | return ( 6 | <> 7 |

8 | V2.1 Release Date 9 |

10 |
    11 |
  • 2020-11-12
  • 12 |
13 |

14 | New Features 15 |

16 |
    17 |
  • 18 | Validation rule creation for authorized users 19 |
      20 |
    • Hard rule can be created via API
    • 21 |
    • 22 | Soft rule can be created by both API and front-end product 23 |
    • 24 |
    25 |
  • 26 |
  • Soft rule creation form
  • 27 |
  • 28 | Supported rule template 29 |
      30 |
    • Accuracy, count consistency
    • 31 |
    • Uniqueness
    • 32 |
    33 |
  • 34 |
  • 35 | Table data quality check result browsing and detail data quality 36 | check result for each rule 37 |
  • 38 |
  • 39 | Data Quality Check result distribution via Mattermost channel for 40 | rule validation 41 |
  • 42 |
43 | 44 | ); 45 | }, 46 | }; 47 | 48 | console.log('some other code...'); 49 | -------------------------------------------------------------------------------- /src/huskygpt/review.ts: -------------------------------------------------------------------------------- 1 | import { IReadFileResult } from 'src/types'; 2 | import WebhookNotifier from 'src/webhook'; 3 | 4 | import HuskyGPTBase from './base'; 5 | 6 | /** 7 | * Review code for a given file path 8 | */ 9 | class HuskyGPTReview extends HuskyGPTBase { 10 | private publishChannel: WebhookNotifier; 11 | 12 | constructor() { 13 | super(); 14 | this.publishChannel = new WebhookNotifier(); 15 | } 16 | 17 | /** 18 | * Write a test message to a file 19 | */ 20 | private async postAIMessage( 21 | filePath: string, 22 | message: string, 23 | ): Promise { 24 | this.publishChannel.addNoticeTask({ filePath, message }); 25 | } 26 | 27 | /** 28 | * Generate a test case for a given file 29 | */ 30 | public async run(fileResult: IReadFileResult): Promise { 31 | // Reset the parent message to avoid the message tokens over limit 32 | this.openai.resetParentMessage(); 33 | const message = await this.openai.run(fileResult); 34 | if (!message?.length) return; 35 | 36 | const resMessage = message.join('\n\n---\n\n'); 37 | this.postAIMessage(fileResult.filePath!, resMessage); 38 | return resMessage; 39 | } 40 | 41 | /** 42 | * Publish the notices to the webhook channel 43 | */ 44 | public publishNotice(): void { 45 | this.publishChannel.publishNotice(); 46 | } 47 | } 48 | 49 | export default HuskyGPTReview; 50 | -------------------------------------------------------------------------------- /src/huskygpt/translate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { userOptions } from 'src/constant'; 3 | import { IReadFileResult } from 'src/types'; 4 | import { getAllCodeBlock } from 'src/utils'; 5 | import getConflictResult from 'src/utils/write-conflict'; 6 | 7 | import HuskyGPTBase from './base'; 8 | 9 | /** 10 | * Translate for a given file path 11 | */ 12 | class HuskyGPTTranslate extends HuskyGPTBase { 13 | /** 14 | * Write message to a file 15 | */ 16 | private writeMessageToFile( 17 | { filePath, fileContent }: IReadFileResult, 18 | message: string, 19 | ) { 20 | try { 21 | if (userOptions.options.debug) { 22 | console.log('Write message to file:', filePath, message); 23 | } 24 | fs.writeFileSync(filePath, getConflictResult(fileContent, message)); 25 | } catch (error) { 26 | console.error('Error writing message to file:', error); 27 | } 28 | } 29 | 30 | /** 31 | * Translate content for a given file 32 | */ 33 | public async run(fileResult: IReadFileResult): Promise { 34 | // Reset the parent message to avoid the message tokens over limit 35 | this.openai.resetParentMessage(); 36 | const message = await this.openai.run(fileResult); 37 | if (!message?.length) return; 38 | 39 | const extractCode = message.map((m) => getAllCodeBlock(m)).join('\n'); 40 | this.writeMessageToFile(fileResult, extractCode); 41 | 42 | return extractCode; 43 | } 44 | } 45 | 46 | export default HuskyGPTTranslate; 47 | -------------------------------------------------------------------------------- /prompt/review.txt: -------------------------------------------------------------------------------- 1 | As an expert programmer with, your task is to provide suggestions for bug fixes or optimizations in code snippets will provided by user. 2 | Please reply with clear and concise feedback that highlights the main points of any bugs or optimizations with updated code snippet, using markdown tsx syntax if necessary. 3 | 4 | Additionally, do not use ternary operators or switch statements for optimization purposes. instead, use return statements where appropriate. 5 | Please begin each feedback response with the function or class name followed by a colon and a space. 6 | Please refrain from changing the function or class names. 7 | Do not convert class component to a functional component. 8 | Do not suggest optimizations or bug fixes that have already been addressed in previous code snippets. 9 | Note not defined is not bug or optimizations. 10 | Note your optimizations should be more readable, understandable and higher performance otherwise, reply "perfect!" without any additional explanation. 11 | 12 | No optimizations needed or bugs found example: 13 | # User question: 14 | {{code snippet}} 15 | # Assistant answer: 16 | `sum`: Perfect! 17 | 18 | Optimizations needed or bugs found example: 19 | # User question: 20 | {{code snippet}} 21 | # Assistant answer: 22 | `sum`: 23 | - __`Bug`__: {{bug}}. 24 | - __Optimization__: {{optimization}}. 25 | 26 | Here is the optimized code: 27 | // ... Other methods remain the same ... 28 | {updated code} 29 | 30 | Your goal is to provide actionable and specific recommendations that will improve the quality and efficiency of the code. 31 | -------------------------------------------------------------------------------- /example/src/components/__test__/chat-input.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import ChatInput from '../chat-input'; 4 | 5 | describe('ChatInput', () => { 6 | it('should display the input value when it changes', () => { 7 | render( {}} />); 8 | const input = screen.getByTestId('chat-input'); 9 | fireEvent.change(input, { target: { value: 'Hello, world!' } }); 10 | expect(input).toHaveValue('Hello, world!'); 11 | }); 12 | 13 | it('should call onSubmit when the form is submitted with a non-empty input value', () => { 14 | const handleSubmit = jest.fn(); 15 | render(); 16 | const input = screen.getByTestId('chat-input'); 17 | const submitButton = screen.getByText('Send'); 18 | fireEvent.change(input, { target: { value: 'Hello, world!' } }); 19 | fireEvent.click(submitButton); 20 | expect(handleSubmit).toHaveBeenCalledTimes(1); 21 | expect(handleSubmit).toHaveBeenCalledWith('Hello, world!'); 22 | expect(input).toHaveValue(''); 23 | }); 24 | 25 | it('should not call onSubmit when the form is submitted with an empty input value', () => { 26 | const handleSubmit = jest.fn(); 27 | render(); 28 | const input = screen.getByTestId('chat-input'); 29 | const submitButton = screen.getByText('Send'); 30 | fireEvent.change(input, { target: { value: '' } }); 31 | fireEvent.click(submitButton); 32 | expect(handleSubmit).not.toHaveBeenCalled(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /prompt/review_v1.txt: -------------------------------------------------------------------------------- 1 | I want you to act as a expert programmer, I will type code snippet and you will reply optimizations or bugs fix suggestion according to these guidelines: 2 | 3 | 1. If there are bugs or potential optimizations, reply the main points and optimized code diff only inside markdown's syntax is decide by code or using tsx. 4 | 2. We might type incomplete code snippets, please ignore any missing pieces. 5 | 3. Do not optimized by using a ternary operator or switch statements, if return statements is prefer. 6 | 4. Do not change classes to functions. 7 | 5. I want you reply simply, so I no need to type continue to let you continue and if there are no optimizations needed or bugs found only reply "perfect!" without any explanation. 8 | 6. Start your feedback with the function or class name, followed by a colon and a space. 9 | 7. Do not optimized by changing the function name or class name or adding a comment describing. 10 | 8. Do not reply optimizations or bug that was incorrect in the previous code snippet 11 | 12 | 9. No optimizations needed or bugs found example: 13 | # User question: 14 | Here is the code snippet for review: "function sum(a, b) { 15 | return a + b; 16 | }" 17 | # Assistant answer: 18 | `sum`: Perfect! 19 | 20 | 10. Optimizations needed or bugs found example, reply diff code only: 21 | # User question: 22 | function sum(a, b) { 23 | return a - b; 24 | } 25 | # Assistant answer: 26 | `sum`: 27 | - Bug: The function should return a + b instead of a - b. 28 | - Optimization: The function can be simplified to return a + b. 29 | 30 | Here is the optimized code: 31 | ```tsx 32 | function sum(a, b) { 33 | return a + b; 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /prompt/tests.txt: -------------------------------------------------------------------------------- 1 | As an expert programmer, your task is to provide unit tests code for the functions or classes that I will provided to you. 2 | 3 | Please note that you should not reply with tests for functions that solely return a value. 4 | 5 | Please use Jest to reply with unit tests and prefer "describe", "it" with multiple "expect" methods. 6 | And use @testing-library/react for React component tests (@testing-library/react@13, react-dom@18).And use all methods from screen like "screen.getByTestId". 7 | Note, please use ReactDOM.render instead of "react-test-renderer" or "enzyme" library. 8 | 9 | Note that you only need to reply with the unit tests code in single markdown code block. 10 | Note that tou should consider the classNames test case. 11 | 12 | Here is example: 13 | # User question: 14 | {{code snippet}} 15 | # Assistant answer: 16 | {{ 17 | if code syntax is tsx,jsx: 18 | import React from 'react'; 19 | }} 20 | 21 | {{Define and reuse mock data or variable reference for below test cases}} 22 | describe({{unit title}}, () => { 23 | {{ 24 | if code syntax is tsx,jsx: 25 | let container: HTMLDivElement; 26 | 27 | beforeEach(() => { 28 | container = document.createElement('div'); 29 | document.body.appendChild(container); 30 | }); 31 | 32 | afterEach(() => { 33 | document.body.removeChild(container); 34 | container = null; 35 | }); 36 | }} 37 | 38 | it({{test case title}}, () => { 39 | {{more test cases}} 40 | }); 41 | 42 | {{more test cases}} 43 | }); 44 | 45 | Your goal is to provide comprehensive and accurate unit tests that cover all possible scenarios and ensure that the functions or classes operate as intended. 46 | -------------------------------------------------------------------------------- /src/reader/reader-directory.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { IReadFileResult } from 'src/types'; 4 | 5 | class ReadTestFilePathsByDirectory { 6 | // Get all files in a directory 7 | private getFilesInDirectory(dirPath: string): string[] { 8 | return fs.readdirSync(dirPath); 9 | } 10 | 11 | // Check if a file path is a directory 12 | private isDirectory(filePath: string): boolean { 13 | return fs.statSync(filePath).isDirectory(); 14 | } 15 | 16 | // Get file paths of all files in a subdirectory 17 | private getSubDirectoryFilePaths(filePath: string): IReadFileResult[] { 18 | return this.getDirFiles(filePath); 19 | } 20 | 21 | // Get file content of a file 22 | private getFileContent(filePath: string): string { 23 | return fs.readFileSync(filePath, 'utf-8'); 24 | } 25 | 26 | // Get all file paths in a directory and its subdirectories 27 | public getDirFiles(dirPath: string): IReadFileResult[] { 28 | // If the path is not a directory, return the path 29 | if (!this.isDirectory(dirPath)) { 30 | return [{ filePath: dirPath, fileContent: this.getFileContent(dirPath) }]; 31 | } 32 | 33 | const filesPath = this.getFilesInDirectory(dirPath); 34 | 35 | return filesPath.reduce((fileResult: IReadFileResult[], file: string) => { 36 | const filePath = path.join(dirPath, file); 37 | 38 | if (this.isDirectory(filePath)) { 39 | const subDirFileResults = this.getSubDirectoryFilePaths(filePath); 40 | return [...fileResult, ...subDirFileResults]; 41 | } 42 | 43 | return [ 44 | ...fileResult, 45 | { filePath, fileContent: this.getFileContent(filePath) }, 46 | ]; 47 | }, [] as IReadFileResult[]); 48 | } 49 | } 50 | 51 | export default ReadTestFilePathsByDirectory; 52 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/src/utils/__test__/pagination.test.ts: -------------------------------------------------------------------------------- 1 | import { Pagination } from '../pagination'; 2 | 3 | describe('Pagination', () => { 4 | let pagination: Pagination; 5 | beforeEach(() => { 6 | // @ts-ignore 7 | pagination = new Pagination(() => {}); 8 | }); 9 | 10 | it('should set the config correctly', () => { 11 | const config = { 12 | pageSize: 10, 13 | current: 1, 14 | total: 0, 15 | showSizeChanger: true, 16 | }; 17 | pagination.setConfig(config); 18 | expect(pagination.config).toEqual(config); 19 | }); 20 | 21 | it('should set the current page correctly', () => { 22 | pagination.setCurrent(2); 23 | expect(pagination.config.current).toEqual(2); 24 | }); 25 | 26 | it('should call the change handler when changePage is called', () => { 27 | // @ts-ignore 28 | const spy = jest.spyOn(pagination, 'handler'); 29 | const config = { 30 | pageSize: 10, 31 | current: 1, 32 | total: 0, 33 | showSizeChanger: true, 34 | }; 35 | pagination.changePage(config); 36 | expect(spy).toHaveBeenCalledWith(config); 37 | }); 38 | }); 39 | 40 | // describe('autoResetPageNum', () => { 41 | // it('should throw an error if the target does not have a pagination property', () => { 42 | // const target = {}; 43 | // expect(() => autoResetPageNum()(target, '', {})).toThrowError( 44 | // 'Current Class must include pagination' 45 | // ); 46 | // }); 47 | 48 | // it('should call the setCurrent method of the pagination object', () => { 49 | // const target = { 50 | // pagination: { 51 | // setCurrent: jest.fn(), 52 | // }, 53 | // }; 54 | // const descriptor = { 55 | // value: jest.fn(() => Promise.resolve(2)), 56 | // }; 57 | // autoResetPageNum()(target, '', descriptor); 58 | // expect(target.pagination.setCurrent).toHaveBeenCalledWith(2); 59 | // }); 60 | // }); 61 | -------------------------------------------------------------------------------- /src/utils/__tests__/extract-code-prompts.test.ts: -------------------------------------------------------------------------------- 1 | import { IReadFileResult } from 'src/types'; 2 | 3 | import { ExtractCodePrompts } from '../extract-code-prompts'; 4 | import { 5 | functionString, 6 | multipleFunctionString, 7 | noFunctionString, 8 | } from './__mock__/test-data'; 9 | 10 | const emptyReg = /[\n\s]+/g; 11 | 12 | describe('ExtractCodePrompts', () => { 13 | let extractCodePrompts: ExtractCodePrompts; 14 | 15 | beforeEach(() => { 16 | extractCodePrompts = new ExtractCodePrompts(); 17 | }); 18 | 19 | it('should return an empty array when no functions or classes found', () => { 20 | const readFileResult: IReadFileResult = { 21 | fileContent: noFunctionString, 22 | filePath: '/test/path', 23 | }; 24 | 25 | const result = 26 | extractCodePrompts.extractFunctionOrClassCodeArray(readFileResult); 27 | 28 | expect(result).toEqual([]); 29 | }); 30 | 31 | it('should return an array with the extracted function code when a function is found', () => { 32 | const readFileResult: IReadFileResult = { 33 | fileContent: functionString, 34 | filePath: '/test/path', 35 | }; 36 | 37 | const result = 38 | extractCodePrompts.extractFunctionOrClassCodeArray(readFileResult); 39 | 40 | const normalizedResult = result.map((code) => code.replace(emptyReg, ' ')); 41 | 42 | expect(normalizedResult).toEqual([functionString]); 43 | }); 44 | 45 | it('should return an array with the extracted function code when multiple function is found', () => { 46 | const readFileResult: IReadFileResult = { 47 | fileContent: multipleFunctionString, 48 | filePath: '/test/path', 49 | }; 50 | 51 | const result = 52 | extractCodePrompts.extractFunctionOrClassCodeArray(readFileResult); 53 | 54 | expect(result.length).toEqual(6); 55 | }); 56 | 57 | // Add more test cases for classes and variable declarations with functions or arrow functions. 58 | }); 59 | -------------------------------------------------------------------------------- /example/src/utils/pagination.ts: -------------------------------------------------------------------------------- 1 | import towSum from './tow-sum'; 2 | 3 | type TablePaginationConfig = any; 4 | 5 | console.log(towSum([1, 3], 4)); 6 | 7 | export interface PaginationClass { 8 | pagination: Pagination; 9 | } 10 | 11 | export const autoResetPageNum = 12 | () => 13 | ( 14 | target: PaginationClass, 15 | property: string, 16 | descriptor: TypedPropertyDescriptor<(...args: any) => any> 17 | ) => { 18 | const original = descriptor?.value; 19 | if (typeof original !== 'function') { 20 | throw new Error('@message must decorator a function'); 21 | } 22 | 23 | descriptor.value = async function (...args: any) { 24 | if (!this.pagination?.setCurrent) { 25 | throw new Error('Current Class must include pagination'); 26 | } 27 | const result = await original.apply(this, args); 28 | this.pagination.setCurrent(result); 29 | return result; 30 | }; 31 | 32 | return descriptor; 33 | }; 34 | 35 | export class Pagination { 36 | config: TablePaginationConfig = { 37 | pageSize: 10, 38 | current: 1, 39 | total: 0, 40 | showSizeChanger: true, 41 | }; 42 | 43 | private handler: (config: Partial) => Promise; 44 | 45 | constructor( 46 | changeHandler: (config: Partial) => Promise, 47 | initConfig?: Partial 48 | ) { 49 | this.handler = changeHandler; 50 | this.setConfig(initConfig); 51 | } 52 | 53 | changePage(config: Partial) { 54 | this.setConfig(config); 55 | return this.handler(config); 56 | } 57 | 58 | setPagination(config: Partial) { 59 | Object.assign(this.config, config); 60 | } 61 | 62 | setCurrent(num: number) { 63 | this.config.current = num || 1; 64 | } 65 | 66 | setConfig(config: Partial) { 67 | this.config = { ...this.config, ...config }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/__tests__/__mock__/test-data.ts: -------------------------------------------------------------------------------- 1 | export const noFunctionString = 'const test = "Hello, World!";'; 2 | export const functionString = 3 | 'function testFunction() { return "Hello, World!"; }'; 4 | 5 | // Multiple functions 6 | export const multipleFunctionString = ` 7 | export const isSafeValue = (val: string | number | boolean) => { 8 | const strVal = String(val); 9 | return !['', 'undefined', 'NaN', 'null'].includes(strVal); 10 | }; 11 | export const isSafeNumber = (val: string | number | boolean) => { 12 | const num = Number(val); 13 | 14 | return typeof val !== 'boolean' && isSafeValue(val) && isFinite(num); 15 | }; 16 | export const convertTo32Fraction = (num: number | string, fractionDigits = 2) => { 17 | if (!isSafeNumber(num)) return num; 18 | if (fractionDigits > 32) { 19 | throw new Error('Fraction digits must be 32 or less'); 20 | } 21 | return Number(num).toFixed(fractionDigits); 22 | }; 23 | 24 | export const numberFractionFormat = (num: string | number, maxFractionDigits = 5) => { 25 | if (!isSafeNumber(num)) return num; 26 | return num; 27 | }; 28 | 29 | /** 30 | * Convert a number to a short string representation of the number. 31 | * @param v The number to convert 32 | * @param fractionDigits The number of digits to use after the decimal point. 33 | * Defaults to 1. 34 | */ 35 | export const formatBigNumber = (v: string | number, fractionDigits = 1) => { 36 | if (!isSafeNumber(v)) return ''; 37 | if (Number(v) === 0) return convertTo32Fraction(v, fractionDigits); 38 | 39 | const suffixes = ['', 'K', 'M', 'B', 'T']; 40 | const index = Math.floor(Math.log10(Math.abs(Number(v))) / 3); 41 | const value = Number(v) / Math.pow(1000, index); 42 | return value; 43 | }; 44 | 45 | export class TestClass { 46 | constructor() { 47 | console.log('Hello, World!'); 48 | } 49 | 50 | private testFunction() { 51 | console.log('Hello, World!'); 52 | } 53 | } 54 | 55 | const testClass = new TestClass(); 56 | `; 57 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | /** 6 | * Get the file name from the file path 7 | * @param {string} filePath The file path 8 | */ 9 | export const getFileNameByPath = (filePath: string) => 10 | filePath && path.basename(filePath, path.extname(filePath)); 11 | 12 | /** 13 | * Get the user email from the git config 14 | * @returns {string} The user email 15 | */ 16 | export const getUserEmail = () => { 17 | const output = execSync('git config user.email').toString().trim(); 18 | return output; 19 | }; 20 | 21 | /** 22 | * Delete the file by the file path 23 | */ 24 | export const deleteFileSync = (filePath: string) => { 25 | if (!fs.existsSync(filePath)) return; 26 | fs.unlinkSync(filePath); 27 | }; 28 | 29 | // Create the directory if it doesn't exist 30 | export const makeDirExist = (dirPath: string) => { 31 | if (fs.existsSync(dirPath)) return; 32 | fs.mkdirSync(dirPath, { recursive: true }); 33 | }; 34 | 35 | /** 36 | * Get the file name to camel case 37 | * @param {string} fileName The file name 38 | * @returns {string} The file name to camel case 39 | * @example 40 | * // returns 'exampleModule' 41 | */ 42 | export const getFileNameToCamelCase = ( 43 | fileName: string, 44 | isFirstUpper = false, 45 | ) => { 46 | if (!fileName) return ''; 47 | if (fileName.indexOf('-') === -1) { 48 | return isFirstUpper 49 | ? fileName.slice(0, 1).toUpperCase() + fileName.slice(1) 50 | : fileName.slice(0, 1).toLowerCase() + fileName.slice(1); 51 | } 52 | 53 | fileName 54 | .split('-') 55 | .map((word, index) => { 56 | if (index !== 0) { 57 | return `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`; 58 | } 59 | 60 | return isFirstUpper 61 | ? word.slice(0, 1).toUpperCase() 62 | : word.slice(0, 1).toLowerCase(); 63 | }) 64 | .join(''); 65 | }; 66 | 67 | export * from './simply-result'; 68 | -------------------------------------------------------------------------------- /src/utils/__tests__/write-conflict.test.ts: -------------------------------------------------------------------------------- 1 | import getConflictResult from '../write-conflict'; 2 | 3 | describe('getConflictResult', () => { 4 | it('should return the correct conflict result for non-empty input', () => { 5 | const sourceContent = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; 6 | const targetContent = 'Line 1\nLine 2\nLine 3 changed\nLine 4\nLine 5'; 7 | 8 | const expectedResult = 9 | 'Line 1\nLine 2\n<<<<<<< HEAD\nLine 3\n=======\nLine 3 changed\n>>>>>>> Incoming\nLine 4\nLine 5'; 10 | 11 | expect(getConflictResult(sourceContent, targetContent)).toEqual( 12 | expectedResult, 13 | ); 14 | }); 15 | 16 | it('should return the correct conflict result when there are starting and ending empty lines', () => { 17 | const sourceContent = '\n\nLine 1\nLine 2\nLine 3\nLine 4\n\n'; 18 | const targetContent = 19 | '\nLine 1\nLine 2\nLine 3 changed\nLine 4\nLine 5\n\n'; 20 | 21 | const expectedResult = 22 | 'Line 1 Line 2 <<<<<<< HEAD Line 3 Line 4 ======= Line 3 changed Line 4 Line 5 >>>>>>> Incoming\n'; 23 | 24 | expect( 25 | getConflictResult(sourceContent, targetContent).replace(/\n/g, ' '), 26 | ).toEqual(expectedResult.replace(/\n/g, ' ')); 27 | }); 28 | 29 | it('should return the correct conflict result when there are no differences', () => { 30 | const sourceContent = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; 31 | const targetContent = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; 32 | 33 | const expectedResult = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; 34 | 35 | expect( 36 | getConflictResult(sourceContent, targetContent).replace(/\n/g, ' '), 37 | ).toEqual(expectedResult.replace(/\n/g, ' ')); 38 | }); 39 | 40 | it('should return the correct conflict result when the entire content is different', () => { 41 | const sourceContent = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; 42 | const targetContent = 43 | 'New Line 1\nNew Line 2\nNew Line 3\nNew Line 4\nNew Line 5'; 44 | 45 | const expectedResult = 46 | '<<<<<<< HEAD\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\n=======\nNew Line 1\nNew Line 2\nNew Line 3\nNew Line 4\nNew Line 5\n>>>>>>> Incoming'; 47 | 48 | expect(getConflictResult(sourceContent, targetContent)).toEqual( 49 | expectedResult, 50 | ); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /example/src/utils/filter-table-base.ts: -------------------------------------------------------------------------------- 1 | import { autoResetPageNum, Pagination } from './pagination'; 2 | 3 | export enum EFilterTableLoading { 4 | getList = 'getList', 5 | getDetail = 'getDetail', 6 | } 7 | 8 | type IPageData = any; 9 | type IObject = any; 10 | 11 | export class FilterTableBase< 12 | T extends IObject, 13 | P extends IObject, 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | ID = any, 16 | DETAIL extends IObject = IObject 17 | > { 18 | filter: Partial = {}; 19 | tableFilter: Partial = {}; 20 | list: P[] = []; 21 | 22 | // used for displaying details function 23 | itemId: ID; 24 | itemDetail: DETAIL; 25 | 26 | pagination: Pagination = new Pagination(() => this.getList()); 27 | 28 | initItem() { 29 | this.setItemId(null); 30 | this.setItemDetail(null); 31 | } 32 | 33 | setItemId(id: ID) { 34 | this.itemId = id; 35 | } 36 | 37 | setItemDetail(itemDetail: DETAIL) { 38 | this.itemDetail = itemDetail; 39 | } 40 | 41 | getItemDetail() { 42 | return this.getItemDetailData(); 43 | } 44 | 45 | getItemDetailData(): Promise { 46 | return Promise.resolve(null); 47 | } 48 | 49 | reset() { 50 | this.setFilter({}); 51 | } 52 | 53 | @autoResetPageNum() 54 | addFilter(filter: Partial) { 55 | this.filter = { ...this.filter, ...filter }; 56 | } 57 | 58 | @autoResetPageNum() 59 | addFilterDebounce(filter: Partial) { 60 | this.filter = { ...this.filter, ...filter }; 61 | } 62 | 63 | addTableFilter(filter: Partial) { 64 | this.tableFilter = { ...this.tableFilter, ...filter }; 65 | } 66 | 67 | @autoResetPageNum() 68 | setTableFilter(filter: Partial) { 69 | this.tableFilter = filter; 70 | } 71 | 72 | @autoResetPageNum() 73 | setFilter(filter: Partial) { 74 | this.filter = filter; 75 | } 76 | 77 | setList(data: IPageData) { 78 | if (!data) return; 79 | this.list = data.entities; 80 | this.pagination.setPagination({ total: data.total }); 81 | } 82 | 83 | getData(_filter: Partial): Promise { 84 | return Promise.resolve(null); 85 | } 86 | 87 | async getList(config: Partial = this.pagination.config) { 88 | const result = await this.getData({ 89 | ...this.filter, 90 | ...this.tableFilter, 91 | pageSize: config.pageSize, 92 | pageNum: config.current, 93 | }); 94 | 95 | this.setList(result); 96 | return result; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/webhook/index.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { codeBlocksRegex, reviewFileName, userOptions } from 'src/constant'; 5 | import { deleteFileSync, getUserEmail, simplyReviewData } from 'src/utils'; 6 | 7 | import { INoticeTask, ISeatalkNoticeOptions } from './constant'; 8 | 9 | /** 10 | * Webhook notifier 11 | * @param {string} channel The webhook channel 12 | * @param {string} userEmail The user email 13 | */ 14 | class WebhookNotifier { 15 | private readonly userEmail: string; 16 | private readonly channel: string; 17 | private tasks: string[]; 18 | 19 | constructor({ 20 | channel = userOptions.options.reviewReportWebhook, 21 | userEmail = '', 22 | }: ISeatalkNoticeOptions = {}) { 23 | this.tasks = []; 24 | 25 | if (!channel) return; 26 | this.userEmail = userEmail; 27 | this.channel = channel; 28 | } 29 | 30 | /** 31 | * Add a notice task 32 | */ 33 | public addNoticeTask(task: INoticeTask) { 34 | if (!task) return; 35 | 36 | this.tasks.push( 37 | `__${path.dirname(task.filePath).split('/').pop()}/${path.basename( 38 | task.filePath, 39 | )}__ \\r• ${task.message}`, 40 | ); 41 | } 42 | 43 | /** 44 | * Publish all notices to the webhook channel 45 | */ 46 | async publishNotice() { 47 | if (!this.tasks?.length) return; 48 | const content = this.tasks.join('\\r\\r\\n'); 49 | const reviewFilePath = `${path.join(process.cwd(), reviewFileName)}`; 50 | 51 | deleteFileSync(reviewFilePath); 52 | 53 | // Write the output text to a file if there are code blocks 54 | if (codeBlocksRegex.test(content)) { 55 | fs.writeFileSync(reviewFilePath, content, 'utf-8'); 56 | } 57 | 58 | // If no channel is provided, log the content to the console 59 | if (userOptions.options.debug) { 60 | console.log( 61 | 'publishNotice: channel=%s, content=%s', 62 | this.channel, 63 | content, 64 | ); 65 | } 66 | 67 | if (!this.channel) return; 68 | 69 | const data = `\\r\\r${simplyReviewData(content)}`; 72 | 73 | try { 74 | await exec( 75 | `curl -i -X POST -H 'Content-Type: application/json' -d '{ "tag": "markdown", "markdown": {"content": "${data}"}}' ${this.channel}`, 76 | ); 77 | } catch (error) { 78 | console.error(error); 79 | } 80 | } 81 | } 82 | 83 | export default WebhookNotifier; 84 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | import { userOptions } from './constant'; 4 | import CreateCLI from './create'; 5 | import { HuskyGPTReview, HuskyGPTTest, HuskyGPTTranslate } from './huskygpt'; 6 | import ModifyCLI from './modify'; 7 | import ReadFiles from './reader'; 8 | import { HuskyGPTTypeEnum, IUserOptions, ReadTypeEnum } from './types'; 9 | 10 | const runMap: Record void> = { 11 | [HuskyGPTTypeEnum.Test]: async () => { 12 | const testFilePaths = new ReadFiles(); 13 | const files = testFilePaths.getFileResults(); 14 | const huskygpt = new HuskyGPTTest(); 15 | 16 | // Generate a test case for each file path 17 | for (const fileResult of files) { 18 | await huskygpt.run(fileResult); 19 | } 20 | }, 21 | [HuskyGPTTypeEnum.Review]: async () => { 22 | const reviewFiles = new ReadFiles(); 23 | const files = reviewFiles.getFileResults(); 24 | const huskygpt = new HuskyGPTReview(); 25 | 26 | // Review code for each file path 27 | for (const fileResult of files) { 28 | await huskygpt.run(fileResult); 29 | } 30 | 31 | // Publish the notices to the webhook channel 32 | huskygpt.publishNotice(); 33 | }, 34 | [HuskyGPTTypeEnum.Create]: async () => { 35 | const cli = new CreateCLI(); 36 | 37 | await cli.start(); 38 | }, 39 | [HuskyGPTTypeEnum.Modify]: async () => { 40 | const reviewFiles = new ReadFiles(); 41 | const files = reviewFiles.getFileResults(); 42 | if (!files.length) return; 43 | 44 | // Modify for each file path 45 | const cli = new ModifyCLI(files); 46 | await cli.start(); 47 | }, 48 | [HuskyGPTTypeEnum.Translate]: async () => { 49 | const testFilePaths = new ReadFiles({ fileExtensions: [] }); 50 | const files = testFilePaths.getFileResults(ReadTypeEnum.Directory); 51 | const huskygpt = new HuskyGPTTranslate(); 52 | 53 | // Translate for each file path 54 | for (const fileResult of files) { 55 | await huskygpt.run(fileResult); 56 | } 57 | }, 58 | }; 59 | 60 | /** 61 | * Main function for huskygpt 62 | */ 63 | export function main(options?: IUserOptions) { 64 | userOptions.init(options); 65 | const type = userOptions.huskyGPTType; 66 | 67 | if (!runMap[type]) throw new Error('Invalid huskyGPTType: ' + type); 68 | 69 | // Print debug info 70 | if (userOptions.options.debug) { 71 | console.log( 72 | 'Running huskygpt with options: ', 73 | JSON.stringify(userOptions.options), 74 | ); 75 | } 76 | 77 | runMap[type](); 78 | } 79 | 80 | export default main; 81 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/chatgpt/send-message.ts: -------------------------------------------------------------------------------- 1 | import { ChatMessage, SendMessageOptions } from 'chatgpt'; 2 | import { 3 | OPENAI_MAX_CONTINUES, 4 | OPENAI_MAX_RETRY, 5 | codeBlocksMdSymbolRegex, 6 | } from 'src/constant'; 7 | 8 | // Helper function to perform the API call with retries, handling specific status codes 9 | export const sendMessageWithRetry = async ( 10 | sendMessage: () => Promise, 11 | retries = OPENAI_MAX_RETRY, 12 | retryDelay = 3000, 13 | ): Promise => { 14 | for (let retry = 0; retry < retries; retry++) { 15 | try { 16 | const res = await sendMessage(); 17 | return res; 18 | } catch (error) { 19 | if (error.statusCode === 401) { 20 | // If statusCode is 401, do not retry 21 | throw error; 22 | } else if (error.statusCode === 429) { 23 | // If statusCode is 429, sleep for retryDelay milliseconds then try again 24 | await new Promise((resolve) => setTimeout(resolve, retryDelay)); 25 | } else { 26 | // If not a specified status code, and we've reached the maximum number of retries, throw the error 27 | if (retry === retries) { 28 | throw error; 29 | } 30 | console.log( 31 | `[huskygpt] sendMessage failed, retrying... (${ 32 | retry + 1 33 | }/${retries})`, 34 | ); 35 | } 36 | } 37 | } 38 | throw new Error('sendMessage failed after retries'); 39 | }; 40 | 41 | // Handle continue message if needed 42 | export const handleContinueMessage = async ( 43 | message: ChatMessage, 44 | sendMessage: ( 45 | messageText: string, 46 | sendOptions?: SendMessageOptions, 47 | ) => Promise, 48 | maxContinueAttempts = OPENAI_MAX_CONTINUES, 49 | ): Promise => { 50 | let resMessage = message; 51 | let continueAttempts = 0; 52 | 53 | if ((resMessage.text.match(codeBlocksMdSymbolRegex) || []).length % 2 === 0) { 54 | return resMessage; 55 | } 56 | 57 | while (continueAttempts < maxContinueAttempts) { 58 | const continueMessage = 'continue'; 59 | const nextMessage = await sendMessage(continueMessage, { 60 | conversationId: resMessage.conversationId, 61 | parentMessageId: resMessage.id, 62 | } as SendMessageOptions); 63 | 64 | console.log( 65 | `[huskygpt] continue message... (${ 66 | continueAttempts + 1 67 | }/${maxContinueAttempts})`, 68 | ); 69 | 70 | resMessage = { 71 | ...resMessage, 72 | ...nextMessage, 73 | text: `${resMessage.text}${nextMessage.text}`, 74 | }; 75 | 76 | if (nextMessage.text.match(codeBlocksMdSymbolRegex)?.length > 0) break; 77 | 78 | continueAttempts++; 79 | } 80 | 81 | return resMessage; 82 | }; 83 | -------------------------------------------------------------------------------- /src/reader/reader-git-stage.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { userOptions } from 'src/constant'; 5 | import { IReadFileResult } from 'src/types'; 6 | import GitDiffExtractor from 'src/utils/extract-modify-funcs'; 7 | 8 | /** 9 | * Read the staged files from git only when the file is added 10 | * @returns the file path of the staged files 11 | */ 12 | class StagedFileReader { 13 | private stagedFiles: IReadFileResult[]; 14 | 15 | constructor() { 16 | this.stagedFiles = this.readStagedFiles(); 17 | } 18 | 19 | /** 20 | * Read the staged files from git 21 | */ 22 | private readStagedFiles(): IReadFileResult[] { 23 | const files = execSync('git diff --cached --name-status') 24 | .toString() 25 | .split('\n') 26 | .filter(Boolean); 27 | const readRootName = userOptions.options.readFilesRootName; 28 | const readGitStatus = 29 | userOptions.options.readGitStatus?.split(',').map((el) => el.trim()) || 30 | []; 31 | 32 | if (!readRootName) throw new Error('readFilesRootName is not set'); 33 | if (!readGitStatus.length) { 34 | console.warn('readGitStatus is not set, no reading staged files'); 35 | return []; 36 | } 37 | 38 | // Add the path of each added, renamed, or modified file 39 | return files.reduce((acc, file) => { 40 | const fileSplitArr = file.split('\t'); 41 | const status = fileSplitArr[0].slice(0, 1); 42 | const filePath = fileSplitArr.slice(-1)[0]; 43 | const fullPath = path.join(process.cwd(), filePath); 44 | 45 | // Only read the files under the specified root directory and the specified status 46 | if ( 47 | !readGitStatus.includes(status) || 48 | !filePath.startsWith(`${readRootName}/`) || 49 | !fs.existsSync(fullPath) 50 | ) { 51 | return acc; 52 | } 53 | 54 | const contents = fs.readFileSync(fullPath, 'utf-8'); 55 | 56 | // If the file is not modified, return the original file content 57 | if (status !== 'M') { 58 | return [...acc, { filePath: fullPath, fileContent: contents }]; 59 | } 60 | 61 | // If the file is modified, extract the modified function or class 62 | const codeExtractor = new GitDiffExtractor(); 63 | const modifiedContents = 64 | codeExtractor.extractModifiedFunction(fullPath, contents) || ''; 65 | return [ 66 | ...acc, 67 | { 68 | filePath: fullPath, 69 | fileContent: modifiedContents, 70 | }, 71 | ]; 72 | }, []); 73 | } 74 | 75 | public getStagedFiles(): IReadFileResult[] { 76 | return this.stagedFiles; 77 | } 78 | } 79 | 80 | export default StagedFileReader; 81 | -------------------------------------------------------------------------------- /src/utils/extract-code-prompts.ts: -------------------------------------------------------------------------------- 1 | import generate from '@babel/generator'; 2 | import { parse } from '@babel/parser'; 3 | import traverse, { NodePath } from '@babel/traverse'; 4 | import fs from 'fs'; 5 | import { userOptions } from 'src/constant'; 6 | import { IReadFileResult } from 'src/types'; 7 | 8 | const traverseFunc = 9 | typeof traverse === 'function' ? traverse : (traverse as any).default; 10 | const generateFunc = 11 | typeof generate === 'function' ? generate : (generate as any).default; 12 | 13 | /** 14 | * Pick function or class code from the given code 15 | */ 16 | export class ExtractCodePrompts { 17 | // Store the remaining code after picking 18 | private remainingCode: string[]; 19 | // Store the end index of the remaining code 20 | private remainingEndIndex; 21 | 22 | constructor() { 23 | this.remainingCode = []; 24 | this.remainingEndIndex = 0; 25 | } 26 | 27 | /** 28 | * Check if the node is a function or class 29 | */ 30 | private isFunctionOrClass(nodePath: NodePath | null): boolean { 31 | if (!nodePath) return true; 32 | 33 | const isVariableDeclarationFunction = 34 | nodePath.isVariableDeclaration() && 35 | nodePath.node.declarations.some( 36 | (d) => 37 | d.init && 38 | (d.init.type === 'FunctionExpression' || 39 | d.init.type === 'ArrowFunctionExpression'), 40 | ); 41 | 42 | return ( 43 | nodePath.isFunction() || 44 | nodePath.isClass() || 45 | isVariableDeclarationFunction 46 | ); 47 | } 48 | 49 | /** 50 | * Pick function or class code from the given code 51 | */ 52 | public extractFunctionOrClassCodeArray({ 53 | fileContent, 54 | filePath, 55 | }: IReadFileResult): string[] { 56 | try { 57 | const ast = parse(fileContent, { 58 | sourceType: 'module', 59 | plugins: ['typescript', 'jsx'], 60 | }); 61 | 62 | traverseFunc(ast, { 63 | enter: (nodePath) => { 64 | // If current node already in the remaining code, skip it 65 | if (Number(nodePath.node.start) < this.remainingEndIndex) return; 66 | 67 | if (!this.isFunctionOrClass(nodePath)) return; 68 | 69 | this.remainingEndIndex = Number(nodePath.node.end); 70 | // If the current node is a function or class, generate the code snippet 71 | const codeSnippet = generateFunc(nodePath.node).code; 72 | this.remainingCode.push(codeSnippet); 73 | }, 74 | }); 75 | 76 | return this.remainingCode; 77 | } catch (e) { 78 | if (userOptions.options?.debug) console.error('Babel parse error: ', e); 79 | return [ 80 | fs.existsSync(filePath) 81 | ? fs.readFileSync(filePath, 'utf-8') 82 | : fileContent, 83 | ]; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/code-context.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { getFileNameByPath } from './index'; 5 | 6 | /** 7 | * Prompt example: 8 | * 9 | // Get the file context 10 | // const dependencyReader = new DependencyReader(filePath); 11 | // const context = dependencyReader 12 | // .getDependencies() 13 | // .map((dependency) => dependency.content) 14 | // .join('\n'); 15 | */ 16 | // Please Write a javascript unit tests. 17 | // The test should import the test function from parent directory and file name is ${fileName}. 18 | // Should include at least one test case for each function or class. 19 | // No need to test the function only return another function. 20 | // No need to test variable definition. 21 | 22 | // ${context} 23 | 24 | // Need to write test content: ${fileContent} 25 | 26 | interface Dependency { 27 | path: string; 28 | content: string; 29 | } 30 | 31 | class DependencyReader { 32 | private fileContent: string; 33 | private basePath: string; 34 | private dependencies: Dependency[]; 35 | 36 | constructor(filePath: string) { 37 | this.fileContent = this.readFileIgnoreExtension(filePath); 38 | this.basePath = path.dirname(filePath); 39 | this.dependencies = this.extractDependencies(); 40 | } 41 | 42 | private readFileIgnoreExtension(filePath: string): string { 43 | const directoryPath = path.dirname(filePath); 44 | const fileName = getFileNameByPath(filePath); 45 | const files = fs.readdirSync(directoryPath); 46 | 47 | const matchingFiles = files.filter((file) => { 48 | const name = path.basename(file, path.extname(file)); 49 | return name === fileName; 50 | }); 51 | 52 | if (matchingFiles.length === 0) { 53 | throw new Error(`File not found: ${filePath}`); 54 | } 55 | 56 | const matchingFilePath = path.join(directoryPath, matchingFiles[0]); 57 | return fs.readFileSync(matchingFilePath, 'utf-8'); 58 | } 59 | 60 | private extractDependencies(): Dependency[] { 61 | const regex = /import\s+.*?\s+from\s+['"](.*?)['"]/g; 62 | const dependencies: Dependency[] = []; 63 | 64 | let match; 65 | while ((match = regex.exec(this.fileContent)) !== null) { 66 | const dependencyPath = match[1]; 67 | const absolutePath = path.join(this.basePath, dependencyPath); 68 | const dependencyContent = this.readFileIgnoreExtension(absolutePath); 69 | const nestedDependencies = new DependencyReader(absolutePath) 70 | .dependencies; 71 | dependencies.push( 72 | { path: absolutePath, content: dependencyContent }, 73 | ...nestedDependencies, 74 | ); 75 | } 76 | 77 | return dependencies; 78 | } 79 | 80 | public getDependencies(): Dependency[] { 81 | return this.dependencies; 82 | } 83 | } 84 | 85 | export default DependencyReader; 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "huskygpt", 3 | "version": "0.5.2", 4 | "description": "Automatically generate unit tests or review your code using openai chatgpt", 5 | "main": "lib/index.js", 6 | "type": "module", 7 | "source": "./src/index.ts", 8 | "types": "./build/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./build/index.js", 12 | "types": "./build/index.d.ts", 13 | "default": "./build/index.js" 14 | } 15 | }, 16 | "scripts": { 17 | "dev": "tsup --watch", 18 | "build": "tsup && cpy prompt build", 19 | "clean": "del build", 20 | "prebuild": "run-s clean", 21 | "predev": "run-s clean && cpy prompt build", 22 | "pretest": "run-s build", 23 | "pre-commit": "lint-staged", 24 | "prepare": "husky install", 25 | "prepublishOnly": "run-s test", 26 | "prettier": "prettier --write ./src", 27 | "test": "jest --watch" 28 | }, 29 | "files": [ 30 | "build", 31 | "bin" 32 | ], 33 | "bin": { 34 | "huskygpt": "./bin/cli.js" 35 | }, 36 | "engines": { 37 | "node": ">=14" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/luffy-xu/huskygpt" 42 | }, 43 | "author": "https://github.com/luffy-xu", 44 | "license": "MIT", 45 | "bugs": { 46 | "url": "https://github.com/luffy-xu/huskygpt/issues" 47 | }, 48 | "homepage": "https://github.com/luffy-xu/huskygpt#readme", 49 | "dependencies": { 50 | "@babel/generator": "^7.21.3", 51 | "@babel/parser": "^7.21.3", 52 | "@babel/traverse": "^7.21.3", 53 | "abort-controller": "^3.0.0", 54 | "chalk": "^5.2.0", 55 | "chatgpt": "^5.1.4", 56 | "commander": "^10.0.0", 57 | "dotenv": "^16.0.3", 58 | "inquirer": "^9.1.5", 59 | "isomorphic-fetch": "^3.0.0", 60 | "ora": "^5.4.1" 61 | }, 62 | "lint-staged": { 63 | "*.{ts,tsx}": [ 64 | "prettier --write" 65 | ] 66 | }, 67 | "keywords": [ 68 | "openai", 69 | "chatgpt", 70 | "chat", 71 | "gpt", 72 | "gpt-3", 73 | "gpt3", 74 | "gpt4", 75 | "conversation", 76 | "conversational ai", 77 | "ai", 78 | "test", 79 | "auto", 80 | "generate", 81 | "gen", 82 | "jest", 83 | "unit test", 84 | "code review" 85 | ], 86 | "devDependencies": { 87 | "@testing-library/jest-dom": "^5.16.5", 88 | "@testing-library/react": "^14.0.0", 89 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 90 | "@types/jest": "^29.5.0", 91 | "@types/node": "^18.15.10", 92 | "cpy-cli": "^4.2.0", 93 | "del-cli": "^5.0.0", 94 | "husky": "^8.0.3", 95 | "jest": "^29.5.0", 96 | "lint-staged": "^13.2.0", 97 | "npm-run-all": "^4.1.5", 98 | "prettier": "^2.8.7", 99 | "ts-jest": "^29.1.0", 100 | "ts-node": "^10.9.1", 101 | "tsup": "^6.7.0", 102 | "typescript": "^5.0.3" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # huskygpt 2 | Auto Review your code or Auto generate unit tests by OpenAI api gpt3.5 (GPT-4) 3 | 4 | ## Demo 5 | - `huskygpt review`: Review your code using the OpenAI API 6 | ![huskygpt-review](https://user-images.githubusercontent.com/105559892/229142794-a85a024c-faff-46cf-8de5-839aff983d9f.gif) 7 | - `huskygpt test`: Generates unit test using the OpenAI API 8 | ![huskygpt-test](https://user-images.githubusercontent.com/105559892/229142867-fb5768dc-d2d6-429c-8a20-b2adec087b6d.gif) 9 | 10 | ## Key Features 11 | - 🤖 Generates unit test using the OpenAI API 12 | - 🤖 Review your code using the OpenAI API 13 | - 🧠 Supports multiple OpenAI models and customizing the prompt 14 | - 📂 Supports reading test files from `directories` or `git staged files` 15 | - 🍺 Only pick up the `functions` or `class` code to OpenAI api for `security` and low `cost` 16 | - 💻 Supports typing review comments in the terminal 17 | 18 | ## Requirements 19 | - Node.js 14.0 or higher 20 | - An [OpenAI API key](https://platform.openai.com/account/api-keys) 21 | 22 | --- 23 | 24 | ## Example 25 | 26 | ### Getting started 27 | 1. Install the required dependencies by running the following command in your terminal: 28 | ```bash 29 | npm install 30 | ``` 31 | 1. Set your OpenAi API key global 32 | ``` 33 | npm config set OPENAI_API_KEY -g 34 | ``` 35 | 1. (Skip if set 2) Set your OpenAI API key as an environment variable in your local machine. You can do this by adding the following line to your `.env` or `.env.local`(preferred) file: 36 | ```bash 37 | OPENAI_API_KEY=YOR_API_KEY 38 | ``` 39 | 1. Make sure to replace `` with your actual API key. 40 | 1. Modify the `.env` file to define your own prompt and model configuration. 41 | 1. Run the script by running the following command in your terminal: 42 | ```bash 43 | // NOTE: If **.test.ts is already present in the project, will skip this file generation 44 | 45 | npm run huskygpt 46 | ``` 47 | - This will generate the test cases based on your prompt and model configuration, and print them to the console. 48 | - Review and modify the generated test cases as necessary to ensure they provide adequate coverage of your code. 49 | - Commit the generated test cases to your repository. 50 | 51 | ### Notes 52 | - The `openai` package for Node.js is used to interact with the OpenAI API. You can find more information about this package in the [official documentation](https://github.com/openai/openai-node 53 | ). 54 | - The generated test cases may not always cover all edge cases or error conditions, and may require manual review and refinement to ensure they provide adequate coverage of your code. Additionally, generating tests may not be the best approach for all types of projects, and may be more suitable for certain types of code or applications. 55 | - The example `xxx.test.ts` file is provided as a starting point and may need to be modified to suit your specific needs. 56 | -------------------------------------------------------------------------------- /src/utils/write-conflict.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get conflict result 3 | * @param sourceContent source content 4 | * @param targetContent target content 5 | * @returns conflict result 6 | */ 7 | function getConflictResult( 8 | sourceContent: string, 9 | targetContent: string, 10 | ): string { 11 | // Utility function to remove the start and end empty line of the content string 12 | const removeStartAndEndEmptyLine = (content: string): string[] => { 13 | const lines = content.split('\n'); 14 | let start = 0; 15 | let end = lines.length - 1; 16 | return lines.filter((line, index) => { 17 | if (line.trim() === '' && index === start) { 18 | start += 1; 19 | return false; 20 | } 21 | if (line.trim() === '' && index === end) { 22 | end -= 1; 23 | return false; 24 | } 25 | return true; 26 | }); 27 | }; 28 | 29 | // Get the line number of the first difference between source and target 30 | const findFirstNotSameLineNumber = ( 31 | sourceLines: string[], 32 | targetLines: string[], 33 | ): number => { 34 | let i = 0; 35 | for (; i < sourceLines.length && i < targetLines.length; i++) { 36 | if (sourceLines[i] !== targetLines[i]) { 37 | break; 38 | } 39 | } 40 | return i; 41 | }; 42 | 43 | // Find reverse same lines length 44 | const findReverseSameLinesLength = ( 45 | sourceLines: string[], 46 | targetLines: string[], 47 | ): number => { 48 | let i = 0; 49 | const sourceLinesReverse = sourceLines.slice().reverse(); 50 | const targetLinesReverse = targetLines.slice().reverse(); 51 | 52 | for ( 53 | ; 54 | i < sourceLinesReverse.length && i < targetLinesReverse.length; 55 | i++ 56 | ) { 57 | if (sourceLinesReverse[i] !== targetLinesReverse[i]) { 58 | break; 59 | } 60 | } 61 | return i; 62 | }; 63 | 64 | const sourceLines = removeStartAndEndEmptyLine(sourceContent); 65 | const targetLines = removeStartAndEndEmptyLine(targetContent); 66 | 67 | // if the source and target are the same, return the source 68 | if (sourceLines.join('\n') === targetLines.join('\n')) { 69 | return sourceContent; 70 | } 71 | 72 | const firstNotSameLineNumber = findFirstNotSameLineNumber( 73 | sourceLines, 74 | targetLines, 75 | ); 76 | const reverseSameLinesLength = findReverseSameLinesLength( 77 | sourceLines, 78 | targetLines, 79 | ); 80 | 81 | // Build the result array 82 | const resultLines: string[] = [ 83 | ...sourceLines.slice(0, firstNotSameLineNumber), 84 | '<<<<<<< HEAD', 85 | ...sourceLines.slice( 86 | firstNotSameLineNumber, 87 | sourceLines.length - reverseSameLinesLength, 88 | ), 89 | '=======', 90 | ...targetLines.slice( 91 | firstNotSameLineNumber, 92 | targetLines.length - reverseSameLinesLength, 93 | ), 94 | '>>>>>>> Incoming', 95 | ...sourceLines.slice(sourceLines.length - reverseSameLinesLength), 96 | ]; 97 | 98 | return resultLines.join('\n'); 99 | } 100 | 101 | export default getConflictResult; 102 | -------------------------------------------------------------------------------- /src/reader/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import ora from 'ora'; 3 | import path from 'path'; 4 | import { userOptions } from 'src/constant'; 5 | import { IReadFileResult, ReadTypeEnum } from 'src/types'; 6 | 7 | import ReadTestFilePathsByDirectory from './reader-directory'; 8 | import StagedFileReader from './reader-git-stage'; 9 | 10 | class ReadFiles { 11 | private dirPath: string; 12 | private fileExtensions: string[]; 13 | 14 | constructor({ 15 | dirPath = userOptions.readFilesRoot, 16 | fileExtensions = userOptions.readFilesExtensions, 17 | } = {}) { 18 | this.dirPath = dirPath; 19 | this.fileExtensions = fileExtensions; 20 | } 21 | 22 | readTypeMap: Record IReadFileResult[]> = { 23 | [ReadTypeEnum.Directory]: () => this.getTestFilePathByDir(), 24 | [ReadTypeEnum.GitStage]: () => this.getTestFilePathByGit(), 25 | }; 26 | 27 | // Get all file paths by directory 28 | private getTestFilePathByDir(): IReadFileResult[] { 29 | const reader = new ReadTestFilePathsByDirectory(); 30 | return reader.getDirFiles(this.dirPath); 31 | } 32 | 33 | // Get all file paths by git stage 34 | private getTestFilePathByGit(): IReadFileResult[] { 35 | const reader = new StagedFileReader(); 36 | return reader.getStagedFiles(); 37 | } 38 | 39 | // Check if a file has a valid extension 40 | private hasValidExtension(file: string): boolean { 41 | const extension = path.extname(file); 42 | if (!this.fileExtensions.length) return true; 43 | 44 | // Check if the file extension is in the list of valid extensions, both match .ts or ts 45 | return this.fileExtensions.some( 46 | (ext) => ext === extension || ext === extension.slice(1), 47 | ); 48 | } 49 | 50 | // Check if a file is a test file 51 | private isTestFile(file: string): boolean { 52 | const extension = path.extname(file); 53 | const testFileType = userOptions.options.testFileType; 54 | return file.endsWith(`.${testFileType}${extension}`); 55 | } 56 | 57 | // Get all file paths that are not test files 58 | public getFileResults( 59 | readFileType = userOptions.readFileType, 60 | ): IReadFileResult[] { 61 | if (!this.readTypeMap[readFileType]) 62 | throw new Error('Invalid test file read type'); 63 | 64 | const readSpinner = ora({ 65 | text: '🚀 [huskygpt] Reading files...', 66 | }).start(); 67 | 68 | try { 69 | const fileResults = this.readTypeMap[readFileType]().filter( 70 | ({ filePath: path }) => 71 | path && this.hasValidExtension(path) && !this.isTestFile(path), 72 | ); 73 | 74 | if (userOptions.options.debug) { 75 | console.log( 76 | '[huskygpt] read files ===>', 77 | fileResults.map((r) => r.filePath), 78 | ); 79 | } 80 | 81 | fileResults.length > 0 82 | ? readSpinner.succeed('🌟🌟 [huskygpt] read files successfully! 🌟🌟') 83 | : readSpinner.warn('🤔🤔 [huskygpt] read no files! 🤔🤔'); 84 | return fileResults; 85 | } catch (error) { 86 | readSpinner.fail(`[huskygpt] read files failed: ${error}`); 87 | throw error; 88 | } 89 | } 90 | } 91 | 92 | export default ReadFiles; 93 | -------------------------------------------------------------------------------- /src/huskygpt/test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { userOptions } from 'src/constant'; 4 | import { IReadFileResult } from 'src/types'; 5 | import { getAllCodeBlock, makeDirExist } from 'src/utils'; 6 | import getConflictResult from 'src/utils/write-conflict'; 7 | 8 | import HuskyGPTBase from './base'; 9 | 10 | /** 11 | * Generate a test case for a given file path 12 | */ 13 | class HuskyGPTTest extends HuskyGPTBase { 14 | /** 15 | * Get the file name without the extension 16 | */ 17 | private getFileNameWithoutExtension(filePath: string): string { 18 | return path.basename(filePath, path.extname(filePath)); 19 | } 20 | 21 | /** 22 | * Get the file extension 23 | */ 24 | private getFileExtension(filePath: string): string { 25 | return path.extname(filePath); 26 | } 27 | 28 | /** 29 | * Write a test message to a file 30 | */ 31 | private async writeTestMessageToFile( 32 | { filePath, fileContent }: IReadFileResult, 33 | message: string, 34 | ): Promise { 35 | // Write the message to a file 36 | try { 37 | const testFileDirName = userOptions.options.testFileDirName; 38 | if (!testFileDirName) throw new Error('testFileDirName is not set'); 39 | 40 | const dirPath = path.join(path.dirname(filePath), testFileDirName); 41 | const fileName = `${this.getFileNameWithoutExtension(filePath)}.${ 42 | userOptions.options.testFileType 43 | }${this.getFileExtension(filePath)}`; 44 | const testFilePath = path.join(dirPath, fileName); 45 | 46 | makeDirExist(dirPath); 47 | 48 | // If the test file doesn't exist, create it 49 | if (!fs.existsSync(testFilePath)) { 50 | // Write the message to the output file 51 | return fs.writeFileSync(testFilePath, message); 52 | } 53 | 54 | // If the file already exists, and file content is not same, merge existing file content 55 | const sourceFileContent = fs.readFileSync(filePath, 'utf-8'); 56 | if (fileContent !== sourceFileContent) { 57 | const testFileContent = fs.readFileSync(testFilePath, 'utf-8'); 58 | return fs.writeFileSync( 59 | testFilePath, 60 | `${testFileContent}\n${message}\n`, 61 | ); 62 | } 63 | 64 | // If the file already exists, and file content is same 65 | return fs.writeFileSync( 66 | testFilePath, 67 | getConflictResult(fileContent, message), 68 | ); 69 | } catch (error) { 70 | console.error('Error writing message to file:', error); 71 | } 72 | } 73 | 74 | /** 75 | * Generate a test case for a given file 76 | */ 77 | public async run(fileResult: IReadFileResult): Promise { 78 | // Reset the parent message to avoid the message tokens over limit 79 | this.openai.resetParentMessage(); 80 | const message = await this.openai.run(fileResult); 81 | if (!message?.length) return; 82 | 83 | const extractTestsCode = message 84 | .map((m) => getAllCodeBlock(m)) 85 | .join('\n\n'); 86 | await this.writeTestMessageToFile(fileResult, extractTestsCode); 87 | 88 | return extractTestsCode; 89 | } 90 | } 91 | 92 | export default HuskyGPTTest; 93 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The run mode of the huskygpt 3 | * test - Run the test file generation 4 | * review - Run the review file generation 5 | */ 6 | export enum HuskyGPTTypeEnum { 7 | Test = 'test', 8 | Review = 'review', 9 | Create = 'create', 10 | Translate = 'translate', 11 | Modify = 'modify', 12 | } 13 | 14 | /** 15 | * The file extensions to search for 16 | * @enum {string} 17 | * @property {string} Directory - Read test files from directory 18 | * @property {string} GitStage - Read test files from git stage 19 | * @readonly 20 | * @type {ReadTypeEnum} 21 | * @default 'dir' 22 | */ 23 | export enum ReadTypeEnum { 24 | Directory = 'dir', 25 | GitStage = 'git', 26 | } 27 | 28 | export interface IReadFileResult { 29 | filePath?: string; 30 | fileContent?: string; 31 | prompts?: string[]; 32 | } 33 | 34 | export interface IUserOptions { 35 | /** 36 | * OpenAI options 37 | */ 38 | 39 | /** 40 | * OpenAI options 41 | * @see https://platform.openai.com/account/api-keys 42 | */ 43 | /** 44 | * @name openAIKey 45 | */ 46 | openAIKey?: string; 47 | /** 48 | * @name openAISessionToken 49 | * @description OpenAI session token, 2 setp to get token, If you don't set this, will use OPENAI_API_KEY, will cause fee by api key 50 | * @description 1. visit https://chat.openai.com/chat and login 51 | * @description 2. Visit https://chat.openai.com/api/auth/session to get token 52 | */ 53 | openAISessionToken?: string; 54 | // More proxy url see https://github.com/transitive-bullshit/chatgpt-api#usage---chatgptunofficialproxyapi 55 | openAIProxyUrl?: string; 56 | // OpenAI model to use, api default is 'gpt-3.5-turbo', proxy default is 'text-davinci-002-render-sha' 57 | openAIModel?: string; 58 | // OpenAI prompt to use, default is '' 59 | openAIPrompt?: string; 60 | // OpenAI max tokens to use, default is 2048 61 | openAIMaxTokens?: number; 62 | 63 | /** 64 | * Huskygpt options 65 | */ 66 | // The type of huskygpt to run 67 | huskyGPTType?: HuskyGPTTypeEnum; 68 | // Debug mode, default is false 69 | debug?: boolean; 70 | // Security regex, if content test not pass, will throw error and not pass to openAI 71 | securityRegex?: string; 72 | 73 | /** 74 | * Read files options 75 | */ 76 | // Read files from directory or git stage, default is 'git' 77 | readType?: ReadTypeEnum; 78 | // Read files from git stage, default is 'R, M, A', R = modified, M = modified, A = added 79 | readGitStatus?: string; 80 | // The root name of the directory to read files from, default is 'src' 81 | readFilesRootName?: string; 82 | // The file extensions to read, default is '.ts,.tsx' 83 | readFileExtensions?: string; 84 | 85 | /** 86 | * Huskygpt test options 87 | */ 88 | // Generate test file type, default is 'test' 89 | testFileType?: string; 90 | // Generate test file directory name, default is '__tests__' 91 | testFileDirName?: string; 92 | 93 | /** 94 | * Huskygpt review options 95 | */ 96 | reviewReportWebhook?: string; 97 | 98 | /** 99 | * Huskygpt translate options 100 | */ 101 | // translate to language, like 'en,zh,es' 102 | translate?: string; 103 | } 104 | -------------------------------------------------------------------------------- /src/chatgpt/prompt.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { userOptions } from 'src/constant'; 3 | import { HuskyGPTTypeEnum, IReadFileResult } from 'src/types'; 4 | import { ExtractCodePrompts } from 'src/utils/extract-code-prompts'; 5 | import { readPromptFile } from 'src/utils/read-prompt-file'; 6 | 7 | export class HuskyGPTPrompt { 8 | private huskyGPTTypeMap: Record< 9 | HuskyGPTTypeEnum, 10 | (fileResult: IReadFileResult) => string[] 11 | > = { 12 | [HuskyGPTTypeEnum.Test]: (fileResult) => { 13 | const fileContent = 14 | fileResult.fileContent || 15 | fs.readFileSync(fileResult.filePath!, 'utf-8'); 16 | const testsPrompt = readPromptFile('tests.txt'); 17 | // const fileName = getFileNameByPath(fileResult.filePath!) 18 | // - Import the test function from "../${fileName}". 19 | const basePrompt = ` 20 | ${testsPrompt} 21 | ${userOptions.openAIPrompt || ''} 22 | `; 23 | 24 | const codePicker = new ExtractCodePrompts(); 25 | 26 | const codePrompts = codePicker.extractFunctionOrClassCodeArray({ 27 | ...fileResult, 28 | fileContent, 29 | }); 30 | 31 | return [basePrompt, ...codePrompts]; 32 | }, 33 | [HuskyGPTTypeEnum.Review]: (fileResult) => { 34 | const fileContent = 35 | fileResult.fileContent || 36 | fs.readFileSync(fileResult.filePath!, 'utf-8'); 37 | const reviewPrompt = readPromptFile('review.txt'); 38 | const basePrompt = ` 39 | ${reviewPrompt} 40 | ${userOptions.openAIPrompt || ''} 41 | `; 42 | 43 | const codePicker = new ExtractCodePrompts(); 44 | 45 | const codePrompts = codePicker.extractFunctionOrClassCodeArray({ 46 | ...fileResult, 47 | fileContent, 48 | }); 49 | 50 | return [basePrompt, ...codePrompts]; 51 | }, 52 | [HuskyGPTTypeEnum.Translate]: (fileResult) => { 53 | const fileContent = 54 | fileResult.fileContent || 55 | fs.readFileSync(fileResult.filePath!, 'utf-8'); 56 | const readPrompt = readPromptFile('translate.txt'); 57 | const basePrompt = ` 58 | ${readPrompt} 59 | - Target language: ${userOptions.options.translate} 60 | ${userOptions.openAIPrompt || ''} 61 | `; 62 | 63 | return [basePrompt, fileContent]; 64 | }, 65 | [HuskyGPTTypeEnum.Create]: ({ prompts }) => { 66 | if (!prompts) throw new Error('prompts is required for create'); 67 | const createPrompt = readPromptFile('create.txt'); 68 | 69 | return [ 70 | createPrompt, 71 | ...[ 72 | `${userOptions.openAIPrompt}\n${prompts.slice(0, 1)}`, 73 | ...prompts.slice(1), 74 | ], 75 | ]; 76 | }, 77 | [HuskyGPTTypeEnum.Modify]: ({ prompts }) => { 78 | const readPrompt = readPromptFile('modify.txt'); 79 | 80 | return [ 81 | readPrompt, 82 | ...[ 83 | `${userOptions.openAIPrompt}\n${prompts.slice(0, 1)}`, 84 | ...prompts.slice(1), 85 | ], 86 | ]; 87 | }, 88 | }; 89 | 90 | constructor(private huskyGPTType: HuskyGPTTypeEnum) {} 91 | 92 | public generatePrompt(fileResult: IReadFileResult): string[] { 93 | if (!fileResult) 94 | throw new Error('File path is required for generatePrompt'); 95 | if (!this.huskyGPTTypeMap[this.huskyGPTType]) 96 | throw new Error('Invalid huskyGPTType: ' + this.huskyGPTType); 97 | 98 | return this.huskyGPTTypeMap[this.huskyGPTType](fileResult); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Write your openai api key here or .env.local file 3 | # https://platform.openai.com/account/api-keys 4 | # https://openai.com/blog/openai-api/ 5 | # ------------------------------------------------------------------------------ 6 | 7 | # DEBUG=true 8 | 9 | # ----------------------------------------------------------------------------- 10 | # OpenAI Settings 11 | # ----------------------------------------------------------------------------- 12 | 13 | # Please write your key in .env.local or use config set your key global: npm config set OPENAI_API_KEY -g 14 | # OPENAI_API_KEY= 15 | 16 | # OpenAI session token, 2 setp to get token 17 | # If you don't set this, will use OPENAI_API_KEY, will cause fee by api key 18 | # 1. visit https://chat.openai.com/chat and login 19 | # 2. Visit https://chat.openai.com/api/auth/session to get token 20 | # OPENAI_SESSION_TOKEN= 21 | 22 | # More proxy url see https://github.com/transitive-bullshit/chatgpt-api#usage---chatgptunofficialproxyapi 23 | OPENAI_PROXY_URL=https://ai.fakeopen.com/api/conversation 24 | # OPENAI_PROXY_URL=https://bypass.churchless.tech/api/conversation 25 | 26 | ## Model For proxy type ## 27 | # OPENAI_MODEL=text-davinci-002-render-sha 28 | 29 | ## Model for api type ## 30 | # OpenAI model to use, gpt 4.0 support see https://platform.openai.com/docs/models/gpt-4 31 | # For review, can use gpt-3.5-turbo model, see https://platform.openai.com/docs/api-reference/chat/create 32 | # OPENAI_MODEL=gpt-4 33 | # OPENAI_MODEL=gpt-3.5-turbo 34 | 35 | 36 | # Set the maximum number of tokens to generate 37 | OPENAI_MAX_TOKENS=1000 38 | 39 | # Customized OpenAI Prompt 40 | # OPENAI_PROMPT=Please review following code, if there is bug or can be optimized reply me: 41 | 42 | # ----------------------------------------------------------------------------- 43 | # Read files settings 44 | # ----------------------------------------------------------------------------- 45 | 46 | # Read test file type, dir or git 47 | READ_TYPE=git 48 | 49 | # The git status to read files, A: Added, R: Renamed, M: Modified 50 | READ_GIT_STATUS=A,R,M, 51 | 52 | # The root name of the directory to read files from 53 | READ_FILES_ROOT_NAME=example 54 | 55 | # The file extensions to read 56 | READ_FILE_EXTENSIONS=.ts,.tsx,.jsx,.js 57 | 58 | # ------------------------------------------------------------------------------ 59 | # Test files settings 60 | # ------------------------------------------------------------------------------ 61 | 62 | # Generate test file type 63 | TEST_FILE_TYPE=test 64 | 65 | # Generate test file directory name 66 | TEST_FILE_DIR_NAME=__tests__ 67 | 68 | # ------------------------------------------------------------------------------ 69 | # Review settings 70 | # ------------------------------------------------------------------------------ 71 | 72 | # Review report, can be report result send to webhook 73 | # REVIEW_REPORT_WEBHOOK= 74 | 75 | # ------------------------------------------------------------------------------ 76 | # Translate settings 77 | # ------------------------------------------------------------------------------ 78 | 79 | # Translate language, the keys of i18n, like en, zh, fr, es, etc. 80 | # see 81 | TRANSLATE=zh,ko,ja 82 | 83 | # ----------------------------------------------------------------------------- 84 | # Security settings 85 | # ----------------------------------------------------------------------------- 86 | SECURITY_REGEX=([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})|(\b[A-Za-z]{1,2}\d{6,10}\b)|(\b(?:\d{4}[-\s]?){3}\d{4}\b)|(\b(?:\d{1,3}\.){3}\d{1,3}\b)|([STFG]\d{7}[A-Z])|(\+65\s\d{4}\s\d{4}|\+65\d{8}|\(65\)\d{8}) 87 | -------------------------------------------------------------------------------- /src/modify/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import inquirer from 'inquirer'; 3 | import ora from 'ora'; 4 | import { userOptions } from 'src/constant'; 5 | import { HuskyGPTModify } from 'src/huskygpt'; 6 | import { IReadFileResult } from 'src/types'; 7 | import getConflictResult from 'src/utils/write-conflict'; 8 | 9 | /** 10 | * Huskygpt Modify CLI 11 | */ 12 | class ModifyCLI { 13 | private huskygpt: HuskyGPTModify; 14 | 15 | constructor(private readFileResult: IReadFileResult[]) { 16 | this.init(); 17 | } 18 | 19 | private init() { 20 | this.huskygpt = new HuskyGPTModify(); 21 | } 22 | 23 | /** 24 | * Prompt description from user 25 | */ 26 | private async promptOptionDescription(): Promise { 27 | const { description } = await inquirer.prompt([ 28 | { 29 | type: 'input', 30 | name: 'description', 31 | default: `Please fix bugs or optimize my code, and extract constant variable or enum variable. if the function is complexity, please chunk it. If it's functional component, use react hooks optimize some UI component or functions. And add comments with ${ 32 | userOptions.options.translate || 'en' 33 | } language for complexity logic steps.`, 34 | messages: `Please input your modify requirements`, 35 | validate: (input: string) => 36 | input.trim() !== '' || 'Description cannot be empty.', 37 | }, 38 | ]); 39 | 40 | return description; 41 | } 42 | 43 | /** 44 | * Prompt continue or finish from user 45 | */ 46 | private async promptContinueOrFinish(): Promise { 47 | const { action } = await inquirer.prompt([ 48 | { 49 | type: 'list', 50 | name: 'action', 51 | message: 'Do you want to continue or finish?', 52 | choices: ['Continue', 'Finish'], 53 | }, 54 | ]); 55 | 56 | return action === 'Continue'; 57 | } 58 | 59 | // Write AI message to file 60 | private writeFile(filePath: string, newContent: string) { 61 | fs.writeFileSync( 62 | filePath, 63 | getConflictResult(fs.readFileSync(filePath, 'utf-8'), newContent), 64 | ); 65 | } 66 | 67 | // Run single file modify 68 | private async runSingleFile( 69 | fileResult: IReadFileResult, 70 | continueTimes: number, 71 | ) { 72 | if (!fileResult?.filePath) throw new Error('File path is empty'); 73 | 74 | console.log(`[huskygpt] Start modify ${fileResult.filePath}...`); 75 | const description = await this.promptOptionDescription(); 76 | const spinner = ora(`[huskygpt] Processing...`).start(); 77 | 78 | const prompts = [ 79 | continueTimes === 0 80 | ? `My fileContent is: ${fileResult.fileContent}.` 81 | : '', 82 | `Please modify previous code by following requirements: ${description}`, 83 | ]; 84 | const message = await this.huskygpt.run({ 85 | ...this.readFileResult, 86 | prompts: [prompts.join('\n')], 87 | }); 88 | if (!message?.length) { 89 | spinner.stop(); 90 | return; 91 | } 92 | 93 | this.writeFile(fileResult.filePath, message.join('\n')); 94 | 95 | spinner.stop(); 96 | } 97 | 98 | /** 99 | * Start CLI 100 | */ 101 | async start() { 102 | if (!this.readFileResult?.length) throw new Error('File path is empty'); 103 | 104 | let continuePrompt = true; 105 | let continueTimes = 0; 106 | 107 | while (continuePrompt) { 108 | for (const fileResult of this.readFileResult) { 109 | await this.runSingleFile(fileResult, continueTimes); 110 | } 111 | 112 | continuePrompt = await this.promptContinueOrFinish(); 113 | continueTimes += 1; 114 | } 115 | } 116 | } 117 | 118 | export default ModifyCLI; 119 | -------------------------------------------------------------------------------- /src/create/index.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import ora from 'ora'; 3 | 4 | import CreateCodeGenerator from './code-generator'; 5 | import { 6 | OptionType, 7 | OptionTypeExtension, 8 | messages, 9 | optionShortcuts, 10 | } from './constant'; 11 | 12 | /** 13 | * Huskygpt Create CLI 14 | */ 15 | class CreateCLI { 16 | private codeGenerator: CreateCodeGenerator; 17 | 18 | constructor() { 19 | this.init(); 20 | } 21 | 22 | private init() { 23 | this.codeGenerator = new CreateCodeGenerator(); 24 | } 25 | /** 26 | * Prompt option selection from user 27 | */ 28 | private async promptOptionSelection(): Promise { 29 | const { option } = await inquirer.prompt([ 30 | { 31 | type: 'list', 32 | name: 'option', 33 | message: messages.selectOption, 34 | choices: [ 35 | OptionType.Models, 36 | OptionType.Sections, 37 | OptionType.Pages, 38 | OptionType.Components, 39 | ].map((option) => ({ 40 | name: `${option} (${optionShortcuts[option]})`, 41 | value: option, 42 | })), 43 | }, 44 | ]); 45 | 46 | return option as OptionType; 47 | } 48 | 49 | /** 50 | * Prompt name from user 51 | */ 52 | private async promptName( 53 | option?: OptionType, 54 | defaultName?: string, 55 | ): Promise { 56 | const { name } = await inquirer.prompt([ 57 | { 58 | type: 'input', 59 | name: 'name', 60 | default: defaultName || option ? 'index' : 'exampleModule', 61 | message: option 62 | ? messages.enterName(option) 63 | : messages.enterDirectoryName, 64 | validate: (input: string) => { 65 | if (input.trim() === '') return messages.nameEmpty; 66 | if (!/^[a-z]+(?:[A-Z][a-z]*)*$/.test(input)) 67 | return 'Name must be in camelCase.'; 68 | return true; 69 | }, 70 | }, 71 | ]); 72 | 73 | return name; 74 | } 75 | 76 | /** 77 | * Prompt description from user 78 | */ 79 | private async promptOptionDescription(option: OptionType): Promise { 80 | const { description } = await inquirer.prompt([ 81 | { 82 | type: 'input', 83 | name: 'description', 84 | default: `Please input your requirements`, 85 | message: messages.enterDescription(option), 86 | validate: (input: string) => 87 | input.trim() !== '' || messages.descriptionEmpty, 88 | }, 89 | ]); 90 | 91 | return description; 92 | } 93 | 94 | /** 95 | * Prompt continue or finish from user 96 | */ 97 | private async promptContinueOrFinish(): Promise { 98 | const { action } = await inquirer.prompt([ 99 | { 100 | type: 'list', 101 | name: 'action', 102 | message: messages.continueOrFinish, 103 | choices: ['Continue', 'Finish'], 104 | }, 105 | ]); 106 | 107 | return action === 'Continue'; 108 | } 109 | 110 | /** 111 | * Start CLI 112 | */ 113 | async start() { 114 | // Prompt user for a directory name 115 | let continuePrompt = true; 116 | 117 | let dirName; 118 | // If user says yes, prompt for options and create a file 119 | while (continuePrompt) { 120 | const selectedOption = await this.promptOptionSelection(); 121 | dirName = await this.promptName(undefined, dirName); 122 | const name = await this.promptName(selectedOption); 123 | const description = await this.promptOptionDescription(selectedOption); 124 | const spinner = ora('[huskygpt] Processing...').start(); 125 | 126 | this.codeGenerator.setOptions({ 127 | option: selectedOption, 128 | name, 129 | dirName, 130 | description, 131 | }); 132 | 133 | await this.codeGenerator.generator(); 134 | 135 | spinner.stop(); 136 | continuePrompt = await this.promptContinueOrFinish(); 137 | } 138 | } 139 | } 140 | 141 | export { OptionType, OptionTypeExtension }; 142 | export default CreateCLI; 143 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from 'commander'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | import { main } from '../build/index.js'; 8 | 9 | // Assuming the current file is located at /home/user/project/app.js 10 | const dirname = fileURLToPath(new URL('.', import.meta.url)); 11 | const packageJson = JSON.parse( 12 | fs.readFileSync(path.join(dirname, '..', 'package.json'), 'utf8'), 13 | ); 14 | const program = new Command(); 15 | const runTypes = ['test', 'review', 'translate', 'create', 'modify']; 16 | 17 | program 18 | .version(packageJson.version, '-v, --version', 'output the current version') 19 | .description('Generate code, unit tests or review your code by chatgpt 4') 20 | .argument('', `run type: ${runTypes.join(', ')}`) 21 | .option('-k, --api-key ', 'Set the OpenAI API key') 22 | .option( 23 | '-t, --openai-session-token ', 24 | "OpenAI session token, 2 step to get token, If you don't set this, will use OPENAI_API_KEY, will cause fee by api key", 25 | ) 26 | .option( 27 | '-pu, --openai-proxy-url ', 28 | 'Proxy URL to use for OpenAI API requests', 29 | ) 30 | .option('-m, --model ', 'OpenAI model to use') 31 | .option('-p, --prompt ', 'OpenAI prompt to use') 32 | .option('-mt, --max-tokens ', 'OpenAI max tokens to use') 33 | .option( 34 | '-e, --file-extensions ', 35 | 'File extensions to read, example: .ts,.tsx', 36 | ) 37 | .option( 38 | '-r, --read-type ', 39 | 'Read files from directory or git stage, example: dir or git', 40 | ) 41 | .option( 42 | '-s, --read-git-status ', 43 | 'Read files from git stage by status default: A,R,M', 44 | ) 45 | .option( 46 | '-d, --read-dir-name ', 47 | 'Root name of the directory to read files from, example: src', 48 | ) 49 | .option( 50 | '-f, --test-file-type ', 51 | 'Generate test file type, example: test or spec', 52 | ) 53 | .option( 54 | '-n, --test-file-dir-name ', 55 | 'Generate test file directory name, example: __tests__', 56 | ) 57 | .option( 58 | '-w, --review-report-webhook ', 59 | 'Webhook URL to send review report', 60 | ) 61 | .option( 62 | '-trans, --translate ', 63 | 'Translate the code to other languages, example: zh,en', 64 | ) 65 | .action((runType, options) => { 66 | if (!runTypes.includes(runType)) { 67 | // exit with error 68 | console.error( 69 | `Invalid run type: ${runType}, please use one of ${runTypes.join(',')}`, 70 | ); 71 | process.exit(1); 72 | } 73 | 74 | const userOptions = { 75 | huskyGPTType: runType, 76 | reviewTyping: options.reviewTyping, 77 | ...(options.apiKey && { openAIKey: options.apiKey }), 78 | ...(options.model && { openAIModel: options.model }), 79 | ...(options.prompt && { openAIPrompt: options.prompt }), 80 | ...(options.maxTokens && { openAIMaxTokens: Number(options.maxTokens) }), 81 | ...(options.fileExtensions && { 82 | readFileExtensions: options.fileExtensions, 83 | }), 84 | ...(options.readType && { readType: options.readType }), 85 | ...(options.readGitStatus && { readGitStatus: options.readGitStatus }), 86 | ...(options.readDirName && { readFilesRootName: options.readDirName }), 87 | ...(options.testFileType && { testFileType: options.testFileType }), 88 | ...(options.testFileDirName && { 89 | testFileDirName: options.testFileDirName, 90 | }), 91 | ...(options.reviewReportWebhook && { 92 | reviewReportWebhook: options.reviewReportWebhook, 93 | }), 94 | ...(options.openAISessionToken && { 95 | openAISessionToken: options.openAISessionToken, 96 | }), 97 | ...(options.openAIProxyUrl && { 98 | openAIProxyUrl: options.openAIProxyUrl, 99 | }), 100 | ...(options.translate && { 101 | translate: options.translate, 102 | }), 103 | }; 104 | 105 | main(userOptions); 106 | }); 107 | 108 | program.parse(process.argv); 109 | -------------------------------------------------------------------------------- /src/chatgpt/__tests__/send-message.test.ts: -------------------------------------------------------------------------------- 1 | import { ChatMessage, SendMessageOptions } from 'chatgpt'; 2 | 3 | import { handleContinueMessage, sendMessageWithRetry } from '../send-message'; 4 | 5 | const sendMessage = jest.fn(); 6 | const OPENAI_MAX_RETRY = 3; 7 | 8 | describe('sendMessageWithRetry', () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | it('should return the message if sendMessage is successful', async () => { 14 | sendMessage.mockResolvedValueOnce({ message: 'Hello' }); 15 | const res = await sendMessageWithRetry(sendMessage, OPENAI_MAX_RETRY); 16 | expect(res).toEqual({ message: 'Hello' }); 17 | }); 18 | 19 | it('should retry if the error has status code 429', async () => { 20 | sendMessage.mockRejectedValueOnce({ statusCode: 429 }); 21 | sendMessage.mockResolvedValueOnce({ message: 'Hello' }); 22 | const res = await sendMessageWithRetry(sendMessage, OPENAI_MAX_RETRY, 100); 23 | expect(res).toEqual({ message: 'Hello' }); 24 | expect(sendMessage).toHaveBeenCalledTimes(2); 25 | }); 26 | 27 | it('should not retry if the error has status code 401', async () => { 28 | sendMessage.mockRejectedValueOnce({ statusCode: 401 }); 29 | await expect( 30 | sendMessageWithRetry(sendMessage, OPENAI_MAX_RETRY), 31 | ).rejects.toEqual({ statusCode: 401 }); 32 | expect(sendMessage).toHaveBeenCalledTimes(1); 33 | }); 34 | 35 | it('should throw an error if the maximum number of retries is reached', async () => { 36 | sendMessage.mockRejectedValue({ message: 'Error' }); 37 | await expect(sendMessageWithRetry(sendMessage, 1)).rejects.toThrowError( 38 | 'sendMessage failed after retries', 39 | ); 40 | expect(sendMessage).toHaveBeenCalledTimes(1); 41 | }); 42 | }); 43 | 44 | describe('handleContinueMessage', () => { 45 | const mockSendMessage: ( 46 | messageText: string, 47 | sendOptions?: SendMessageOptions, 48 | ) => Promise = async (messageText, sendOptions) => ({ 49 | id: '1', 50 | conversationId: '1', 51 | role: 'user', 52 | text: messageText, 53 | }); 54 | 55 | it('should return the same message if there are no unmatched code block symbols', async () => { 56 | const message: ChatMessage = { 57 | id: '1', 58 | conversationId: '1', 59 | text: 'Test message without code blocks', 60 | role: 'user', 61 | }; 62 | 63 | const result = await handleContinueMessage(message, {}, mockSendMessage); 64 | expect(result).toEqual(message); 65 | }); 66 | 67 | it('should return a combined message after a single continue attempt', async () => { 68 | const message: ChatMessage = { 69 | id: '1', 70 | conversationId: '1', 71 | role: 'user', 72 | text: 'Test message with `code` and ```unmatched code block', 73 | }; 74 | 75 | const mockFunc = jest.fn().mockImplementationOnce(() => ({ 76 | id: '2', 77 | conversationId: '1', 78 | text: 'Continued message with ```', 79 | timestamp: new Date(), 80 | })); 81 | 82 | const result = await handleContinueMessage(message, {}, mockFunc); 83 | expect(result.text).toBe( 84 | 'Test message with `code` and ```unmatched code blockContinued message with ```', 85 | ); 86 | expect(mockFunc).toHaveBeenCalledTimes(1); 87 | }); 88 | 89 | it('should return a combined message after multiple continue attempts', async () => { 90 | const message: ChatMessage = { 91 | id: '1', 92 | conversationId: '1', 93 | role: 'user', 94 | text: 'Test message with `code` and ```unmatched code block', 95 | }; 96 | 97 | const mockFunc = jest 98 | .fn() 99 | .mockImplementationOnce(() => ({ 100 | id: '2', 101 | conversationId: '1', 102 | text: 'Continued message.', 103 | })) 104 | .mockImplementationOnce(() => ({ 105 | id: '3', 106 | conversationId: '1', 107 | text: 'Another continued message with ```', 108 | })); 109 | 110 | const result = await handleContinueMessage(message, {}, mockFunc); 111 | expect(result.text).toBe( 112 | 'Test message with `code` and ```unmatched code blockContinued message.Another continued message with ```', 113 | ); 114 | expect(mockFunc).toHaveBeenCalledTimes(2); 115 | }); 116 | 117 | it('should stop continuing after reaching the maximum attempts', async () => { 118 | const message: ChatMessage = { 119 | id: '1', 120 | conversationId: '1', 121 | role: 'user', 122 | text: 'Test message with `code` and ```unmatched code block', 123 | }; 124 | 125 | const mockFunc = jest.fn().mockImplementation(() => ({ 126 | id: '2', 127 | conversationId: '1', 128 | text: 'Continued message.', 129 | })); 130 | 131 | const result = await handleContinueMessage(message, {}, mockFunc, 3); 132 | expect(result.text).toBe( 133 | 'Test message with `code` and ```unmatched code blockContinued message.Continued message.Continued message.', 134 | ); 135 | expect(mockFunc).toHaveBeenCalledTimes(3); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/create/code-generator.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { userOptions } from 'src/constant'; 4 | import { HuskyGPTCreate } from 'src/huskygpt'; 5 | import { getFileNameToCamelCase, makeDirExist } from 'src/utils'; 6 | import { readPromptFile } from 'src/utils/read-prompt-file'; 7 | import getConflictResult from 'src/utils/write-conflict'; 8 | 9 | import { IOptionCreated, OptionType, OptionTypeExtension } from './constant'; 10 | 11 | interface IWriteFileOptions { 12 | fileName: string; 13 | fileContent: string; 14 | optionType: OptionType; 15 | needCreateDir?: boolean; 16 | rootDirPath?: string; 17 | } 18 | 19 | /** 20 | * Code Generator for create command 21 | * @usage new CreateCodeGenerator(options).run() 22 | */ 23 | class CreateCodeGenerator { 24 | private option: OptionType; 25 | private description: string; 26 | private dirName: string; 27 | private name: string; 28 | private huskygpt: HuskyGPTCreate; 29 | 30 | constructor() { 31 | this.init(); 32 | } 33 | 34 | private init() { 35 | this.huskygpt = new HuskyGPTCreate(); 36 | } 37 | 38 | // Get crete prompts 39 | private getPrompts() { 40 | const { option, description, dirName, name } = this; 41 | const basePrompts = [ 42 | `${readPromptFile(`create-${option}.txt`)} 43 | Please note is's modelName is "${dirName}${getFileNameToCamelCase( 44 | name, 45 | true, 46 | )}", and reply "${option}" code by following requirements: ${description}. 47 | `, 48 | ]; 49 | if (option === OptionType.Models) { 50 | basePrompts.push( 51 | `${readPromptFile(`create-${OptionType.Services}.txt`)} 52 | Note that you should consider the method name and relationship between the "${ 53 | OptionType.Models 54 | }" that you reply before. 55 | Please reply "${ 56 | OptionType.Services 57 | }" code by following requirements: ${description}. 58 | `, 59 | ); 60 | basePrompts.push( 61 | `${readPromptFile(`create-${OptionType.Mock}.txt`)} 62 | Note that you should consider the requests api path and relationship between the "${ 63 | OptionType.Services 64 | }" that you reply before. 65 | Please reply "${ 66 | OptionType.Mock 67 | }" code by following requirements: ${description}. 68 | `, 69 | ); 70 | } 71 | return basePrompts; 72 | } 73 | 74 | // Write AI message to file 75 | private writeFile(options: IWriteFileOptions) { 76 | const { 77 | fileName, 78 | fileContent, 79 | needCreateDir, 80 | optionType, 81 | rootDirPath = '', 82 | } = options; 83 | const dirPath = 84 | rootDirPath || 85 | path.join( 86 | process.cwd(), 87 | userOptions.options.readFilesRootName, 88 | optionType, 89 | needCreateDir ? getFileNameToCamelCase(this.dirName, true) : '', 90 | ); 91 | makeDirExist(dirPath); 92 | const filePath = path.join( 93 | dirPath, 94 | `${fileName}.${OptionTypeExtension[optionType]}`, 95 | ); 96 | 97 | const existFileContent = 98 | fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf-8'); 99 | fs.writeFileSync( 100 | filePath, 101 | existFileContent 102 | ? getConflictResult(existFileContent, fileContent) 103 | : fileContent, 104 | ); 105 | } 106 | 107 | // Handle models option 108 | private handleModelsOption(dirName: string, name: string, message: string[]) { 109 | const [modelContent, serviceContent, mockContent] = message; 110 | const fileName = `${dirName}${getFileNameToCamelCase(name, true)}`; 111 | this.writeFile({ 112 | fileName, 113 | fileContent: modelContent, 114 | needCreateDir: false, 115 | optionType: OptionType.Models, 116 | }); 117 | this.writeFile({ 118 | fileName, 119 | fileContent: serviceContent, 120 | needCreateDir: false, 121 | optionType: OptionType.Services, 122 | }); 123 | this.writeFile({ 124 | fileName, 125 | fileContent: mockContent, 126 | rootDirPath: path.join(process.cwd(), OptionType.Mock), 127 | optionType: OptionType.Mock, 128 | }); 129 | } 130 | 131 | // Set options 132 | setOptions(options: IOptionCreated) { 133 | this.option = options.option; 134 | this.description = options.description; 135 | this.dirName = options.dirName; 136 | this.name = options.name; 137 | } 138 | 139 | // Run code generator 140 | async generator() { 141 | const prompts = this.getPrompts(); 142 | const message = await this.huskygpt.run({ prompts }); 143 | if (!message.length) return; 144 | 145 | if ([OptionType.Models].includes(this.option)) { 146 | this.handleModelsOption(this.dirName, this.name, message); 147 | return; 148 | } 149 | 150 | let optionType = this.option; 151 | if ([OptionType.Sections].includes(this.option)) { 152 | optionType = OptionType.Pages; 153 | } 154 | 155 | this.writeFile({ 156 | fileName: this.name, 157 | fileContent: message.join('\n'), 158 | needCreateDir: true, 159 | optionType: optionType, 160 | }); 161 | } 162 | } 163 | 164 | export default CreateCodeGenerator; 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # huskygpt 2 | > Node.js CLI tools for `auto review` your code or `auto generate` unit tests by OpenAI `chatgpt3.5` and `GPT-4` Plus Account! ✅ 3 | 4 | [![NPM](https://img.shields.io/npm/v/huskygpt.svg)](https://www.npmjs.com/package/huskygpt) [![Prettier Code Formatting](https://img.shields.io/badge/code_style-prettier-brightgreen.svg)](https://prettier.io) 5 | 6 | ## Demo 7 | - 🤖 Generate `unit tests` by gpt-4 model: 8 | ![huskygpt-unit-test](https://user-images.githubusercontent.com/105559892/229816192-1cc2c885-b298-41be-9114-7b6b5b2195e8.gif) 9 | - ✨ The `unit test` result: 10 | ![format-test](https://user-images.githubusercontent.com/105559892/229817346-66e272ff-e12a-4d6f-9100-fe445ddd79f1.png) 11 | - 🌍 `Translate` source file keep the same format and structure: 12 | ![translate](https://user-images.githubusercontent.com/105559892/232946990-8ec9ae57-c668-40cc-8e1c-8e9eed2c4eac.gif) 13 | - 🖊️ `Modify` exist code by your input requirements e.g. 14 | ![modify](https://user-images.githubusercontent.com/105559892/234238162-d9cfcc33-8b5a-4f7d-b3d5-d940598cf449.png) 15 | > Please fix bugs or optimize my code. if the function is complexity, please chunk it. If it's function component, use hooks optimize it. And add en and zh comments for complexity logic steps e.g. // EN: some comments, // ZH: 一些评论. 16 | 17 | 18 | ## Key Features 19 | - 🤖 `AI`: AI-powered code `review`, `modify`, `translate` and unit `test` generation 20 | - ✨ `Free`: Free to use with an `OpenAI Session Token`, enjoy chatgpt-3.5 or gpt-4 (Plus Account). 21 | - 🛡️ `Security`: Security-conscious function and class extraction, customize your `SECURITY_REGEX`. 22 | - 🧠 `Customizing`: Customizable prompts and model selection. 23 | - 📂 `File Reader`: Supports reading files from `directories` or `git staged files`. 24 | 25 | 26 | ## Installation 27 | To install `huskygpt`, run the following command: 28 | ``` 29 | npm install -g huskygpt 30 | ``` 31 | 32 | ## Configuration 33 | ### OpenAI Key (Choose one) 34 | - Set the [OpenAI API Key](https://platform.openai.com/account/api-keys) by npm config set -g 35 | ``` 36 | npm config set OPENAI_API_KEY -g 37 | ``` 38 | - Set the `OpenAI Session Token` for free using chatgpt 39 | - OpenAI session token, 2 setp to get token 40 | - If you don't set this, will use OPENAI_API_KEY 41 | 1. visit https://chat.openai.com/chat and login 42 | 2. Visit https://chat.openai.com/api/auth/session to get token 43 | ```bash 44 | npm config set OPENAI_SESSION_TOKEN -g 45 | ``` 46 | 3. Copy [`.env`](https://github.com/luffy-xu/huskygpt/blob/main/.env) file to your project root directory, and set `OPENAI_PROXY_URL`. 47 | 48 | | Method | Free? | Robust? | Quality? | 49 | | --------------------------- | ------ | -------- | ----------------------- | 50 | | `OpenAI Session Token` | ✅ Yes | ☑️ Maybe | ✅️ Real ChatGPT | 51 | | `OpenAI API Key` | ❌ No | ✅ Yes | ✅ Real ChatGPT models | 52 | 53 | 54 | ### Local prompt 55 | 1. Create `prompt` directory in the root directory of your project. 56 | 1. Add `review.txt` or `tests.txt` in the `prompt` directory. 57 | 58 | ### Pre-Commit 59 | 1. [husky](https://github.com/typicode/husky) and [lint-stage](https://github.com/okonet/lint-staged) 60 | ``` 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "huskygpt review && huskygpt test && lint-staged --allow-empty" 64 | } 65 | }, 66 | ``` 67 | 68 | ### `.gitignore`: 69 | ``` 70 | # review 71 | .huskygpt_review.md 72 | .env.local 73 | ``` 74 | 75 | ## Usage 76 | - Run the following command to `review` your git staged files: 77 | ``` 78 | huskygpt review --model gpt-4 --max-tokens 2048 79 | ``` 80 | - Run the following command to `modify` your exist code: 81 | ``` 82 | huskygpt modify -r dir -d src/pages/UserRegister/RegisterList.tsx -m gpt-4 83 | ``` 84 | - Run the following command to generate unit `test`: 85 | ``` 86 | huskygpt test --model gpt-3.5-turbo --max-tokens 2048 --file-extensions .ts,.tsx --read-type dir --read-dir-name src --test-file-type test --test-file-extension .ts --test-file-dir-name tests 87 | ``` 88 | - Run the following command to `translate` your git staged files: 89 | ``` 90 | huskygpt translate -d example/i18n/test.json 91 | ``` 92 | 93 | ### Options 94 | 95 | - `-k, --api-key `: Set the OpenAI API key. 96 | - `-t, --openai-session-token `: OpenAI session token, 2 step to get token, If you don't set this, will use OPENAI_API_KEY, will cause fee by api key. 97 | - `-pu, --openai-proxy-url `: Proxy URL to use for OpenAI API requests. 98 | - `-m, --model `: OpenAI model to use. 99 | - `-p, --prompt `: OpenAI prompt to use. 100 | - `-mt, --max-tokens `: OpenAI max tokens to use. 101 | - `-e, --file-extensions `: File extensions to read, example: .ts,.tsx 102 | - `-r, --read-type `: Read files from directory or git stage, example: dir or git. 103 | - `-s, --read-git-status `: Read files from git stage by status default: A,R,M. 104 | - `-d, --read-dir-name `: Root name of the directory to read files from, example: src. 105 | - `-f, --test-file-type `: Generate test file type, example: test or spec. 106 | - `-n, --test-file-dir-name `: Generate test file directory name, example: __tests__. 107 | - `-o, --test-file-overwrite `: Generate test file overwrite, default is true. 108 | - `-w, --review-report-webhook `: Webhook URL to send review report. 109 | 110 | ### Environment Variables options 111 | See [`.env`](https://github.com/luffy-xu/huskygpt/blob/main/.env) file. 112 | 113 | ## Note 114 | 1. Also can set all options in `.env` or `.env.local`, that will be used as default options. Command options will override the default options. 115 | 2. Webhook currently only test in `seaTalk`, if other channel need to use, please rise `PR` by yourself or ask [me](swhd0501@gmail.com) for help. 116 | 117 | -------------------------------------------------------------------------------- /src/utils/extract-modify-funcs.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | class GitDiffExtractor { 4 | /** 5 | * Retrieves the git diff output for the specified file path. 6 | * @param filePath - The file path to retrieve the git diff output for. 7 | * @returns The git diff output as a string. 8 | */ 9 | private getGitDiffOutput(filePath: string): string { 10 | return execSync(`git diff --cached ${filePath}`).toString(); 11 | } 12 | 13 | /** 14 | * Gets the line numbers of the modified lines in the git diff output. 15 | * @param diffLines - The git diff output lines. 16 | * @returns An array of modified line numbers. 17 | */ 18 | private getModifiedLineNumbers(diffLines: string[]): number[] { 19 | const modifiedLineNumbers: number[] = []; 20 | let currentLineNumber = 0; 21 | 22 | for (const line of diffLines) { 23 | if (line.startsWith('@@ ')) { 24 | const match = line.match(/\+(\d+)/); 25 | if (match) { 26 | currentLineNumber = parseInt(match[1], 10) - 1; 27 | } 28 | } else if ( 29 | line.startsWith('+') && 30 | !line.startsWith('++') && 31 | !line.startsWith('@@') 32 | ) { 33 | modifiedLineNumbers.push(currentLineNumber); 34 | currentLineNumber++; 35 | } else if (!line.startsWith('-')) { 36 | currentLineNumber++; 37 | } 38 | } 39 | 40 | return modifiedLineNumbers; 41 | } 42 | 43 | /** 44 | * Extracts a code block containing a function or class from the specified line number. 45 | * @param lines - The lines of the target file. 46 | * @param lineNumber - The line number of the modified line. 47 | * @returns The extracted code block as a string or null if not found. 48 | */ 49 | private extractCodeBlock(lines: string[], lineNumber: number): string | null { 50 | let startLine = lineNumber; 51 | let endLine = lineNumber; 52 | const blockPattern = 53 | /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\b|class\b|.*=>|\(.*\)\s*=>|\(\s*\)\s*=>)/; 54 | const classPattern = /^\s*(?:export\s+)?(?:default\s+)?class\b/; 55 | 56 | // Search for the beginning of a code block (function, class, or arrow function) 57 | while (startLine >= 0 && !lines[startLine].match(blockPattern)) { 58 | startLine--; 59 | } 60 | 61 | // If the code block is a class, set the endLine to the closing brace of the class 62 | if (lines[startLine] && lines[startLine].match(classPattern)) { 63 | endLine = this.findClosingBrace(lines, startLine); 64 | } else { 65 | let openBraces = 0; 66 | 67 | for (let i = startLine; i < lines.length; i++) { 68 | const line = lines[i]; 69 | openBraces += this.countChar(line, '{'); 70 | openBraces -= this.countChar(line, '}'); 71 | 72 | if (openBraces === 0) { 73 | endLine = i; 74 | break; 75 | } 76 | } 77 | } 78 | 79 | if (startLine < 0 || endLine >= lines.length || !lines[startLine]) { 80 | return null; 81 | } 82 | 83 | return lines.slice(startLine, endLine + 1).join('\n'); 84 | } 85 | 86 | /** 87 | * Finds the line number of the closing brace of a class. 88 | * @param lines - The lines of the target file. 89 | * @param startLine - The line number of the opening brace of the class. 90 | * @returns The line number of the closing brace of the class. 91 | */ 92 | private findClosingBrace(lines: string[], startLine: number): number { 93 | let openBraces = 0; 94 | 95 | for (let i = startLine; i < lines.length; i++) { 96 | const line = lines[i]; 97 | openBraces += this.countChar(line, '{'); 98 | openBraces -= this.countChar(line, '}'); 99 | 100 | if (openBraces === 0) { 101 | return i; 102 | } 103 | } 104 | 105 | return lines.length - 1; 106 | } 107 | 108 | /** 109 | * Counts the occurrences of a specific character in a string. 110 | * @param line - The string to search for the character in. 111 | * @param char - The character to count occurrences of. 112 | * @returns The number of occurrences of the character in the string. 113 | */ 114 | private countChar(line: string, char: string): number { 115 | return line?.split(char).length - 1 || 0; 116 | } 117 | 118 | /** 119 | * Checks if the specified code block is contained in any of the existing code blocks. 120 | */ 121 | private isCodeBlockContainedInExistingBlocks( 122 | codeBlock: string, 123 | existingBlocks: string[], 124 | ): boolean { 125 | for (const existingBlock of existingBlocks) { 126 | if (existingBlock.includes(codeBlock)) { 127 | return true; 128 | } 129 | } 130 | return false; 131 | } 132 | 133 | /** 134 | * Adds the specified code block to the array of extracted code blocks if it is not already contained in the array. 135 | */ 136 | private addCodeBlockIfNotContained(blocks: string[], newBlock: string): void { 137 | if ( 138 | !this.isCodeBlockContainedInExistingBlocks(newBlock, blocks) && 139 | !blocks.includes(newBlock) 140 | ) { 141 | blocks.push(newBlock); 142 | } 143 | } 144 | 145 | /** 146 | * Extracts the modified functions from the specified file. 147 | */ 148 | public extractModifiedFunction( 149 | filePath: string, 150 | contents: string, 151 | ): string | null { 152 | const diffOutput = this.getGitDiffOutput(filePath); 153 | const diffLines = diffOutput?.split('\n'); 154 | if (!diffLines || !contents) return null; 155 | 156 | const modifiedLineNumbers = this.getModifiedLineNumbers(diffLines); 157 | if (modifiedLineNumbers.length === 0) return null; 158 | 159 | const lines = contents.split('\n'); 160 | const extractedCodeBlocks: string[] = []; 161 | 162 | for (const lineNumber of modifiedLineNumbers) { 163 | const codeBlock = this.extractCodeBlock(lines, lineNumber); 164 | if (codeBlock) { 165 | this.addCodeBlockIfNotContained(extractedCodeBlocks, codeBlock); 166 | } 167 | } 168 | 169 | return extractedCodeBlocks.join('\n\n'); 170 | } 171 | } 172 | 173 | export default GitDiffExtractor; 174 | -------------------------------------------------------------------------------- /example/src/utils/__test__/filter-table-base.test.ts: -------------------------------------------------------------------------------- 1 | import { FilterTableBase } from '../filter-table-base'; 2 | 3 | describe('FilterTableBase', () => { 4 | let filterTableBase: FilterTableBase; 5 | beforeEach(() => { 6 | filterTableBase = new FilterTableBase(); 7 | }); 8 | 9 | describe('initItem', () => { 10 | it('should set itemId to null', () => { 11 | filterTableBase.initItem(); 12 | expect(filterTableBase.itemId).toBeNull(); 13 | }); 14 | 15 | it('should set itemDetail to null', () => { 16 | filterTableBase.initItem(); 17 | expect(filterTableBase.itemDetail).toBeNull(); 18 | }); 19 | }); 20 | 21 | describe('setItemId', () => { 22 | it('should set itemId to given value', () => { 23 | const itemId = 'testId'; 24 | filterTableBase.setItemId(itemId); 25 | expect(filterTableBase.itemId).toBe(itemId); 26 | }); 27 | }); 28 | 29 | describe('setItemDetail', () => { 30 | it('should set itemDetail to given value', () => { 31 | const itemDetail = { test: 'test' }; 32 | filterTableBase.setItemDetail(itemDetail); 33 | expect(filterTableBase.itemDetail).toBe(itemDetail); 34 | }); 35 | }); 36 | 37 | // describe('getItemDetail', () => { 38 | // it('should return itemDetail', () => { 39 | // const itemDetail = { test: 'test' }; 40 | // filterTableBase.setItemDetail(itemDetail); 41 | // expect(filterTableBase.getItemDetail()).toBe(itemDetail); 42 | // }); 43 | // }); 44 | 45 | describe('getItemDetailData', () => { 46 | it('should return a promise', () => { 47 | expect(filterTableBase.getItemDetailData()).toBeInstanceOf(Promise); 48 | }); 49 | }); 50 | 51 | describe('reset', () => { 52 | it('should set filter to empty object', () => { 53 | filterTableBase.reset(); 54 | expect(filterTableBase.filter).toEqual({}); 55 | }); 56 | }); 57 | 58 | describe('addFilter', () => { 59 | it('should add given filter to filter', () => { 60 | const filter = { test: 'test' }; 61 | filterTableBase.addFilter(filter); 62 | expect(filterTableBase.filter).toEqual(filter); 63 | }); 64 | 65 | // it('should call autoResetPageNum', () => { 66 | // const spy = jest.spyOn(filterTableBase, 'autoResetPageNum'); 67 | // filterTableBase.addFilter({}); 68 | // expect(spy).toHaveBeenCalled(); 69 | // }); 70 | }); 71 | 72 | describe('addFilterDebounce', () => { 73 | it('should add given filter to filter', () => { 74 | const filter = { test: 'test' }; 75 | filterTableBase.addFilterDebounce(filter); 76 | expect(filterTableBase.filter).toEqual(filter); 77 | }); 78 | 79 | // it('should call autoResetPageNum', () => { 80 | // const spy = jest.spyOn(filterTableBase, 'autoResetPageNum'); 81 | // filterTableBase.addFilterDebounce({}); 82 | // expect(spy).toHaveBeenCalled(); 83 | // }); 84 | }); 85 | 86 | describe('addTableFilter', () => { 87 | it('should add given filter to tableFilter', () => { 88 | const filter = { test: 'test' }; 89 | filterTableBase.addTableFilter(filter); 90 | expect(filterTableBase.tableFilter).toEqual(filter); 91 | }); 92 | }); 93 | 94 | describe('setTableFilter', () => { 95 | it('should set tableFilter to given filter', () => { 96 | const filter = { test: 'test' }; 97 | filterTableBase.setTableFilter(filter); 98 | expect(filterTableBase.tableFilter).toEqual(filter); 99 | }); 100 | 101 | // it('should call autoResetPageNum', () => { 102 | // const spy = jest.spyOn(filterTableBase, 'autoResetPageNum'); 103 | // filterTableBase.setTableFilter({}); 104 | // expect(spy).toHaveBeenCalled(); 105 | // }); 106 | }); 107 | 108 | describe('setFilter', () => { 109 | it('should set filter to given filter', () => { 110 | const filter = { test: 'test' }; 111 | filterTableBase.setFilter(filter); 112 | expect(filterTableBase.filter).toEqual(filter); 113 | }); 114 | 115 | // it('should call autoResetPageNum', () => { 116 | // const spy = jest.spyOn(filterTableBase, 'autoResetPageNum'); 117 | // filterTableBase.setFilter({}); 118 | // expect(spy).toHaveBeenCalled(); 119 | // }); 120 | }); 121 | 122 | describe('setList', () => { 123 | it('should set list to given data', () => { 124 | const data = { 125 | entities: [{ test: 'test' }], 126 | total: 1, 127 | }; 128 | filterTableBase.setList(data); 129 | expect(filterTableBase.list).toEqual(data.entities); 130 | }); 131 | 132 | it('should set pagination to given data', () => { 133 | const data = { 134 | entities: [{ test: 'test' }], 135 | total: 1, 136 | }; 137 | filterTableBase.setList(data); 138 | expect(filterTableBase.pagination.config.total).toBe(data.total); 139 | }); 140 | }); 141 | 142 | describe('getData', () => { 143 | it('should return a promise', () => { 144 | expect(filterTableBase.getData({})).toBeInstanceOf(Promise); 145 | }); 146 | }); 147 | 148 | describe('getList', () => { 149 | it('should call getData with given config', async () => { 150 | const spy = jest.spyOn(filterTableBase, 'getData'); 151 | const config = { pageSize: 10, current: 1 }; 152 | await filterTableBase.getList(config); 153 | expect(spy).toHaveBeenCalledWith({ 154 | pageSize: config.pageSize, 155 | pageNum: config.current, 156 | }); 157 | }); 158 | 159 | // it('should call getData with filter and tableFilter', async () => { 160 | // const spy = jest.spyOn(filterTableBase, 'getData'); 161 | // const filter = { test: 'test' }; 162 | // const tableFilter = { test2: 'test2' }; 163 | // filterTableBase.filter = filter; 164 | // filterTableBase.tableFilter = tableFilter; 165 | // await filterTableBase.getList(); 166 | // expect(spy).toHaveBeenCalledWith({ 167 | // ...filter, 168 | // ...tableFilter, 169 | // }); 170 | // }); 171 | 172 | it('should set list and pagination with data from getData', async () => { 173 | const data = { 174 | entities: [{ test: 'test' }], 175 | total: 1, 176 | }; 177 | jest.spyOn(filterTableBase, 'getData').mockResolvedValue(data); 178 | await filterTableBase.getList(); 179 | expect(filterTableBase.list).toEqual(data.entities); 180 | expect(filterTableBase.pagination.config.total).toBe(data.total); 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/chatgpt/index.ts: -------------------------------------------------------------------------------- 1 | import { AbortController } from 'abort-controller'; 2 | import chalk from 'chalk'; 3 | import { 4 | ChatGPTAPI, 5 | ChatGPTUnofficialProxyAPI, 6 | ChatMessage, 7 | SendMessageOptions, 8 | } from 'chatgpt'; 9 | import ora from 'ora'; 10 | import { HuskyGPTPrompt } from 'src/chatgpt/prompt'; 11 | import { userOptions } from 'src/constant'; 12 | import { HuskyGPTTypeEnum, IReadFileResult } from 'src/types'; 13 | 14 | import { handleContinueMessage, sendMessageWithRetry } from './send-message'; 15 | 16 | export class ChatgptProxyAPI { 17 | private api: ChatGPTUnofficialProxyAPI | ChatGPTAPI; 18 | private parentMessage?: ChatMessage; 19 | 20 | constructor() { 21 | this.initApi(); 22 | } 23 | 24 | get needPrintMessage(): boolean { 25 | return true; 26 | } 27 | 28 | private initApi() { 29 | if (process.env.DEBUG) 30 | console.log(`openAI session token: "${userOptions.openAISessionToken}"`); 31 | 32 | console.log( 33 | '[huskygpt] Using Model:', 34 | chalk.green(userOptions.openAIModel), 35 | ); 36 | // Use the official api if the session token is not set 37 | if (!userOptions.openAISendByProxy) { 38 | this.api = new ChatGPTAPI({ 39 | apiKey: userOptions.openAIKey, 40 | completionParams: userOptions.openAIOptions, 41 | debug: userOptions.options.debug, 42 | }); 43 | return; 44 | } 45 | 46 | // Use the proxy api 47 | this.api = new ChatGPTUnofficialProxyAPI({ 48 | model: userOptions.openAIModel, 49 | accessToken: userOptions.openAISessionToken, 50 | apiReverseProxyUrl: userOptions.options.openAIProxyUrl, 51 | }); 52 | } 53 | 54 | /** 55 | * Generate prompt for the OpenAI API 56 | */ 57 | private generatePrompt(fileResult: IReadFileResult): string[] { 58 | // Set the file content as the prompt for the API request 59 | const huskyGPTType = new HuskyGPTPrompt(userOptions.huskyGPTType); 60 | 61 | return huskyGPTType.generatePrompt(fileResult); 62 | } 63 | 64 | /** 65 | * Is the review passed? 66 | */ 67 | private isReviewPassed(message: string): boolean { 68 | if (userOptions.huskyGPTType !== HuskyGPTTypeEnum.Review) return true; 69 | return /perfect!/gi.test(message); 70 | } 71 | 72 | /** 73 | * Log the review info 74 | */ 75 | private oraStart( 76 | text = '', 77 | needPrintMessage = this.needPrintMessage, 78 | ): ora.Ora { 79 | if (!needPrintMessage) return ora(); 80 | 81 | return ora({ 82 | text, 83 | spinner: { 84 | interval: 800, 85 | frames: ['🚀', '🤖', '🚀', '🤖', '🚀', '🤖', '🚀', '🤖'], 86 | }, 87 | }).start(); 88 | } 89 | 90 | /** 91 | * Run the OpenAI API 92 | */ 93 | 94 | // Send the prompt to the API 95 | private async sendPrompt( 96 | prompt: string, 97 | prevMessage?: Partial, 98 | ): Promise { 99 | const securityPrompt = userOptions.securityPrompt(prompt); 100 | 101 | // If this is the first message, send it directly 102 | if (!prevMessage) { 103 | return await sendMessageWithRetry(() => 104 | this.api.sendMessage(securityPrompt), 105 | ); 106 | } 107 | 108 | // Send the message with the progress callback 109 | const reviewSpinner = this.oraStart(); 110 | const controller = new AbortController(); 111 | const signal = controller.signal; 112 | const sendOptions: SendMessageOptions = { 113 | ...prevMessage, 114 | // Set the timeout to 5 minutes 115 | timeoutMs: 1000 * 60 * 5, 116 | // @ts-ignore 117 | abortSignal: signal, 118 | onProgress: (partialResponse) => { 119 | reviewSpinner.text = partialResponse.text; 120 | }, 121 | }; 122 | 123 | try { 124 | let resMessage = await sendMessageWithRetry(() => 125 | this.api.sendMessage(securityPrompt, sendOptions), 126 | ); 127 | 128 | // Handle continue message logic 129 | resMessage = await handleContinueMessage(resMessage, (message, options) => 130 | this.api.sendMessage(message, { ...sendOptions, ...options }), 131 | ); 132 | 133 | // Check if the review is passed 134 | const isReviewPassed = this.isReviewPassed(resMessage.text); 135 | const colorText = isReviewPassed 136 | ? chalk.green(resMessage.text) 137 | : chalk.yellow(resMessage.text); 138 | 139 | // Stop the spinner 140 | reviewSpinner[isReviewPassed ? 'succeed' : 'fail']( 141 | `[huskygpt] ${colorText} \n `, 142 | ); 143 | 144 | return resMessage; 145 | } catch (error) { 146 | // Stop the spinner 147 | reviewSpinner.fail(`[huskygpt] ${error.message} \n `); 148 | controller.abort(); 149 | throw error; 150 | } 151 | } 152 | 153 | /** 154 | * Generate a prompt for a given file, then send it to the OpenAI API 155 | */ 156 | async sendFileResult(fileResult: IReadFileResult): Promise { 157 | const promptArray = this.generatePrompt(fileResult); 158 | const [systemPrompt, ...codePrompts] = promptArray; 159 | if (userOptions.options.debug) { 160 | console.log('[huskygpt] systemPrompt:', systemPrompt); 161 | console.log('[huskygpt] codePrompts:', codePrompts.length, codePrompts); 162 | } 163 | if (!codePrompts.length) return []; 164 | 165 | const messageArray: string[] = []; 166 | let message = this.parentMessage || (await this.sendPrompt(systemPrompt)); 167 | 168 | for (const prompt of codePrompts) { 169 | message = await this.sendPrompt(prompt, { 170 | conversationId: message?.conversationId, 171 | parentMessageId: message?.id, 172 | }); 173 | messageArray.push(message.text); 174 | this.parentMessage = message; 175 | } 176 | 177 | return messageArray; 178 | } 179 | 180 | /** 181 | * Reset the parent message 182 | */ 183 | public resetParentMessage() { 184 | this.parentMessage = undefined; 185 | } 186 | 187 | /** 188 | * Start the huskygpt process 189 | */ 190 | async run(fileResult: IReadFileResult): Promise { 191 | const reviewSpinner = this.oraStart( 192 | chalk.cyan( 193 | `[huskygpt] start ${userOptions.huskyGPTType} your code... \n`, 194 | ), 195 | ); 196 | 197 | return this.sendFileResult(fileResult) 198 | .then((res) => { 199 | reviewSpinner.succeed( 200 | chalk.green( 201 | `🎉🎉 [huskygpt] ${userOptions.huskyGPTType} code successfully! 🎉🎉\n `, 202 | ), 203 | ); 204 | return res; 205 | }) 206 | .catch((error) => { 207 | console.error('run error:', error); 208 | reviewSpinner.fail( 209 | chalk.red( 210 | `🤔🤔 [huskygpt] ${userOptions.huskyGPTType} your code failed! 🤔🤔\n`, 211 | ), 212 | ); 213 | return ['[huskygpt] call OpenAI API failed!']; 214 | }) 215 | .finally(() => { 216 | reviewSpinner.stop(); 217 | }); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | import { ChatGPTAPIOptions } from 'chatgpt'; 2 | import { execSync } from 'child_process'; 3 | import { config } from 'dotenv'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | import { HuskyGPTTypeEnum, IUserOptions, ReadTypeEnum } from './types'; 8 | 9 | export const OPENAI_API_KEY_NAME = 'OPENAI_API_KEY'; 10 | export const OPENAI_SESSION_TOKEN_NAME = 'OPENAI_SESSION_TOKEN'; 11 | 12 | // Fetch openAI api retry times 13 | export const OPENAI_MAX_RETRY = 3; 14 | // Fetch openAI api max continues times 15 | export const OPENAI_MAX_CONTINUES = 5; 16 | 17 | const DEFAULT_MODELS = { 18 | apiModel: 'gpt-3.5-turbo', 19 | proxyModel: 'text-davinci-002-render-sha', 20 | }; 21 | 22 | export const ROOT_SRC_DIR_PATH = __dirname; 23 | // export const ROOT_SRC_DIR_PATH = path.join( 24 | // new URL('.', import.meta.url).pathname, 25 | // ); 26 | 27 | class UserOptionsClass { 28 | options: IUserOptions; 29 | 30 | private userOptionsDefault: IUserOptions = { 31 | debug: false, 32 | huskyGPTType: HuskyGPTTypeEnum.Review, 33 | openAIModel: '', 34 | openAIProxyUrl: 'https://bypass.churchless.tech/api/conversation', 35 | openAIMaxTokens: 4096, 36 | readType: ReadTypeEnum.GitStage, 37 | readGitStatus: 'R, M, A', 38 | readFilesRootName: 'src', 39 | readFileExtensions: '.ts,.tsx', 40 | testFileType: 'test', 41 | testFileDirName: '__test__', 42 | reviewReportWebhook: '', 43 | translate: 'zh,en', 44 | }; 45 | 46 | /** 47 | * Get huskygpt run type 48 | * @example 49 | * // returns 'test' 50 | */ 51 | get huskyGPTType(): HuskyGPTTypeEnum { 52 | if (!this.options.huskyGPTType) throw new Error('huskyGPTType is not set'); 53 | return this.options.huskyGPTType; 54 | } 55 | 56 | // get open AI key from npm config 57 | private getOpenAIKeyFromNpmConfig(key: string): string { 58 | try { 59 | return execSync(`npm config get ${key}`).toString().trim(); 60 | } catch (error) { 61 | return ''; 62 | } 63 | } 64 | 65 | /** 66 | * Get OpenAI API key 67 | * @example 68 | * @returns 'sk-1234567890' 69 | */ 70 | get openAIKey(): string { 71 | if (!this.options.openAIKey) { 72 | this.options.openAIKey = 73 | this.getOpenAIKeyFromNpmConfig(OPENAI_API_KEY_NAME); 74 | } 75 | 76 | if (!this.options.openAIKey) throw new Error('openAIKey is not set'); 77 | 78 | if (process.env.DEBUG) 79 | console.log(`openAI key: "${this.options.openAIKey}"`); 80 | 81 | return this.options.openAIKey; 82 | } 83 | 84 | /** 85 | * Get OpenAI session token 86 | */ 87 | get openAISessionToken(): string { 88 | if (!this.options.openAISessionToken) { 89 | this.options.openAISessionToken = this.getOpenAIKeyFromNpmConfig( 90 | OPENAI_SESSION_TOKEN_NAME, 91 | ); 92 | } 93 | 94 | return this.options.openAISessionToken; 95 | } 96 | 97 | /** 98 | * Get OpenAI send message type, proxy or api 99 | */ 100 | get openAISendByProxy(): boolean { 101 | return ( 102 | this.options.openAIProxyUrl && 103 | this.openAISessionToken && 104 | this.openAISessionToken !== 'undefined' 105 | ); 106 | } 107 | 108 | get openAIModel(): string { 109 | if (this.openAISendByProxy) { 110 | if (this.options.openAIModel === DEFAULT_MODELS.apiModel) { 111 | console.warn( 112 | '[huskygpt] openAIModel is set to gpt-3.5-turbo, but use proxy type, so openAIModel is set to text-davinci-002-render-sha', 113 | ); 114 | return (this.options.openAIModel = DEFAULT_MODELS.proxyModel); 115 | } 116 | 117 | return this.options.openAIModel || DEFAULT_MODELS.proxyModel; 118 | } 119 | return this.options.openAIModel || DEFAULT_MODELS.apiModel; 120 | } 121 | 122 | /** 123 | * Get OpenAI options 124 | */ 125 | get openAIOptions(): ChatGPTAPIOptions['completionParams'] { 126 | if (!this.openAIModel) throw new Error('openAIModel is not set'); 127 | 128 | return { 129 | temperature: 0, 130 | top_p: 0.4, 131 | stop: ['###'], 132 | model: this.openAIModel, 133 | max_tokens: this.options.openAIMaxTokens, 134 | }; 135 | } 136 | 137 | /** 138 | * Get the root directory path to read files from 139 | * @example 140 | * // returns '/Users/username/project/src' 141 | */ 142 | get readFilesRoot(): string { 143 | if (!this.options.readFilesRootName) 144 | throw new Error('readFilesRootName is not set'); 145 | return path.join(process.cwd(), this.options.readFilesRootName); 146 | } 147 | 148 | /** 149 | * Get the file extensions to read 150 | * @example 151 | * // returns ['.ts', '.tsx'] 152 | */ 153 | get readFilesExtensions(): string[] { 154 | if (!this.options.readFileExtensions) 155 | throw new Error('readFileExtensions is not set'); 156 | return this.options.readFileExtensions.split(','); 157 | } 158 | 159 | /** 160 | * File read type, either 'dir' or 'git' 161 | */ 162 | get readFileType(): ReadTypeEnum { 163 | if (!this.options.readType) throw new Error('readType is not set'); 164 | return this.options.readType; 165 | } 166 | 167 | /** 168 | * Get user openAIPrompt arg, if prompts is a file path, read the file 169 | */ 170 | get openAIPrompt(): string { 171 | const { openAIPrompt } = this.options; 172 | 173 | if (!openAIPrompt) return ''; 174 | 175 | // Split the string by comma to get individual file paths 176 | const filePaths = openAIPrompt.split(','); 177 | 178 | // Filter out invalid file paths and read each file's content 179 | const filesContent = filePaths 180 | .filter( 181 | (filePath) => fs.existsSync(filePath) && fs.statSync(filePath).isFile(), 182 | ) 183 | .map((filePath) => fs.readFileSync(filePath.trim(), 'utf-8')) 184 | .join('\n'); 185 | 186 | return filesContent 187 | ? `Note here is context that you need understand: ${filesContent}.` 188 | : openAIPrompt; 189 | } 190 | 191 | /** 192 | * Convert the process.env to user options 193 | */ 194 | private convertProcessEnvToUserOptions( 195 | processEnv: NodeJS.ProcessEnv, 196 | ): IUserOptions { 197 | return { 198 | debug: process.env.DEBUG === 'true', 199 | securityRegex: process.env.SECURITY_REGEX || '', 200 | openAIKey: processEnv.OPENAI_API_KEY, 201 | openAISessionToken: processEnv.OPENAI_SESSION_TOKEN, 202 | openAIProxyUrl: 203 | processEnv.OPENAI_PROXY_URL || this.userOptionsDefault.openAIProxyUrl, 204 | openAIModel: 205 | processEnv.OPENAI_MODEL || this.userOptionsDefault.openAIModel, 206 | openAIMaxTokens: Number( 207 | processEnv.OPENAI_MAX_TOKENS || this.userOptionsDefault.openAIMaxTokens, 208 | ), 209 | /** 210 | * Read file options 211 | */ 212 | readType: 213 | (processEnv.READ_TYPE as ReadTypeEnum) || 214 | this.userOptionsDefault.readType, 215 | readGitStatus: 216 | processEnv.READ_GIT_STATUS || this.userOptionsDefault.readGitStatus, 217 | readFilesRootName: 218 | processEnv.READ_FILES_ROOT_NAME || 219 | this.userOptionsDefault.readFilesRootName, 220 | readFileExtensions: 221 | processEnv.READ_FILE_EXTENSIONS || 222 | this.userOptionsDefault.readFileExtensions, 223 | /** 224 | * Test file options 225 | */ 226 | testFileType: 227 | processEnv.TEST_FILE_TYPE || this.userOptionsDefault.testFileType, 228 | testFileDirName: 229 | processEnv.TEST_FILE_DIR_NAME || 230 | this.userOptionsDefault.testFileDirName, 231 | /** 232 | * Review options 233 | */ 234 | reviewReportWebhook: processEnv.REVIEW_REPORT_WEBHOOK, 235 | /** 236 | * Translate options 237 | */ 238 | translate: processEnv.TRANSLATE || this.userOptionsDefault.translate, 239 | }; 240 | } 241 | 242 | /** 243 | * Security test 244 | * If return false, the prompt does not pass the security test 245 | */ 246 | public securityPrompt(prompt: string): string { 247 | if (!this.options.securityRegex) return prompt; 248 | 249 | const regex = new RegExp(this.options.securityRegex, 'gi'); 250 | 251 | return prompt.replace(regex, 'REMOVED'); 252 | } 253 | 254 | /** 255 | * Initialize the user options 256 | */ 257 | 258 | public init(userOptions: IUserOptions = {}) { 259 | // Read the .env file 260 | config(); 261 | config({ path: path.join(process.cwd(), '.env.local') }); 262 | const envUserOptions = this.convertProcessEnvToUserOptions(process.env); 263 | 264 | if (process.env.DEBUG) { 265 | console.log('envUserOptions: ', envUserOptions); 266 | console.log('userOptions: ', userOptions); 267 | } 268 | 269 | this.options = Object.assign( 270 | {}, 271 | this.userOptionsDefault, 272 | envUserOptions, 273 | userOptions, 274 | ); 275 | } 276 | } 277 | 278 | export const userOptions = new UserOptionsClass(); 279 | 280 | /** 281 | * Review result configs 282 | */ 283 | export const codeBlocksRegex = /```([\s\S]*?)```/g; 284 | 285 | export const codeBlocksMdSymbolRegex = /```(\w?)*/g; 286 | 287 | // Write the output text to a file if there are code blocks 288 | export const reviewFileName = '.huskygpt_review.md'; 289 | --------------------------------------------------------------------------------