diff --git a/.gitignore b/.gitignore index 6704566..dd1a86e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + # Logs logs *.log @@ -40,6 +41,7 @@ build/Release # Dependency directories node_modules/ jspm_packages/ +.idea/ # TypeScript v1 declaration files typings/ diff --git a/index.js b/index.js index 10daddd..db1d63a 100755 --- a/index.js +++ b/index.js @@ -8,8 +8,10 @@ import { addGitmojiToCommitMessage } from './gitmoji.js'; import { AI_PROVIDER, MODEL, args } from "./config.js" import openai from "./openai.js" import ollama from "./ollama.js" +import { encode } from 'gpt-3-encoder'; const REGENERATE_MSG = "♻️ Regenerate Commit Messages"; +const MAX_TOKENS = 128000; console.log('Ai provider: ', AI_PROVIDER); @@ -170,6 +172,33 @@ const filterLockFiles = (diff) => { return filteredLines.join('\n'); }; +function parseDiffByFile(diff) { + const files = []; + const lines = diff.split('\n'); + let currentFile = null; + let currentDiff = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const diffGitMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + if (diffGitMatch) { + // New file diff + if (currentFile) { + files.push({ filename: currentFile, diff: currentDiff.join('\n') }); + } + currentFile = diffGitMatch[2]; + currentDiff = [line]; + } else if (currentFile) { + currentDiff.push(line); + } + } + // Add the last file + if (currentFile && currentDiff.length) { + files.push({ filename: currentFile, diff: currentDiff.join('\n') }); + } + return files; +} + + async function generateAICommit() { const isGitRepository = checkGitRepository(); @@ -196,9 +225,97 @@ async function generateAICommit() { process.exit(1); } - args.list - ? await generateListCommits(diff) - : await generateSingleCommit(diff); + const prompt = getPromptForSingleCommit(diff); + + const numTokens = encode(prompt).length; + + if (numTokens > MAX_TOKENS) { + // Split the diff by file and generate summaries + console.log("The commit diff is too large for the ChatGPT API. Splitting by files..."); + + // Parse diff into per-file diffs + const fileDiffs = parseDiffByFile(diff); + + const summaries = []; + + for (const { filename, diff: fileDiff } of fileDiffs) { + const summaryPrompt = provider.getPromptForDiffSummary(fileDiff, filename, { language }); + const numTokens = encode(summaryPrompt).length; + + if (numTokens > MAX_TOKENS) { + console.log(`Skipping ${filename} because its diff is too large.`); + continue; + } + + if (!await provider.filterApi({ prompt: summaryPrompt, filterFee: args['filter-fee'] })) continue; + + const summary = await provider.sendMessage(summaryPrompt, { apiKey, model: MODEL }); + + summaries.push(`- **${filename}**: ${summary}`); + } + + if (summaries.length === 0) { + console.log("No files to summarize."); + process.exit(1); + } + + // Combine summaries into a single prompt + const summariesText = summaries.join('\n'); + + const commitPrompt = + `Based on the following summaries of changes, create a useful commit message in ${language} language` + + (commitType ? ` with commit type '${commitType}'. ` : ". ") + + "Use the summaries below to create the commit message. Do not preface the commit with anything, use the present tense, return the full sentence, and use the conventional commits specification (: ):\n\n" + + summariesText; + + if (!await provider.filterApi({ prompt: commitPrompt, filterFee: args['filter-fee'] })) process.exit(1); + + const commitMessage = await provider.sendMessage(commitPrompt, { apiKey, model: MODEL }); + + let finalCommitMessage = processEmoji(commitMessage, args.emoji); + + if (args.template) { + finalCommitMessage = processTemplate({ + template: args.template, + commitMessage: finalCommitMessage, + }) + + console.log( + `Proposed Commit With Template:\n------------------------------\n${finalCommitMessage}\n------------------------------` + ); + } else { + console.log( + `Proposed Commit:\n------------------------------\n${finalCommitMessage}\n------------------------------` + ); + } + + if (args.force) { + makeCommit(finalCommitMessage); + return; + } + + const answer = await inquirer.prompt([ + { + type: "confirm", + name: "continue", + message: "Do you want to continue?", + default: true, + }, + ]); + + if (!answer.continue) { + console.log("Commit aborted by user 🙅‍♂️"); + process.exit(1); + } + + makeCommit(finalCommitMessage); + + } else { + // Proceed as usual if the diff is not too large + args.list + ? await generateListCommits(diff) + : await generateSingleCommit(diff); + } } await generateAICommit(); diff --git a/openai.js b/openai.js index 8740cf4..86fbca1 100644 --- a/openai.js +++ b/openai.js @@ -1,79 +1,83 @@ import { ChatGPTAPI } from "chatgpt"; - import { encode } from 'gpt-3-encoder'; import inquirer from "inquirer"; import { AI_PROVIDER } from "./config.js" const FEE_PER_1K_TOKENS = 0.02; const MAX_TOKENS = 128000; -//this is the approximate cost of a completion (answer) fee from CHATGPT +// this is the approximate cost of a completion (answer) fee from CHATGPT const FEE_COMPLETION = 0.001; const openai = { - sendMessage: async (input, {apiKey, model}) => { - console.log("prompting chat gpt..."); - console.log("prompt: ", input); - const api = new ChatGPTAPI({ - apiKey, - completionParams: { - model: "gpt-4-1106-preview", - }, - }); - const { text } = await api.sendMessage(input); - - return text; - }, - - getPromptForSingleCommit: (diff, {commitType, language}) => { + sendMessage: async (input, {apiKey, model}) => { + console.log("prompting chat gpt..."); + console.log("prompt: ", input); + const api = new ChatGPTAPI({ + apiKey, + completionParams: { + model: "gpt-4-1106-preview", + }, + }); + const { text } = await api.sendMessage(input); - return ( - "I want you to act as the author of a commit message in git." + - `I'll enter a git diff, and your job is to convert it into a useful commit message in ${language} language` + - (commitType ? ` with commit type '${commitType}'. ` : ". ") + - "Do not preface the commit with anything, use the present tense, return the full sentence, and use the conventional commits specification (: ): " + - '\n\n'+ - diff - ); - }, + return text; + }, - getPromptForMultipleCommits: (diff, {commitType, numOptions, language}) => { - const prompt = - "I want you to act as the author of a commit message in git." + - `I'll enter a git diff, and your job is to convert it into a useful commit message in ${language} language` + - (commitType ? ` with commit type '${commitType}.', ` : ", ") + - `and make ${numOptions} options that are separated by ";".` + - "For each option, use the present tense, return the full sentence, and use the conventional commits specification (: ):" + - diff; + getPromptForSingleCommit: (diff, {commitType, language}) => { + return ( + "I want you to act as the author of a commit message in git. " + + `I'll enter a git diff, and your job is to convert it into a useful commit message in ${language} language` + + (commitType ? ` with commit type '${commitType}'. ` : ". ") + + "Do not preface the commit with anything, use the present tense, return the full sentence, and use the conventional commits specification (: ): " + + '\n\n'+ + diff + ); + }, - return prompt; - }, + getPromptForMultipleCommits: (diff, {commitType, numOptions, language}) => { + const prompt = + "I want you to act as the author of a commit message in git. " + + `I'll enter a git diff, and your job is to convert it into a useful commit message in ${language} language` + + (commitType ? ` with commit type '${commitType}.', ` : ", ") + + `and make ${numOptions} options that are separated by ";".` + + "For each option, use the present tense, return the full sentence, and use the conventional commits specification (: ):" + + diff; - filterApi: async ({ prompt, numCompletion = 1, filterFee }) => { - const numTokens = encode(prompt).length; - const fee = numTokens / 1000 * FEE_PER_1K_TOKENS + (FEE_COMPLETION * numCompletion); + return prompt; + }, - if (numTokens > MAX_TOKENS) { - console.log("The commit diff is too large for the ChatGPT API. Max 4k tokens or ~8k characters. "); - return false; - } + // New function to prompt for a summary of the diff + getPromptForDiffSummary: (diff, filename, {language}) => { + return ( + `Summarize the following git diff for the file '${filename}' in ${language} language. Be concise and focus on the main changes:\n\n` + + diff + ); + }, - if (filterFee) { - console.log(`This will cost you ~$${+fee.toFixed(3)} for using the API.`); - const answer = await inquirer.prompt([ - { - type: "confirm", - name: "continue", - message: "Do you want to continue 💸?", - default: true, - }, - ]); - if (!answer.continue) return false; - } + filterApi: async ({ prompt, numCompletion = 1, filterFee }) => { + const numTokens = encode(prompt).length; + const fee = numTokens / 1000 * FEE_PER_1K_TOKENS + (FEE_COMPLETION * numCompletion); - return true; -} + if (numTokens > MAX_TOKENS) { + console.log(`The commit diff is too large for the ChatGPT API. Max ${MAX_TOKENS/1000}k tokens or ~${MAX_TOKENS/500} characters.`); + return false; + } + if (filterFee) { + console.log(`This will cost you ~$${+fee.toFixed(3)} for using the API.`); + const answer = await inquirer.prompt([ + { + type: "confirm", + name: "continue", + message: "Do you want to continue 💸?", + default: true, + }, + ]); + if (!answer.continue) return false; + } + return true; + } }; export default openai; diff --git a/package-lock.json b/package-lock.json index 390a3ca..a7b7ebb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-commit", - "version": "2.1.1", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-commit", - "version": "2.1.1", + "version": "2.2.0", "license": "MIT", "dependencies": { "chatgpt": "^5.0.0", diff --git a/package.json b/package.json index dead2b0..0e417be 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,9 @@ }, "homepage": "https://github.com/insulineru/ai-commit#readme", "dependencies": { - "chatgpt": "^5.0.0", - "dotenv": "^16.0.3", + "chatgpt": "^5.2.5", + "dotenv": "^16.4.5", "gpt-3-encoder": "^1.1.4", - "inquirer": "^9.1.4" + "inquirer": "^11.1.0" } }