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 + }; +};