├── .gitignore ├── tsconfig.json ├── template.ts ├── package.json ├── client.ts ├── config.ts ├── README.md ├── config_storage.ts └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "module": "ESNext", 5 | "target": "es2022", 6 | "lib": ["es2022"], 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "skipLibCheck": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /template.ts: -------------------------------------------------------------------------------- 1 | export const defaultPromptTemplate = [ 2 | "suggest 10 commit messages based on the following diff:", 3 | "{{diff}}", 4 | "", 5 | "commit messages should:", 6 | " - follow conventional commits", 7 | " - message format should be: [scope]: ", 8 | 9 | "", 10 | "examples:", 11 | " - fix(authentication): add password regex pattern", 12 | " - feat(storage): add new test cases", 13 | ].join("\n"); 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "commitgpt", 3 | "version": "2.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "bin": "dist/index.js", 8 | "scripts": { 9 | "build": "tsc", 10 | "start": "ts-node --esm index.ts", 11 | "prepublishOnly": "npm run build" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/RomanHotsiy/commitgpt.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/RomanHotsiy/commitgpt/issues" 22 | }, 23 | "homepage": "https://github.com/RomanHotsiy/commitgpt#readme", 24 | "dependencies": { 25 | "enquirer": "^2.3.6", 26 | "openai": "^3.2.1", 27 | "ora": "^6.1.2", 28 | "uuid": "^9.0.0", 29 | "ws": "^8.12.1", 30 | "yargs-parser": "^21.1.1" 31 | }, 32 | "devDependencies": { 33 | "ts-node": "^10.9.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client.ts: -------------------------------------------------------------------------------- 1 | // Adapted from: https://github.com/wong2/chat-gpt-google-extension/blob/main/background/index.mjs 2 | 3 | import { Configuration, OpenAIApi } from "openai"; 4 | import { getApiKey, getPromptOptions } from "./config.js"; 5 | import { getConfig } from "./config_storage.js"; 6 | 7 | const configuration = new Configuration({ 8 | apiKey: await getApiKey(), 9 | }); 10 | const openai = new OpenAIApi(configuration); 11 | 12 | export class ChatGPTClient { 13 | async getAnswer(question: string): Promise { 14 | const { model, maxTokens, temperature } = await getPromptOptions(); 15 | 16 | try { 17 | const result = await openai.createCompletion({ 18 | model, 19 | prompt: question, 20 | max_tokens: maxTokens, 21 | temperature, 22 | }); 23 | return result.data.choices[0].text; 24 | } catch (e) { 25 | console.error(e?.response ?? e); 26 | throw e; 27 | } 28 | 29 | // @ts-ignore 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | import enquirer from "enquirer"; 2 | import { getConfig, setGlobalConfig } from "./config_storage.js"; 3 | 4 | async function promptToken() { 5 | try { 6 | const answer = await enquirer.prompt<{ apikey: string }>({ 7 | type: "password", 8 | name: "apikey", 9 | message: "Paste your OpenAI apikey here:", 10 | }); 11 | 12 | return answer.apikey; 13 | } catch (e) { 14 | console.log("Aborted."); 15 | process.exit(1); 16 | } 17 | } 18 | 19 | export async function getApiKey(clean?: boolean): Promise { 20 | let apiKey = getConfig("apiKey"); 21 | 22 | if (clean) { 23 | apiKey = undefined; 24 | } 25 | 26 | if (!apiKey) { 27 | apiKey = await promptToken(); 28 | setGlobalConfig("apiKey", apiKey); 29 | } 30 | 31 | return apiKey; 32 | } 33 | 34 | export async function getPromptOptions(): Promise<{ 35 | model: string; 36 | temperature: number; 37 | maxTokens: number; 38 | }> { 39 | const model = getConfig("model"); 40 | const temperature = getConfig("temperature"); 41 | const maxTokens = getConfig("maxTokens"); 42 | 43 | return { 44 | model, 45 | temperature, 46 | maxTokens, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # commitgpt 2 | 3 | Automatically generate commit messages using ChatGPT. 4 | 5 | ![commitgpt](https://user-images.githubusercontent.com/3975738/205517867-1e7533ae-a8e7-4c0d-afb6-d259635f3f9d.gif) 6 | 7 | ## How to use? 8 | 9 | ```bash 10 | npx commitgpt 11 | ``` 12 | 13 | ### Get OpenAI api key 14 | https://platform.openai.com/account/api-keys 15 | 16 | ### Configuration (Optional) 17 | you can create `.commitgpt.json` and/or `.commitgpt-template` config files in your project root. 18 | 19 | #### `.commitgpt.json` file 20 | default: 21 | ```json 22 | { 23 | "model": "text-davinci-003", 24 | "temperature": 0.5, 25 | "maxTokens": 2048, 26 | } 27 | ``` 28 | this file can be used to change the openai model and other parameters. 29 | 30 | 31 | ### `.commitgpt-template` file 32 | default: 33 | ``` 34 | suggest 10 commit messages based on the following diff: 35 | {{diff}} 36 | commit messages should: 37 | - follow conventional commits 38 | - message format should be: [scope]: 39 | 40 | examples: 41 | - fix(authentication): add password regex pattern 42 | - feat(storage): add new test cases 43 | ``` 44 | 45 | this file can be used to change the template used to generate the prompt request. you can modify the template to fit your needs. 46 | 47 | ## How it works 48 | 49 | - Runs `git diff --cached` 50 | - Sends the diff to ChatGPT and asks it to suggest commit messages 51 | - Shows suggestions to the user 52 | 53 | ## Credits 54 | 55 | Some code and approaches were inspired by the awesome projects below: 56 | 57 | - https://github.com/acheong08/ChatGPT 58 | - https://github.com/transitive-bullshit/chatgpt-api 59 | - https://github.com/wong2/chat-gpt-google-extension 60 | 61 | ---- 62 | 63 | Do you need API docs? Check out [Redocly](https://redocly.com). 64 | -------------------------------------------------------------------------------- /config_storage.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from "os"; 2 | import { readFileSync, writeFileSync, existsSync } from "fs"; 3 | import { defaultPromptTemplate } from "./template.js"; 4 | 5 | const GLOBAL_CONFIG_PATH = `${homedir()}/.commitgpt.json`; 6 | const LOCAL_CONFIG_PATH = `${process.cwd()}/.commitgpt.json`; 7 | 8 | const GLOBAL_PROMPT_TEMPLATE_PATH = `${homedir()}/.commitgpt-template`; 9 | const LOCAL_PROMPT_TEMPLATE_PATH = `${process.cwd()}/.commitgpt-template`; 10 | 11 | interface Config { 12 | apiKey?: string; 13 | promptTemplate?: string; 14 | model: string; 15 | temperature: number; 16 | maxTokens: number; 17 | } 18 | 19 | const defaultConfig = { 20 | model: "text-davinci-003", 21 | temperature: 0.5, 22 | maxTokens: 2048, 23 | } satisfies Config; 24 | 25 | const writeJsonFile = (path: string, data: unknown) => { 26 | writeFileSync(path, JSON.stringify(data, null, 2)); 27 | }; 28 | 29 | function ensureGlobal() { 30 | if (!existsSync(GLOBAL_CONFIG_PATH)) { 31 | writeJsonFile(GLOBAL_CONFIG_PATH, {}); 32 | } 33 | } 34 | 35 | function loadGlobal() { 36 | return JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, "utf-8")); 37 | } 38 | 39 | function loadLocal(): Partial { 40 | if (!existsSync(LOCAL_CONFIG_PATH)) return {}; 41 | return JSON.parse(readFileSync(LOCAL_CONFIG_PATH, "utf-8")); 42 | } 43 | 44 | let cache = null; 45 | function load() { 46 | if (cache) return cache; 47 | ensureGlobal(); 48 | const global = loadGlobal(); 49 | const local = loadLocal(); 50 | cache = { ...defaultConfig, ...global, ...local }; 51 | return cache; 52 | } 53 | 54 | function assertTempValid(t: string) { 55 | // should include {{diff}} 56 | if (!t.includes("{{diff}}")) { 57 | throw new Error("Template must include {{diff}}"); 58 | } 59 | } 60 | 61 | export function loadPromptTemplate(): string { 62 | if (existsSync(LOCAL_CONFIG_PATH)) { 63 | const temp = readFileSync(LOCAL_PROMPT_TEMPLATE_PATH, "utf-8"); 64 | assertTempValid(temp); 65 | 66 | return temp; 67 | } 68 | 69 | if (existsSync(GLOBAL_PROMPT_TEMPLATE_PATH)) { 70 | const temp = readFileSync(GLOBAL_PROMPT_TEMPLATE_PATH, "utf-8"); 71 | assertTempValid(temp); 72 | 73 | return temp; 74 | } 75 | 76 | return defaultPromptTemplate; 77 | } 78 | 79 | export function getConfig(key: string): T { 80 | const config = load(); 81 | return config[key]; 82 | } 83 | 84 | export function setGlobalConfig(key: string, value: unknown) { 85 | const config = loadGlobal(); 86 | config[key] = value; 87 | writeJsonFile(GLOBAL_CONFIG_PATH, config); 88 | } 89 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { execSync } from "child_process"; 3 | 4 | import enquirer from "enquirer"; 5 | import ora from "ora"; 6 | 7 | import { ChatGPTClient } from "./client.js"; 8 | import { loadPromptTemplate } from "./config_storage.js"; 9 | 10 | const debug = (...args: unknown[]) => { 11 | if (process.env.DEBUG) { 12 | console.debug(...args); 13 | } 14 | }; 15 | 16 | const CUSTOM_MESSAGE_OPTION = "[write own message]..."; 17 | const spinner = ora(); 18 | 19 | let diff = ""; 20 | try { 21 | diff = execSync("git diff --cached").toString(); 22 | if (!diff) { 23 | console.log("No changes to commit."); 24 | process.exit(0); 25 | } 26 | } catch (e) { 27 | console.log("Failed to run git diff --cached"); 28 | process.exit(1); 29 | } 30 | 31 | run(diff) 32 | .then(() => { 33 | process.exit(0); 34 | }) 35 | .catch((e: Error) => { 36 | console.log("Error: " + e.message, e.cause ?? ""); 37 | process.exit(1); 38 | }); 39 | 40 | async function run(diff: string) { 41 | // TODO: we should use a good tokenizer here 42 | const diffTokens = diff.split(" ").length; 43 | if (diffTokens > 2000) { 44 | console.log(`Diff is way too bug. Truncating to 700 tokens. It may help`); 45 | diff = diff.split(" ").slice(0, 700).join(" "); 46 | } 47 | 48 | const api = new ChatGPTClient(); 49 | 50 | const prompt = loadPromptTemplate().replace( 51 | "{{diff}}", 52 | ["```", diff, "```"].join("\n") 53 | ); 54 | 55 | while (true) { 56 | debug("prompt: ", prompt); 57 | const choices = await getMessages(api, prompt); 58 | 59 | try { 60 | const answer = await enquirer.prompt<{ message: string }>({ 61 | type: "select", 62 | name: "message", 63 | message: "Pick a message", 64 | choices, 65 | }); 66 | 67 | if (answer.message === CUSTOM_MESSAGE_OPTION) { 68 | execSync("git commit", { stdio: "inherit" }); 69 | return; 70 | } else { 71 | execSync(`git commit -m '${escapeCommitMessage(answer.message)}'`, { 72 | stdio: "inherit", 73 | }); 74 | return; 75 | } 76 | } catch (e) { 77 | console.log("Aborted."); 78 | console.log(e); 79 | process.exit(1); 80 | } 81 | } 82 | } 83 | 84 | async function getMessages(api: ChatGPTClient, request: string) { 85 | spinner.start("Asking ChatGPT 🤖 for commit messages..."); 86 | 87 | // send a message and wait for the response 88 | try { 89 | const response = await api.getAnswer(request); 90 | // find json array of strings in the response 91 | const messages = response 92 | .split("\n") 93 | .map(normalizeMessage) 94 | .filter((l) => l.length > 1); 95 | 96 | spinner.stop(); 97 | 98 | debug("response: ", response); 99 | 100 | messages.push(CUSTOM_MESSAGE_OPTION); 101 | return messages; 102 | } catch (e) { 103 | spinner.stop(); 104 | if (e.message === "Unauthorized") { 105 | return getMessages(api, request); 106 | } else { 107 | throw e; 108 | } 109 | } 110 | } 111 | 112 | function normalizeMessage(line: string) { 113 | return line 114 | .trim() 115 | .replace(/^(\d+\.|-|\*)\s+/, "") 116 | .replace(/^[`"']/, "") 117 | .replace(/[`"']$/, "") 118 | .replace(/[`"']:/, ":") // sometimes it formats messages like this: `feat`: message 119 | .replace(/:[`"']/, ":") // sometimes it formats messages like this: `feat:` message 120 | .replace(/\\n/g, "") 121 | .trim(); 122 | } 123 | 124 | function escapeCommitMessage(message: string) { 125 | return message.replace(/'/, `''`); 126 | } 127 | --------------------------------------------------------------------------------