From eae7618d575ee8d2e9fff5de56da79d40c4bc5fc Mon Sep 17 00:00:00 2001 From: di-sukharev Date: Mon, 6 Mar 2023 22:55:02 +0800 Subject: [PATCH] =?UTF-8?q?*=20=F0=9F=93=9D=20docs(TODO.md):=20add=20TODOs?= =?UTF-8?q?=20Added=20a=20TODO=20list=20with=20tasks=20that=20need=20to=20?= =?UTF-8?q?be=20completed.=20These=20tasks=20include=20building=20for=20bo?= =?UTF-8?q?th=20mjs=20and=20cjs,=20configuring=20esbuild=20to=20make=20the?= =?UTF-8?q?=20bundle=20smaller,=20adding=20//=20TODOs=20in=20the=20code,?= =?UTF-8?q?=20batching=20small=20files=20in=20one=20request,=20adding=20te?= =?UTF-8?q?sts,=20and=20making=20the=20hook=20work.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(api.ts): add OpenAI class with generateCommitMessage method This commit adds a new OpenAI class with a generateCommitMessage method that uses the OpenAI API to generate a commit message based on an array of messages. The method takes an array of ChatCompletionRequestMessage objects and returns a Promise that resolves to a ChatCompletionResponseMessage object. The OpenAI class is exported as a singleton instance named api. * ✨ feat(prepare-commit-msg-hook.ts): add support for generating commit messages with chat completion This commit adds support for generating commit messages with chat completion. The `prepare-commit-msg-hook.ts` file now imports the `generateCommitMessageWithChatCompletion` function from the `generateCommitMessageFromGitDiff` module. The function generates a commit message based on the staged git diff and appends it to the commit message file. If the `OPENAI_API_KEY` environment variable is not set, an error is thrown. If the commit source is specified, the function returns without generating a commit message. * 🆕 feat(generateCommitMessageFromGitDiff.ts): add functionality to generate commit messages from git diff This commit adds a new file, generateCommitMessageFromGitDiff.ts, which contains a function that generates commit messages from the output of the 'git diff --staged' command. The function uses the OpenAI API to prompt the user to create a commit message in the conventional commit convention. The user can choose to use Gitmoji convention to preface the commit and add a short description of what the commit is about. * 🐛 fix(server.ts): change port variable case from lowercase port to uppercase PORT * ✨ feat(server.ts): add support for process.env.PORT environment variable The port variable is now named PORT, which improves consistency with the naming conventions as PORT is a constant. Support for an environment variable allows the application to be more flexible as it can now run on any available port specified via the process.env.PORT environment variable. * 🚀 feat: add function to generate commit messages from diff This commit adds a new function that generates commit messages from a diff. The function takes a diff as input and splits it into files. It then generates commit messages for each file and returns them as a concatenated string. If the total length of the commit message exceeds the maximum allowed length, the function skips the file. If the commit message is empty, the function skips the file. If an error occurs during the process, the function returns an error. * ✨ feat(git.ts): add function to assert git repository existence * ✨ feat(git.ts): add function to get staged git diff The assertGitRepo function checks if the current directory is a git repository by running the 'git rev-parse' command. If the command fails, an error is thrown. The getStagedGitDiff function returns the staged diff of the git repository. It takes an optional boolean argument isStageAllFlag, which when true stages all changes before getting the diff. The function uses the 'git diff --staged' command to get the diff and excludes big files from the diff. The function returns an object with two properties: files, which is an array of the names of the files that have changes, and diff, which is the diff of the staged changes. --- TODO.md | 8 ++ src/api.ts | 66 +++++++++++ src/commands/commit.ts | 104 ++++++++++++++++++ src/commands/config.ts | 140 ++++++++++++++++++++++++ src/commands/githook.ts | 85 ++++++++++++++ src/commands/prepare-commit-msg-hook.ts | 47 ++++++++ src/generateCommitMessageFromGitDiff.ts | 127 +++++++++++++++++++++ src/utils/git.ts | 49 +++++++++ 8 files changed, 626 insertions(+) create mode 100644 TODO.md create mode 100644 src/api.ts create mode 100644 src/commands/commit.ts create mode 100644 src/commands/config.ts create mode 100644 src/commands/githook.ts create mode 100644 src/commands/prepare-commit-msg-hook.ts create mode 100644 src/generateCommitMessageFromGitDiff.ts create mode 100644 src/utils/git.ts diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..75c109be --- /dev/null +++ b/TODO.md @@ -0,0 +1,8 @@ +# TODOs + +- [] [build for both mjs and cjs](https://snyk.io/blog/best-practices-create-modern-npm-package/) +- [] make bundle smaller by properly configuring esbuild +- [] do // TODOs in the code +- [] batch small files in one request +- [] add tests +- [] make hook work diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000..743bea4c --- /dev/null +++ b/src/api.ts @@ -0,0 +1,66 @@ +import { intro, outro } from '@clack/prompts'; +import { + ChatCompletionRequestMessage, + ChatCompletionResponseMessage, + Configuration as OpenAiApiConfiguration, + OpenAIApi +} from 'openai'; + +import { getConfig } from './commands/config'; + +const config = getConfig(); + +let apiKey = config?.OPENAI_API_KEY; + +if (!apiKey) { + intro('opencommit'); + + outro( + 'OPENAI_API_KEY is not set, please run `oc config set OPENAI_API_KEY=`' + ); + outro( + 'For help Look into README https://github.com/di-sukharev/opencommit#setup' + ); +} + +// if (!apiKey) { +// intro('opencommit'); +// const apiKey = await text({ +// message: 'input your OPENAI_API_KEY' +// }); + +// setConfig([[CONFIG_KEYS.OPENAI_API_KEY as string, apiKey as any]]); + +// outro('OPENAI_API_KEY is set'); +// } + +class OpenAi { + private openAiApiConfiguration = new OpenAiApiConfiguration({ + apiKey: apiKey + }); + + private openAI = new OpenAIApi(this.openAiApiConfiguration); + + public generateCommitMessage = async ( + messages: Array + ): Promise => { + try { + const { data } = await this.openAI.createChatCompletion({ + model: 'gpt-3.5-turbo', + messages, + temperature: 0, + top_p: 0.1, + max_tokens: 196 + }); + + const message = data.choices[0].message; + + return message; + } catch (error) { + console.error('openAI api error', { error }); + throw error; + } + }; +} + +export const api = new OpenAi(); diff --git a/src/commands/commit.ts b/src/commands/commit.ts new file mode 100644 index 00000000..e5624ecd --- /dev/null +++ b/src/commands/commit.ts @@ -0,0 +1,104 @@ +import { execa } from 'execa'; +import { + GenerateCommitMessageErrorEnum, + generateCommitMessageWithChatCompletion +} from '../generateCommitMessageFromGitDiff'; +import { assertGitRepo, getStagedGitDiff } from '../utils/git'; +import { spinner, confirm, outro, isCancel, intro } from '@clack/prompts'; +import chalk from 'chalk'; + +const generateCommitMessageFromGitDiff = async ( + diff: string +): Promise => { + await assertGitRepo(); + + const commitSpinner = spinner(); + commitSpinner.start('Generating the commit message'); + const commitMessage = await generateCommitMessageWithChatCompletion(diff); + + if (typeof commitMessage !== 'string') { + const errorMessages = { + [GenerateCommitMessageErrorEnum.emptyMessage]: + 'empty openAI response, weird, try again', + [GenerateCommitMessageErrorEnum.internalError]: + 'internal error, try again', + [GenerateCommitMessageErrorEnum.tooMuchTokens]: + 'too much tokens in git diff, stage and commit files in parts' + }; + + outro(`${chalk.red('✖')} ${errorMessages[commitMessage.error]}`); + process.exit(1); + } + + commitSpinner.stop('📝 Commit message generated'); + + outro( + `Commit message: +${chalk.grey('——————————————————')} +${commitMessage} +${chalk.grey('——————————————————')}` + ); + + const isCommitConfirmedByUser = await confirm({ + message: 'Confirm the commit message' + }); + + if (isCommitConfirmedByUser && !isCancel(isCommitConfirmedByUser)) { + await execa('git', ['commit', '-m', commitMessage]); + outro(`${chalk.green('✔')} successfully committed`); + } else outro(`${chalk.gray('✖')} process cancelled`); +}; + +export async function commit(isStageAllFlag = false) { + intro('open-commit'); + + const stagedFilesSpinner = spinner(); + stagedFilesSpinner.start('Counting staged files'); + const staged = await getStagedGitDiff(isStageAllFlag); + + if (!staged && isStageAllFlag) { + outro( + `${chalk.red( + 'No changes detected' + )} — write some code, stage the files ${chalk + .hex('0000FF') + .bold('`git add .`')} and rerun ${chalk + .hex('0000FF') + .bold('`oc`')} command.` + ); + + process.exit(1); + } + + if (!staged) { + outro( + `${chalk.red('Nothing to commit')} — stage the files ${chalk + .hex('0000FF') + .bold('`git add .`')} and rerun ${chalk + .hex('0000FF') + .bold('`oc`')} command.` + ); + + stagedFilesSpinner.stop('Counting staged files'); + const isStageAllAndCommitConfirmedByUser = await confirm({ + message: 'Do you want to stage all files and generate commit message?' + }); + + if ( + isStageAllAndCommitConfirmedByUser && + !isCancel(isStageAllAndCommitConfirmedByUser) + ) { + await commit(true); + } + + process.exit(1); + } + + stagedFilesSpinner.stop( + `${staged.files.length} staged files:\n${staged.files + .map((file) => ` ${file}`) + .join('\n')}` + ); + + await generateCommitMessageFromGitDiff(staged.diff); +} diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 00000000..d6b65d82 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,140 @@ +import { command } from 'cleye'; +import { join as pathJoin } from 'path'; +import { parse as iniParse, stringify as iniStringify } from 'ini'; +import { existsSync, writeFileSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { intro, outro } from '@clack/prompts'; +import chalk from 'chalk'; + +export enum CONFIG_KEYS { + OPENAI_API_KEY = 'OPENAI_API_KEY', + description = 'description', + emoji = 'emoji' +} + +const validateConfig = ( + key: string, + condition: any, + validationMessage: string +) => { + if (!condition) { + throw new Error(`Unsupported config key ${key}: ${validationMessage}`); + } +}; + +export const configValidators = { + [CONFIG_KEYS.OPENAI_API_KEY](value: any) { + validateConfig(CONFIG_KEYS.OPENAI_API_KEY, value, 'Cannot be empty'); + validateConfig( + CONFIG_KEYS.OPENAI_API_KEY, + value.startsWith('sk-'), + 'Must start with "sk-"' + ); + validateConfig( + CONFIG_KEYS.OPENAI_API_KEY, + value.length === 51, + 'Must be 51 characters long' + ); + + return value; + }, + [CONFIG_KEYS.description](value: any) { + validateConfig( + CONFIG_KEYS.description, + typeof value === 'boolean', + 'Must be true or false' + ); + + return value; + }, + [CONFIG_KEYS.emoji](value: any) { + validateConfig( + CONFIG_KEYS.emoji, + typeof value === 'boolean', + 'Must be true or false' + ); + + return value; + } +}; + +export type ConfigType = { + [key in CONFIG_KEYS]?: any; +}; + +const configPath = pathJoin(homedir(), '.opencommit'); + +export const getConfig = (): ConfigType | null => { + const configExists = existsSync(configPath); + if (!configExists) return null; + + const configFile = readFileSync(configPath, 'utf8'); + const config = iniParse(configFile); + + for (const configKey of Object.keys(config)) { + const validValue = configValidators[configKey as CONFIG_KEYS]( + config[configKey] + ); + + config[configKey] = validValue; + } + + return config; +}; + +export const setConfig = (keyValues: [key: string, value: string][]) => { + const config = getConfig() || {}; + + for (const [configKey, configValue] of keyValues) { + if (!configValidators.hasOwnProperty(configKey)) { + throw new Error(`Unsupported config key: ${configKey}`); + } + + let parsedConfigValue; + + try { + parsedConfigValue = JSON.parse(configValue); + } catch (error) { + parsedConfigValue = configValue; + } + + const validValue = + configValidators[configKey as CONFIG_KEYS](parsedConfigValue); + config[configKey as CONFIG_KEYS] = validValue; + } + + writeFileSync(configPath, iniStringify(config), 'utf8'); + + outro(`${chalk.green('✔')} config successfully set`); +}; + +export const configCommand = command( + { + name: 'config', + parameters: ['', ''] + }, + async (argv) => { + intro('opencommit — config'); + try { + const { mode, keyValues } = argv._; + + if (mode === 'get') { + const config = getConfig() || {}; + for (const key of keyValues) { + outro(`${key}=${config[key as keyof typeof config]}`); + } + } else if (mode === 'set') { + await setConfig( + keyValues.map((keyValue) => keyValue.split('=') as [string, string]) + ); + } else { + throw new Error( + `Unsupported mode: ${mode}. Valid modes are: "set" and "get"` + ); + } + } catch (error) { + outro(`${chalk.red('✖')} ${error}`); + process.exit(1); + } + } +); diff --git a/src/commands/githook.ts b/src/commands/githook.ts new file mode 100644 index 00000000..0f2fd104 --- /dev/null +++ b/src/commands/githook.ts @@ -0,0 +1,85 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { command } from 'cleye'; +import { assertGitRepo } from '../utils/git.js'; +import { existsSync } from 'fs'; +import chalk from 'chalk'; +import { intro, outro } from '@clack/prompts'; + +const HOOK_NAME = 'prepare-commit-msg'; +const SYMLINK_URL = `.git/hooks/${HOOK_NAME}`; + +export const isHookCalled = process.argv[1].endsWith(`/${SYMLINK_URL}`); + +const isHookExists = existsSync(SYMLINK_URL); + +export const hookCommand = command( + { + name: 'hook', + parameters: [''] + }, + async (argv) => { + const HOOK_URL = __filename; + + try { + await assertGitRepo(); + + const { setUnset: mode } = argv._; + + if (mode === 'set') { + intro(`setting opencommit as '${HOOK_NAME}' hook`); + + if (isHookExists) { + let realPath; + try { + realPath = await fs.realpath(SYMLINK_URL); + } catch (error) { + outro(error as string); + realPath = null; + } + + if (realPath === HOOK_URL) + return outro(`opencommit is already set as '${HOOK_NAME}'`); + + throw new Error( + `Different ${HOOK_NAME} is already set. Remove it before setting opencommit as '${HOOK_NAME}' hook.` + ); + } + + await fs.mkdir(path.dirname(SYMLINK_URL), { recursive: true }); + await fs.symlink(HOOK_URL, SYMLINK_URL, 'file'); + await fs.chmod(SYMLINK_URL, 0o755); + + return outro(`${chalk.green('✔')} Hook set`); + } + + if (mode === 'unset') { + intro(`unsetting opencommit as '${HOOK_NAME}' hook`); + + if (!isHookExists) { + return outro( + `opencommit wasn't previously set as '${HOOK_NAME}' hook, nothing to remove` + ); + } + + const realpath = await fs.realpath(SYMLINK_URL); + if (realpath !== HOOK_URL) { + return outro( + `opencommit wasn't previously set as '${HOOK_NAME}' hook, but different hook was, if you want to remove it — do it manually` + ); + } + + await fs.rm(SYMLINK_URL); + return outro(`${chalk.green('✔')} Hook is removed`); + } + + throw new Error( + `unsupported mode: ${mode}. Supported modes are: 'set' or 'unset'` + ); + } catch (error) { + outro(`${chalk.red('✖')} ${error}`); + process.exit(1); + } + } +); diff --git a/src/commands/prepare-commit-msg-hook.ts b/src/commands/prepare-commit-msg-hook.ts new file mode 100644 index 00000000..ffc19e4b --- /dev/null +++ b/src/commands/prepare-commit-msg-hook.ts @@ -0,0 +1,47 @@ +import fs from 'fs/promises'; +import chalk from 'chalk'; +import { intro, outro } from '@clack/prompts'; +import { getStagedGitDiff } from '../utils/git'; +import { getConfig } from './config'; +import { generateCommitMessageWithChatCompletion } from '../generateCommitMessageFromGitDiff'; + +const [messageFilePath, commitSource] = process.argv.slice(2); + +export const prepareCommitMessageHook = async () => { + try { + if (!messageFilePath) { + throw new Error( + 'Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook' + ); + } + + if (commitSource) return; + + const staged = await getStagedGitDiff(); + + if (!staged) return; + + intro('opencommit'); + + const config = getConfig(); + + if (!config?.OPENAI_API_KEY) { + throw new Error( + 'No OPEN_AI_API exists. Set your OPEN_AI_API= in ~/.opencommit' + ); + } + + const commitMessage = await generateCommitMessageWithChatCompletion( + staged.diff + ); + + if (typeof commitMessage !== 'string') throw new Error(commitMessage.error); + + await fs.appendFile(messageFilePath, commitMessage); + + outro(`${chalk.green('✔')} commit done`); + } catch (error) { + outro(`${chalk.red('✖')} ${error}`); + process.exit(1); + } +}; diff --git a/src/generateCommitMessageFromGitDiff.ts b/src/generateCommitMessageFromGitDiff.ts new file mode 100644 index 00000000..bd94625d --- /dev/null +++ b/src/generateCommitMessageFromGitDiff.ts @@ -0,0 +1,127 @@ +import { + ChatCompletionRequestMessage, + ChatCompletionRequestMessageRoleEnum +} from 'openai'; +import { api } from './api'; +import { getConfig } from './commands/config'; + +const config = getConfig(); + +const INIT_MESSAGES_PROMPT: Array = [ + { + role: ChatCompletionRequestMessageRoleEnum.System, + content: `You are to act as the author of a commit message in git. Your mission is to create clean and comprehensive commit messages in the conventional commit convention. I'll send you an output of 'git diff --staged' command, and you convert it into a commit message. ${ + config?.emoji + ? 'Use Gitmoji convention to preface the commit' + : 'Do not preface the commit with anything' + }, use the present tense. ${ + config?.description + ? 'Add a short description of what commit is about after the commit message. Don\'t start it with "This commit", just describe the changes.' + : "Don't add any descriptions to the commit, only commit message." + }` + }, + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: `diff --git a/src/server.ts b/src/server.ts + index ad4db42..f3b18a9 100644 + --- a/src/server.ts + +++ b/src/server.ts + @@ -10,7 +10,7 @@ import { + initWinstonLogger(); + + const app = express(); + -const port = 7799; + +const PORT = 7799; + + app.use(express.json()); + + @@ -34,6 +34,6 @@ app.use((_, res, next) => { + // ROUTES + app.use(PROTECTED_ROUTER_URL, protectedRouter); + + -app.listen(port, () => { + - console.log(\`Server listening on port \${port}\`); + +app.listen(process.env.PORT || PORT, () => { + + console.log(\`Server listening on port \${PORT}\`); + });` + }, + { + role: ChatCompletionRequestMessageRoleEnum.Assistant, + // prettier-ignore + content: `* ${config?.emoji ? '🐛 ' : ''}fix(server.ts): change port variable case from lowercase port to uppercase PORT +* ${config?.emoji ? '✨ ' : ''}feat(server.ts): add support for process.env.PORT environment variable +${config?.description ? 'The port variable is now named PORT, which improves consistency with the naming conventions as PORT is a constant. Support for an environment variable allows the application to be more flexible as it can now run on any available port specified via the process.env.PORT environment variable.' : ''}` + } +]; + +const generateCommitMessageChatCompletionPrompt = ( + diff: string +): Array => { + const chatContextAsCompletionRequest = [...INIT_MESSAGES_PROMPT]; + + chatContextAsCompletionRequest.push({ + role: ChatCompletionRequestMessageRoleEnum.User, + content: diff + }); + + return chatContextAsCompletionRequest; +}; + +export enum GenerateCommitMessageErrorEnum { + tooMuchTokens = 'TOO_MUCH_TOKENS', + internalError = 'INTERNAL_ERROR', + emptyMessage = 'EMPTY_MESSAGE' +} + +interface GenerateCommitMessageError { + error: GenerateCommitMessageErrorEnum; +} + +const INIT_MESSAGES_PROMPT_LENGTH = INIT_MESSAGES_PROMPT.map( + (msg) => msg.content +).join('').length; + +export const generateCommitMessageWithChatCompletion = async ( + diff: string +): Promise => { + try { + const MAX_REQ_TOKENS = 3900; + + if (INIT_MESSAGES_PROMPT_LENGTH + diff.length >= MAX_REQ_TOKENS) { + const separator = 'diff --git '; + + const diffByFiles = diff.split(separator).slice(1); + + const commitMessages = []; + + for (const diffFile of diffByFiles) { + if (INIT_MESSAGES_PROMPT_LENGTH + diffFile.length >= MAX_REQ_TOKENS) + continue; + + const messages = generateCommitMessageChatCompletionPrompt( + separator + diffFile + ); + + const commitMessage = await api.generateCommitMessage(messages); + + // TODO: handle this edge case + if (!commitMessage?.content) continue; + + commitMessages.push(commitMessage?.content); + } + + return commitMessages.join('\n\n'); + } + + const messages = generateCommitMessageChatCompletionPrompt(diff); + + const commitMessage = await api.generateCommitMessage(messages); + + if (!commitMessage) + return { error: GenerateCommitMessageErrorEnum.emptyMessage }; + + return commitMessage.content; + } catch (error) { + return { error: GenerateCommitMessageErrorEnum.internalError }; + } +}; diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 00000000..97af5fa3 --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,49 @@ +import { execa } from 'execa'; +import { spinner } from '@clack/prompts'; + +export const assertGitRepo = async () => { + try { + await execa('git', ['rev-parse']); + } catch (error) { + throw new Error(error as string); + } +}; + +const excludeBigFilesFromDiff = ['*-lock.*', '*.lock'].map( + (file) => `:(exclude)${file}` +); + +export interface StagedDiff { + files: string[]; + diff: string; +} + +export const getStagedGitDiff = async ( + isStageAllFlag = false +): Promise => { + if (isStageAllFlag) { + const stageAllSpinner = spinner(); + stageAllSpinner.start('Staging all changes'); + await execa('git', ['add', '.']); + stageAllSpinner.stop('Done'); + } + + const diffStaged = ['diff', '--staged']; + const { stdout: files } = await execa('git', [ + ...diffStaged, + '--name-only', + ...excludeBigFilesFromDiff + ]); + + if (!files) return null; + + const { stdout: diff } = await execa('git', [ + ...diffStaged, + ...excludeBigFilesFromDiff + ]); + + return { + files: files.split('\n').sort(), + diff + }; +};