From 5fc852120a8963d395db69a48ef0f0989e82d547 Mon Sep 17 00:00:00 2001 From: Renan Garcia Date: Tue, 31 Mar 2026 18:52:20 -0500 Subject: [PATCH 1/3] feat(lib): Enhance CLI with interactive commit options This update introduces interactive options for the commit command in the CLI. Users can now choose to review the generated commit message before finalizing it. This feature allows for greater flexibility and control over commit messages, ensuring they accurately reflect the changes made. Additionally, the command now supports a dry-run option, which enables users to preview the commit message without actually committing. This enhancement improves the user experience by providing more transparency and reducing the likelihood of incorrect commits. Overall, these changes aim to streamline the commit process and empower users with better tools for managing their commit messages. --- .env.example | 26 +++- bin/cli.js | 206 +++++++++++++++++++++++++++---- lib/core/format.js | 106 ++++++++++++++++ lib/core/generate.js | 17 ++- lib/core/interactive.js | 144 +++++++++++++++++++++ lib/core/openai.js | 46 ++++--- lib/providers/anthropic.js | 35 ++++++ lib/providers/azure-openai.js | 37 ++++++ lib/providers/base.js | 32 +++++ lib/providers/index.js | 101 +++++++++++++++ lib/providers/ollama.js | 38 ++++++ lib/providers/openai.js | 28 +++++ package.json | 12 ++ pnpm-lock.yaml | 36 ++++++ tools/lib/pr-ai.js | 72 ++++------- tools/semantic-release-notes.cjs | 78 ++++-------- 16 files changed, 860 insertions(+), 154 deletions(-) create mode 100644 lib/core/format.js create mode 100644 lib/core/interactive.js create mode 100644 lib/providers/anthropic.js create mode 100644 lib/providers/azure-openai.js create mode 100644 lib/providers/base.js create mode 100644 lib/providers/index.js create mode 100644 lib/providers/ollama.js create mode 100644 lib/providers/openai.js diff --git a/.env.example b/.env.example index 34a7270..ed79440 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,27 @@ # Copy to .env and/or .env.local in your project root (do not commit secrets). + +# --- Provider configuration --- +# COMMIT_AI_PROVIDER=openai # openai (default), anthropic, ollama, azure-openai +# COMMIT_AI_MODEL=gpt-4o-mini # Model override (default depends on provider) +# COMMIT_AI_API_KEY= # Universal API key (or use provider-specific below) +# COMMIT_AI_BASE_URL= # Custom API base URL + +# --- OpenAI (default provider) --- OPENAI_API_KEY= -# Optional: -# COMMIT_AI_MODEL=gpt-4o-mini + +# --- Anthropic --- +# ANTHROPIC_API_KEY= + +# --- Azure OpenAI --- +# AZURE_OPENAI_API_KEY= +# AZURE_OPENAI_ENDPOINT=https://.openai.azure.com +# AZURE_OPENAI_DEPLOYMENT= +# AZURE_OPENAI_API_VERSION=2024-08-01-preview + +# --- Ollama (local, no API key needed) --- +# COMMIT_AI_PROVIDER=ollama +# COMMIT_AI_BASE_URL=http://localhost:11434 +# COMMIT_AI_MODEL=llama3.2 # --- Optional: PR automation (`pnpm open-pr` or `.github/workflows/pr.yml`) --- # GH_TOKEN= @@ -9,12 +29,14 @@ OPENAI_API_KEY= # PR_HEAD_BRANCH= # PR_DRAFT=true # PR_AI=true +# PR_AI_PROVIDER=openai # PR_AI_ENDPOINT=https://api.openai.com/v1 # PR_AI_API_KEY= # PR_AI_MODEL=gpt-4o-mini # --- Optional: AI release summary (semantic-release plugin, `RELEASE_NOTES_AI_*`) --- # RELEASE_NOTES_AI=true +# RELEASE_NOTES_AI_PROVIDER=openai # RELEASE_NOTES_AI_ENDPOINT=https://api.openai.com/v1 # RELEASE_NOTES_AI_API_KEY= # RELEASE_NOTES_AI_MODEL=gpt-4o-mini diff --git a/bin/cli.js b/bin/cli.js index 2623ad1..f777b48 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -13,6 +13,9 @@ const { hasStagedChanges, commitFromFile, } = require("../lib/core/git.js"); +const { getProviderNames, hasApiKey } = require("../lib/providers/index.js"); +const { formatCommitMessage, ok, warn: fmtWarn, fail, info, createSpinner, bold, dim, cyan, green } = require("../lib/core/format.js"); +const { interactiveCommit } = require("../lib/core/interactive.js"); function presetPath() { return path.join(__dirname, "..", "lib", "commitlint-preset.cjs"); @@ -23,26 +26,53 @@ function commitlintCliPath() { } function printHelp() { - process.stdout.write(`commit-ai — conventional commits + bundled commitlint (mandatory deterministic scope; see README). + process.stdout.write(`${bold("commit-ai")} — AI-assisted conventional commits with bundled commitlint -Usage: - commit-ai run +${bold("Usage:")} + commit-ai run [options] commit-ai prepare-commit-msg [source] commit-ai lint --edit + commit-ai config [--init] + commit-ai hooks install -Commands: - run Generate a message from the staged diff and run git commit. - prepare-commit-msg Git hook: fill an empty commit message file (merge/squash skipped). - lint Run commitlint with the package default config (for commit-msg hook). +${bold("Commands:")} + ${green("run")} Generate a message from the staged diff and run git commit. + ${green("prepare-commit-msg")} Git hook: fill an empty commit message file. + ${green("lint")} Run commitlint with the bundled config (for commit-msg hook). + ${green("config")} Show resolved configuration (or --init to create config file). + ${green("hooks")} Install git hooks (commit-ai hooks install). -Environment: - OPENAI_API_KEY Required for AI generation on \`run\` (and for prepare-commit-msg when you want AI). - COMMIT_AI_MODEL Optional OpenAI model (default: gpt-4o-mini). +${bold("Run Options:")} + --dry-run Generate and display the message without committing. + -i, --interactive Review the message before committing (accept/edit/regenerate/cancel). -Loads \`.env\` then \`.env.local\` from the current working directory (\`.env.local\` overrides). +${bold("Environment:")} + COMMIT_AI_PROVIDER Provider: ${getProviderNames().join(", ")} (default: openai). + COMMIT_AI_API_KEY API key (or use provider-specific: OPENAI_API_KEY, ANTHROPIC_API_KEY). + COMMIT_AI_MODEL Model override (default depends on provider). + COMMIT_AI_BASE_URL Custom API base URL. + +${bold("Provider-specific:")} + OPENAI_API_KEY OpenAI API key. + ANTHROPIC_API_KEY Anthropic API key. + AZURE_OPENAI_ENDPOINT Azure OpenAI endpoint URL. + AZURE_OPENAI_API_KEY Azure OpenAI API key. + AZURE_OPENAI_DEPLOYMENT Azure deployment name. + AZURE_OPENAI_API_VERSION Azure API version. + +Loads ${dim(".env")} then ${dim(".env.local")} from the current working directory (${dim(".env.local")} overrides). `); } +function parseRunArgs(argv) { + const flags = { dryRun: false, interactive: false }; + for (const arg of argv) { + if (arg === "--dry-run") flags.dryRun = true; + if (arg === "-i" || arg === "--interactive") flags.interactive = true; + } + return flags; +} + function parseLintArgv(argv) { const i = argv.indexOf("--edit"); if (i === -1 || !argv[i + 1]) { @@ -58,19 +88,53 @@ function stripGitComments(text) { .join("\n"); } -async function cmdRun() { +async function cmdRun(flags) { assertInGitRepo(); if (!hasStagedChanges()) { - process.stderr.write("No staged changes. Stage files before running commit-ai (e.g. pnpm commit).\n"); + fail("No staged changes. Stage files before running commit-ai."); process.exit(1); } - const { message, warnings } = await generateAndValidate(process.cwd(), { - requireOpenAI: true, - }); - for (const w of warnings) { - process.stderr.write(`warning: ${w}\n`); + + const providerName = process.env.COMMIT_AI_PROVIDER || "openai"; + const spinner = createSpinner(`Generating commit message via ${providerName}...`); + + let result; + try { + result = await generateAndValidate(process.cwd(), { + requireOpenAI: true, + }); + } finally { + spinner.stop(); } + + const { message, warnings } = result; + + if (flags.dryRun) { + for (const w of warnings) fmtWarn(w); + info("Dry run — message not committed:"); + process.stderr.write("\n"); + process.stderr.write(formatCommitMessage(message)); + process.stderr.write("\n"); + return; + } + + if (flags.interactive) { + await interactiveCommit({ + message, + warnings, + regenerate: () => generateAndValidate(process.cwd(), { requireOpenAI: true }), + commit: commitFromFile, + cwd: process.cwd(), + }); + return; + } + + for (const w of warnings) fmtWarn(w); + process.stderr.write("\n"); + process.stderr.write(formatCommitMessage(message)); + process.stderr.write("\n\n"); commitFromFile(message); + ok("Committed successfully."); } async function cmdPrepareCommitMsg(file, source) { @@ -113,6 +177,94 @@ function cmdLint(editFile) { process.exit(r.status ?? 1); } +function cmdConfig(argv) { + if (argv.includes("--init")) { + const configPath = path.join(process.cwd(), ".commit-airc.json"); + if (fs.existsSync(configPath)) { + info(`Config file already exists: ${configPath}`); + return; + } + const defaultConfig = { + provider: "openai", + model: "gpt-4o-mini", + }; + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + "\n", "utf8"); + ok(`Created ${configPath}`); + return; + } + + const providerName = process.env.COMMIT_AI_PROVIDER || "openai"; + const model = process.env.COMMIT_AI_MODEL || "(provider default)"; + const baseUrl = process.env.COMMIT_AI_BASE_URL || "(default)"; + + const maskKey = (key) => { + if (!key) return "(not set)"; + if (key.length <= 8) return "****"; + return key.slice(0, 4) + "…" + key.slice(-4); + }; + + const config = { + provider: providerName, + model, + baseUrl, + apiKey: maskKey( + process.env.COMMIT_AI_API_KEY || + process.env.OPENAI_API_KEY || + process.env.ANTHROPIC_API_KEY || + process.env.AZURE_OPENAI_API_KEY, + ), + apiKeyAvailable: hasApiKey(), + availableProviders: getProviderNames(), + }; + + if (providerName === "azure-openai") { + config.azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT || "(not set)"; + config.azureDeployment = process.env.AZURE_OPENAI_DEPLOYMENT || "(not set)"; + config.azureApiVersion = process.env.AZURE_OPENAI_API_VERSION || "(default)"; + } + + process.stdout.write(JSON.stringify(config, null, 2) + "\n"); +} + +function cmdHooksInstall() { + const cwd = process.cwd(); + const huskyDir = path.join(cwd, ".husky"); + const gitHooksDir = path.join(cwd, ".git", "hooks"); + + const prepareCommitMsg = 'commit-ai prepare-commit-msg "$1" "$2"\n'; + const commitMsg = 'commit-ai lint --edit "$1"\n'; + + if (fs.existsSync(huskyDir)) { + const prepareFile = path.join(huskyDir, "prepare-commit-msg"); + const commitMsgFile = path.join(huskyDir, "commit-msg"); + + fs.writeFileSync(prepareFile, prepareCommitMsg, { mode: 0o755 }); + fs.writeFileSync(commitMsgFile, commitMsg, { mode: 0o755 }); + + ok("Husky hooks installed:"); + info(` ${prepareFile}`); + info(` ${commitMsgFile}`); + return; + } + + if (fs.existsSync(gitHooksDir)) { + const prepareFile = path.join(gitHooksDir, "prepare-commit-msg"); + const commitMsgFile = path.join(gitHooksDir, "commit-msg"); + + const shebang = "#!/bin/sh\n"; + fs.writeFileSync(prepareFile, shebang + prepareCommitMsg, { mode: 0o755 }); + fs.writeFileSync(commitMsgFile, shebang + commitMsg, { mode: 0o755 }); + + ok("Git hooks installed:"); + info(` ${prepareFile}`); + info(` ${commitMsgFile}`); + return; + } + + fail("No .husky/ directory or .git/hooks/ found. Initialize git or Husky first."); + process.exit(1); +} + async function main() { const argv = process.argv.slice(2); const cmd = argv[0]; @@ -121,7 +273,8 @@ async function main() { process.exit(cmd ? 0 : 1); } if (cmd === "run") { - await cmdRun(); + const flags = parseRunArgs(argv.slice(1)); + await cmdRun(flags); return; } if (cmd === "prepare-commit-msg") { @@ -138,10 +291,21 @@ async function main() { cmdLint(file); return; } - throw new Error(`Unknown command: ${cmd}`); + if (cmd === "config") { + cmdConfig(argv.slice(1)); + return; + } + if (cmd === "hooks") { + if (argv[1] === "install") { + cmdHooksInstall(); + return; + } + throw new Error("Usage: commit-ai hooks install"); + } + throw new Error(`Unknown command: ${cmd}. Run commit-ai --help for usage.`); } main().catch((e) => { - process.stderr.write(`${e.message}\n`); + fail(e.message); process.exit(1); }); diff --git a/lib/core/format.js b/lib/core/format.js new file mode 100644 index 0000000..dd6babe --- /dev/null +++ b/lib/core/format.js @@ -0,0 +1,106 @@ +"use strict"; + +const isTTY = process.stderr.isTTY; + +const codes = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + gray: "\x1b[90m", +}; + +function style(text, ...styles) { + if (!isTTY) return text; + const prefix = styles.map((s) => codes[s] || "").join(""); + return `${prefix}${text}${codes.reset}`; +} + +const bold = (t) => style(t, "bold"); +const dim = (t) => style(t, "dim"); +const red = (t) => style(t, "red"); +const green = (t) => style(t, "green"); +const yellow = (t) => style(t, "yellow"); +const cyan = (t) => style(t, "cyan"); +const magenta = (t) => style(t, "magenta"); + +function formatCommitMessage(msg) { + const lines = msg.split("\n"); + const header = lines[0] || ""; + const headerMatch = header.match(/^(\w+)(\([^)]+\))?(!)?(:\s)(.+)$/); + let formatted; + if (headerMatch) { + const [, type, scope, bang, sep, subject] = headerMatch; + formatted = [ + green(type), + scope ? cyan(scope) : "", + bang ? red("!") : "", + dim(sep), + bold(subject), + ].join(""); + } else { + formatted = bold(header); + } + const rest = lines.slice(1).join("\n"); + return rest ? `${formatted}\n${dim(rest)}` : formatted; +} + +function ok(msg) { + process.stderr.write(`${green("✓")} ${msg}\n`); +} + +function warn(msg) { + process.stderr.write(`${yellow("⚠")} ${msg}\n`); +} + +function fail(msg) { + process.stderr.write(`${red("✗")} ${msg}\n`); +} + +function info(msg) { + process.stderr.write(`${cyan("ℹ")} ${msg}\n`); +} + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function createSpinner(text) { + if (!isTTY) { + process.stderr.write(` ${text}\n`); + return { stop() {} }; + } + let i = 0; + const id = setInterval(() => { + const frame = SPINNER_FRAMES[i % SPINNER_FRAMES.length]; + process.stderr.write(`\r${cyan(frame)} ${text}`); + i++; + }, 80); + return { + stop(finalText) { + clearInterval(id); + process.stderr.write(`\r\x1b[K`); + if (finalText) process.stderr.write(`${finalText}\n`); + }, + }; +} + +module.exports = { + style, + bold, + dim, + red, + green, + yellow, + cyan, + magenta, + ok, + warn, + fail, + info, + formatCommitMessage, + createSpinner, +}; diff --git a/lib/core/generate.js b/lib/core/generate.js index f152e0d..740d9ce 100644 --- a/lib/core/generate.js +++ b/lib/core/generate.js @@ -13,6 +13,7 @@ const { getChangedFiles, getBranchName, } = require("./git.js"); +const { createProvider, hasApiKey } = require("../providers/index.js"); function buildChoreFallback({ files, scope, issueNumbers }) { const subject = buildFallbackSubject(files); @@ -32,9 +33,12 @@ async function generateAndValidate( const scope = detectScopeFromFiles(files, cwd); const breakingAllowed = looksBreaking({ files }); - if (requireOpenAI && !process.env.OPENAI_API_KEY) { + const apiKeyAvailable = hasApiKey(); + + if (requireOpenAI && !apiKeyAvailable) { + const providerName = process.env.COMMIT_AI_PROVIDER || "openai"; const err = new Error( - "OPENAI_API_KEY is not set. Add it to your environment or a .env / .env.local file in the project root.", + `API key not set for provider "${providerName}". Add it to your environment or a .env / .env.local file in the project root.`, ); err.code = "ENOKEY"; throw err; @@ -42,8 +46,9 @@ async function generateAndValidate( let msg = ""; let usedAi = false; - if (process.env.OPENAI_API_KEY) { + if (apiKeyAvailable) { try { + const provider = createProvider(); msg = await generateCommitMessageFull( { diff, @@ -52,7 +57,7 @@ async function generateAndValidate( scope, breakingAllowed, }, - { cwd }, + { cwd, provider }, ); if (msg) usedAi = true; } catch (e) { @@ -69,8 +74,8 @@ async function generateAndValidate( if (result.valid) { const warnings = []; if (!usedAi) { - if (!process.env.OPENAI_API_KEY) { - warnings.push("OPENAI_API_KEY not set; used deterministic fallback message."); + if (!apiKeyAvailable) { + warnings.push("API key not set; used deterministic fallback message."); } else { warnings.push("Model output could not be used; used deterministic fallback message."); } diff --git a/lib/core/interactive.js b/lib/core/interactive.js new file mode 100644 index 0000000..c554995 --- /dev/null +++ b/lib/core/interactive.js @@ -0,0 +1,144 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const readline = require("readline"); +const { formatCommitMessage, ok, warn, fail, info, cyan, dim, bold, green, yellow } = require("./format.js"); +const { lintMessage } = require("./lint.js"); + +function askQuestion(prompt) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase()); + }); + }); +} + +function openInEditor(message) { + const editor = process.env.VISUAL || process.env.EDITOR || "vi"; + const tmpFile = path.join(os.tmpdir(), `commit-ai-${Date.now()}.txt`); + fs.writeFileSync(tmpFile, message, "utf8"); + + const result = spawnSync(editor, [tmpFile], { + stdio: "inherit", + env: process.env, + }); + + if (result.status !== 0) { + try { fs.unlinkSync(tmpFile); } catch {} + return null; + } + + const edited = fs.readFileSync(tmpFile, "utf8"); + try { fs.unlinkSync(tmpFile); } catch {} + return edited.trim(); +} + +function printMessage(message) { + process.stderr.write("\n"); + process.stderr.write(`${dim("─".repeat(60))}\n`); + process.stderr.write(formatCommitMessage(message)); + process.stderr.write("\n"); + process.stderr.write(`${dim("─".repeat(60))}\n`); + process.stderr.write("\n"); +} + +function printMenu() { + process.stderr.write( + ` ${green("[a]")}ccept ${cyan("[e]")}dit ${yellow("[r]")}egenerate ${dim("[c]ancel")}\n\n`, + ); +} + +/** + * Interactive commit flow: display message, let user accept/edit/regenerate/cancel. + * + * @param {object} opts + * @param {string} opts.message - Initial generated message. + * @param {string[]} opts.warnings - Any warnings from generation. + * @param {Function} opts.regenerate - Async function that returns { message, warnings }. + * @param {Function} opts.commit - Function that accepts the final message and commits. + * @param {string} opts.cwd - Working directory for linting. + */ +async function interactiveCommit({ message, warnings, regenerate, commit, cwd }) { + let currentMessage = message; + let regenerateCount = 0; + const maxRegenerations = 3; + + for (const w of warnings) { + warn(w); + } + + while (true) { + printMessage(currentMessage); + printMenu(); + + const answer = await askQuestion(` ${bold("Choice:")} `); + + if (answer === "a" || answer === "accept") { + commit(currentMessage); + ok("Committed successfully."); + return; + } + + if (answer === "e" || answer === "edit") { + const edited = openInEditor(currentMessage); + if (!edited) { + fail("Editor returned empty or non-zero exit. Message unchanged."); + continue; + } + + const result = await lintMessage(edited, cwd); + if (!result.valid) { + fail("Edited message failed commitlint:"); + for (const e of result.errors) { + process.stderr.write(` ${e.name}: ${e.message}\n`); + } + info("Try again or accept the original."); + continue; + } + + currentMessage = edited; + commit(currentMessage); + ok("Committed with edited message."); + return; + } + + if (answer === "r" || answer === "regenerate") { + if (regenerateCount >= maxRegenerations) { + fail(`Maximum regenerations (${maxRegenerations}) reached.`); + continue; + } + + regenerateCount++; + info(`Regenerating... (${regenerateCount}/${maxRegenerations})`); + + try { + const result = await regenerate(); + currentMessage = result.message; + for (const w of result.warnings) { + warn(w); + } + ok("New message generated."); + } catch (e) { + fail(`Regeneration failed: ${e.message}`); + } + continue; + } + + if (answer === "c" || answer === "cancel" || answer === "q" || answer === "quit") { + info("Cancelled. No commit created."); + return; + } + + warn("Unknown choice. Use: a(ccept), e(dit), r(egenerate), c(ancel)"); + } +} + +module.exports = { interactiveCommit }; diff --git a/lib/core/openai.js b/lib/core/openai.js index edf47c9..b33cb41 100644 --- a/lib/core/openai.js +++ b/lib/core/openai.js @@ -1,7 +1,7 @@ "use strict"; -const OpenAI = require("openai"); const { COMMIT_TYPES } = require("../rules.js"); +const { createProvider, hasApiKey } = require("../providers/index.js"); const { parseMessage, extractTypeAndSubject, @@ -13,7 +13,14 @@ const { const DEFAULT_MODEL = "gpt-4o-mini"; const DIFF_PROMPT_SLICE = 12000; +const SYSTEM_PROMPT = + "You produce strict Conventional Commit messages. Body text follows classic Beams-style prose: full sentences, imperative clarity, 72-character wrap, and normal sentence capitalization (capitalize the first word of each sentence; proper nouns as in English)."; + +/** + * @deprecated Use createProvider() + provider.complete() directly. Kept for backwards compat. + */ function getClient() { + const OpenAI = require("openai"); const key = process.env.OPENAI_API_KEY; if (!key) { const err = new Error( @@ -25,21 +32,18 @@ function getClient() { return new OpenAI({ apiKey: key }); } -async function callOpenAI({ prompt, model = process.env.COMMIT_AI_MODEL || DEFAULT_MODEL }) { - const openai = getClient(); - const response = await openai.chat.completions.create({ +async function callProvider({ prompt, model, provider }) { + const p = provider || createProvider(); + return p.complete({ + systemPrompt: SYSTEM_PROMPT, + userPrompt: prompt, model, - temperature: 0.1, - messages: [ - { - role: "system", - content: - "You produce strict Conventional Commit messages. Body text follows classic Beams-style prose: full sentences, imperative clarity, 72-character wrap, and normal sentence capitalization (capitalize the first word of each sentence; proper nouns as in English).", - }, - { role: "user", content: prompt }, - ], }); - return response.choices?.[0]?.message?.content?.trim() || ""; +} + +/** @deprecated Alias for callProvider — kept for backwards compatibility. */ +async function callOpenAI({ prompt, model }) { + return callProvider({ prompt, model }); } function coerceType(type) { @@ -187,7 +191,7 @@ ${basePrompt} } /** - * Full pipeline: AI (no scope in header) → inject deterministic scope → wrap → issue footers → lint → one retry. + * Full pipeline: AI (no scope in header) -> inject deterministic scope -> wrap -> issue footers -> lint -> one retry. */ async function generateCommitMessageFull( { @@ -197,7 +201,7 @@ async function generateCommitMessageFull( scope, breakingAllowed, }, - { cwd, model } = {}, + { cwd, model, provider } = {}, ) { const basePrompt = buildBasePrompt({ diff, @@ -207,10 +211,11 @@ async function generateCommitMessageFull( }); const { lintMessage } = require("./lint.js"); + const p = provider || createProvider(); let raw = ""; try { - raw = await callOpenAI({ prompt: basePrompt, model }); + raw = await callProvider({ prompt: basePrompt, model, provider: p }); } catch { return ""; } @@ -224,9 +229,10 @@ async function generateCommitMessageFull( const feedback = formatLintErrors(result); let retryRaw = ""; try { - retryRaw = await callOpenAI({ + retryRaw = await callProvider({ prompt: buildRetryPrompt(basePrompt, feedback), model, + provider: p, }); } catch { return ""; @@ -241,9 +247,13 @@ async function generateCommitMessageFull( module.exports = { generateCommitMessageFull, + callProvider, callOpenAI, assembleFromRaw, buildBasePrompt, + buildRetryPrompt, getClient, + hasApiKey, DEFAULT_MODEL, + SYSTEM_PROMPT, }; diff --git a/lib/providers/anthropic.js b/lib/providers/anthropic.js new file mode 100644 index 0000000..4abab55 --- /dev/null +++ b/lib/providers/anthropic.js @@ -0,0 +1,35 @@ +"use strict"; + +const { BaseProvider } = require("./base.js"); + +class AnthropicProvider extends BaseProvider { + get name() { + return "anthropic"; + } + + async complete({ systemPrompt, userPrompt, model, temperature }) { + let Anthropic; + try { + Anthropic = require("@anthropic-ai/sdk"); + } catch { + throw new Error( + "Anthropic provider requires @anthropic-ai/sdk. Install it with: npm install @anthropic-ai/sdk", + ); + } + const client = new Anthropic({ + apiKey: this.apiKey, + ...(this.baseUrl ? { baseURL: this.baseUrl } : {}), + }); + const response = await client.messages.create({ + model: model || this.model || "claude-sonnet-4-20250514", + max_tokens: 1024, + temperature: temperature ?? this.temperature, + system: systemPrompt, + messages: [{ role: "user", content: userPrompt }], + }); + const block = response.content?.[0]; + return block?.type === "text" ? block.text.trim() : ""; + } +} + +module.exports = { AnthropicProvider }; diff --git a/lib/providers/azure-openai.js b/lib/providers/azure-openai.js new file mode 100644 index 0000000..1797500 --- /dev/null +++ b/lib/providers/azure-openai.js @@ -0,0 +1,37 @@ +"use strict"; + +const { BaseProvider } = require("./base.js"); + +class AzureOpenAIProvider extends BaseProvider { + constructor(opts = {}) { + super(opts); + this.deployment = opts.deployment; + this.apiVersion = opts.apiVersion || "2024-08-01-preview"; + } + + get name() { + return "azure-openai"; + } + + async complete({ systemPrompt, userPrompt, model, temperature }) { + const OpenAI = require("openai"); + const { AzureOpenAI } = OpenAI; + const client = new AzureOpenAI({ + apiKey: this.apiKey, + endpoint: this.baseUrl, + deployment: model || this.deployment || this.model, + apiVersion: this.apiVersion, + }); + const response = await client.chat.completions.create({ + model: model || this.deployment || this.model, + temperature: temperature ?? this.temperature, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }); + return response.choices?.[0]?.message?.content?.trim() || ""; + } +} + +module.exports = { AzureOpenAIProvider }; diff --git a/lib/providers/base.js b/lib/providers/base.js new file mode 100644 index 0000000..1407dba --- /dev/null +++ b/lib/providers/base.js @@ -0,0 +1,32 @@ +"use strict"; + +class BaseProvider { + constructor({ apiKey, baseUrl, model, temperature = 0.1 } = {}) { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.model = model; + this.temperature = temperature; + } + + get name() { + throw new Error("Provider must implement get name()"); + } + + /** + * @param {object} opts + * @param {string} opts.systemPrompt + * @param {string} opts.userPrompt + * @param {string} [opts.model] + * @param {number} [opts.temperature] + * @returns {Promise} The generated text content. + */ + async complete({ systemPrompt, userPrompt, model, temperature }) { + void systemPrompt; + void userPrompt; + void model; + void temperature; + throw new Error("Provider must implement complete()"); + } +} + +module.exports = { BaseProvider }; diff --git a/lib/providers/index.js b/lib/providers/index.js new file mode 100644 index 0000000..2bd9806 --- /dev/null +++ b/lib/providers/index.js @@ -0,0 +1,101 @@ +"use strict"; + +const { OpenAIProvider } = require("./openai.js"); +const { AnthropicProvider } = require("./anthropic.js"); +const { OllamaProvider } = require("./ollama.js"); +const { AzureOpenAIProvider } = require("./azure-openai.js"); + +const PROVIDERS = { + openai: OpenAIProvider, + anthropic: AnthropicProvider, + ollama: OllamaProvider, + "azure-openai": AzureOpenAIProvider, +}; + +function resolveApiKey(providerName) { + if (providerName === "anthropic") { + return process.env.COMMIT_AI_API_KEY || process.env.ANTHROPIC_API_KEY; + } + if (providerName === "ollama") { + return "not-required"; + } + if (providerName === "azure-openai") { + return process.env.COMMIT_AI_API_KEY || process.env.AZURE_OPENAI_API_KEY; + } + return process.env.COMMIT_AI_API_KEY || process.env.OPENAI_API_KEY; +} + +function resolveModel(providerName) { + const envModel = process.env.COMMIT_AI_MODEL; + if (envModel) return envModel; + const defaults = { + openai: "gpt-4o-mini", + anthropic: "claude-sonnet-4-20250514", + ollama: "llama3.2", + "azure-openai": undefined, + }; + return defaults[providerName]; +} + +/** + * Create a provider instance from environment variables. + * @param {object} [overrides] - Override env-derived values. + * @param {string} [overrides.provider] + * @param {string} [overrides.apiKey] + * @param {string} [overrides.baseUrl] + * @param {string} [overrides.model] + * @param {string} [overrides.deployment] - Azure deployment name. + * @param {string} [overrides.apiVersion] - Azure API version. + * @returns {import("./base.js").BaseProvider} + */ +function createProvider(overrides = {}) { + const providerName = overrides.provider || process.env.COMMIT_AI_PROVIDER || "openai"; + const Cls = PROVIDERS[providerName]; + if (!Cls) { + const valid = Object.keys(PROVIDERS).join(", "); + throw new Error(`Unknown provider "${providerName}". Valid providers: ${valid}`); + } + + const apiKey = overrides.apiKey || resolveApiKey(providerName); + if (!apiKey) { + const keyHints = { + openai: "OPENAI_API_KEY or COMMIT_AI_API_KEY", + anthropic: "ANTHROPIC_API_KEY or COMMIT_AI_API_KEY", + "azure-openai": "AZURE_OPENAI_API_KEY or COMMIT_AI_API_KEY", + }; + const hint = keyHints[providerName] || "COMMIT_AI_API_KEY"; + const err = new Error( + `API key not set for provider "${providerName}". Set ${hint} in your environment or .env file.`, + ); + err.code = "ENOKEY"; + throw err; + } + + const baseUrl = overrides.baseUrl || process.env.COMMIT_AI_BASE_URL || undefined; + const model = overrides.model || resolveModel(providerName); + + const opts = { apiKey, baseUrl, model }; + + if (providerName === "azure-openai") { + opts.baseUrl = overrides.baseUrl || process.env.AZURE_OPENAI_ENDPOINT || baseUrl; + opts.deployment = overrides.deployment || process.env.AZURE_OPENAI_DEPLOYMENT; + opts.apiVersion = overrides.apiVersion || process.env.AZURE_OPENAI_API_VERSION; + } + + return new Cls(opts); +} + +function hasApiKey() { + const providerName = process.env.COMMIT_AI_PROVIDER || "openai"; + try { + return !!resolveApiKey(providerName); + } catch { + return false; + } +} + +function getProviderNames() { + return Object.keys(PROVIDERS); +} + +module.exports = { createProvider, hasApiKey, getProviderNames, PROVIDERS }; diff --git a/lib/providers/ollama.js b/lib/providers/ollama.js new file mode 100644 index 0000000..8109831 --- /dev/null +++ b/lib/providers/ollama.js @@ -0,0 +1,38 @@ +"use strict"; + +const { BaseProvider } = require("./base.js"); + +const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434"; + +class OllamaProvider extends BaseProvider { + get name() { + return "ollama"; + } + + async complete({ systemPrompt, userPrompt, model, temperature }) { + const baseUrl = (this.baseUrl || DEFAULT_OLLAMA_BASE_URL).replace(/\/$/, ""); + const url = `${baseUrl}/api/chat`; + const body = { + model: model || this.model || "llama3.2", + stream: false, + options: { temperature: temperature ?? this.temperature }, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }; + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`Ollama API error ${res.status}: ${errText.slice(0, 200)}`); + } + const data = await res.json(); + return (data.message?.content || "").trim(); + } +} + +module.exports = { OllamaProvider }; diff --git a/lib/providers/openai.js b/lib/providers/openai.js new file mode 100644 index 0000000..6e743a3 --- /dev/null +++ b/lib/providers/openai.js @@ -0,0 +1,28 @@ +"use strict"; + +const { BaseProvider } = require("./base.js"); + +class OpenAIProvider extends BaseProvider { + get name() { + return "openai"; + } + + async complete({ systemPrompt, userPrompt, model, temperature }) { + const OpenAI = require("openai"); + const client = new OpenAI({ + apiKey: this.apiKey, + ...(this.baseUrl ? { baseURL: this.baseUrl } : {}), + }); + const response = await client.chat.completions.create({ + model: model || this.model || "gpt-4o-mini", + temperature: temperature ?? this.temperature, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }); + return response.choices?.[0]?.message?.content?.trim() || ""; + } +} + +module.exports = { OpenAIProvider }; diff --git a/package.json b/package.json index 6e26d09..d93d8c0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "commitlint", "conventional-commits", "openai", + "anthropic", + "ollama", + "azure-openai", + "ai", "git", "husky" ], @@ -55,6 +59,14 @@ "dotenv": "^16.4.7", "openai": "^6.33.0" }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.30.0" + }, + "peerDependenciesMeta": { + "@anthropic-ai/sdk": { + "optional": true + } + }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d20bb26..3ec48e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: '>=0.30.0' + version: 0.81.0 '@commitlint/cli': specifier: ^20.5.0 version: 20.5.0(@types/node@25.5.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.2) @@ -66,6 +69,15 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@anthropic-ai/sdk@0.81.0': + resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -74,6 +86,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -797,6 +813,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -1363,6 +1383,9 @@ packages: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tunnel@0.0.6: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} @@ -1514,6 +1537,10 @@ snapshots: '@actions/io@3.0.2': {} + '@anthropic-ai/sdk@0.81.0': + dependencies: + json-schema-to-ts: 3.1.1 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1522,6 +1549,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/runtime@7.29.2': {} + '@colors/colors@1.5.0': optional: true @@ -2312,6 +2341,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} json-with-bigint@3.5.8: {} @@ -2791,6 +2825,8 @@ snapshots: traverse@0.6.8: {} + ts-algebra@2.0.0: {} + tunnel@0.0.6: {} type-fest@1.4.0: {} diff --git a/tools/lib/pr-ai.js b/tools/lib/pr-ai.js index 85094b9..71cdf91 100644 --- a/tools/lib/pr-ai.js +++ b/tools/lib/pr-ai.js @@ -1,32 +1,24 @@ "use strict"; -const OpenAI = require("openai"); +const { createProvider } = require("../../lib/providers/index.js"); function isAiEnabled() { return (process.env.PR_AI || "").toLowerCase() === "true"; } -function requiredAiEnv(name) { - const v = process.env[name]; - if (!v) throw new Error(`PR_AI is enabled but missing ${name}`); - return v; -} - -function normalizeOpenAIBaseUrl(endpoint) { - const u = new URL(endpoint); - let path = u.pathname.replace(/\/$/, ""); - if (path.endsWith("/responses") || path.endsWith("/chat/completions")) { - path = path.replace(/\/[^/]+$/, ""); - u.pathname = path || "/"; - } - return u.toString().replace(/\/$/, ""); -} - -function getPrAiClient() { - const apiKey = requiredAiEnv("PR_AI_API_KEY"); - const endpoint = requiredAiEnv("PR_AI_ENDPOINT"); - const baseURL = normalizeOpenAIBaseUrl(endpoint); - return new OpenAI({ apiKey, baseURL }); +function getPrProvider() { + const apiKey = process.env.PR_AI_API_KEY; + const endpoint = process.env.PR_AI_ENDPOINT; + if (!apiKey) throw new Error("PR_AI is enabled but missing PR_AI_API_KEY"); + if (!endpoint) throw new Error("PR_AI is enabled but missing PR_AI_ENDPOINT"); + + const providerName = process.env.PR_AI_PROVIDER || "openai"; + return createProvider({ + provider: providerName, + apiKey, + baseUrl: endpoint, + model: process.env.PR_AI_MODEL || "gpt-4o-mini", + }); } function extractAllowedShortHashes(commits) { @@ -67,13 +59,12 @@ function replaceSummarySection(body, summaryBullets) { } async function generateAiSummary({ title, commits, fileChanges, allowedHashes }) { - const openai = getPrAiClient(); - const model = process.env.PR_AI_MODEL || "gpt-4o-mini"; + const provider = getPrProvider(); const minBullets = Math.min(6, allowedHashes.length); const maxBullets = Math.min(12, allowedHashes.length); const targetBullets = Math.min(10, Math.ceil(allowedHashes.length * 0.8)); - const system = [ + const systemPrompt = [ "You write pull request summaries for an enterprise repo.", "You MUST NOT invent changes, files, or behaviors.", "You may ONLY summarize what is present in the provided commits and file list.", @@ -89,7 +80,7 @@ async function generateAiSummary({ title, commits, fileChanges, allowedHashes }) const fileList = fileChanges.files.map(f => `- ${f.status} ${f.path}`).join("\n") || "- (none)"; const diffStat = fileChanges.stat || "(no diff)"; - const user = [ + const userPrompt = [ `PR Title: ${title}`, "", `Allowed short hashes: ${allowedHashes.join(", ")}`, @@ -106,16 +97,7 @@ async function generateAiSummary({ title, commits, fileChanges, allowedHashes }) "Write the PR Summary bullets now.", ].join("\n"); - const response = await openai.chat.completions.create({ - model, - temperature: 0, - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - }); - - const text = response.choices?.[0]?.message?.content?.trim() || ""; + const text = await provider.complete({ systemPrompt, userPrompt }); const bullets = String(text) .split("\n") @@ -127,10 +109,9 @@ async function generateAiSummary({ title, commits, fileChanges, allowedHashes }) } async function generateAiLabelsAndChecklist({ title, body, commits }) { - const openai = getPrAiClient(); - const model = process.env.PR_AI_MODEL || "gpt-4o-mini"; + const provider = getPrProvider(); - const system = [ + const systemPrompt = [ "You suggest GitHub PR labels and a short review checklist for the author/CI.", "Output ONLY valid markdown in this exact structure (no extra text):", "## Suggested labels / Review checklist", @@ -144,7 +125,7 @@ async function generateAiLabelsAndChecklist({ title, body, commits }) { ].join("\n"); const commitList = commits.map(c => `- ${c.hash.slice(0, 7)} ${c.subjectLine}`).join("\n"); - const user = [ + const userPrompt = [ `PR Title: ${title}`, "", "PR body (excerpt):", @@ -156,16 +137,7 @@ async function generateAiLabelsAndChecklist({ title, body, commits }) { 'Output the "Suggested labels / Review checklist" section only.', ].join("\n"); - const response = await openai.chat.completions.create({ - model, - temperature: 0, - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - }); - - const text = response.choices?.[0]?.message?.content?.trim() || ""; + const text = await provider.complete({ systemPrompt, userPrompt }); const trimmed = String(text).trim(); if (!trimmed) return ""; return trimmed.startsWith("## ") ? trimmed : `## Suggested labels / Review checklist\n\n${trimmed}`; diff --git a/tools/semantic-release-notes.cjs b/tools/semantic-release-notes.cjs index a43408d..bd4e3b4 100644 --- a/tools/semantic-release-notes.cjs +++ b/tools/semantic-release-notes.cjs @@ -3,26 +3,20 @@ require("../lib/load-project-env.js").loadProjectEnv(); const { buildDeterministicReleaseNotes } = require("./lib/conventional-notes.js"); - -function normalizeOpenAIBaseUrl(endpoint) { - const u = new URL(endpoint); - let path = u.pathname.replace(/\/$/, ""); - if (path.endsWith("/responses") || path.endsWith("/chat/completions")) { - path = path.replace(/\/[^/]+$/, ""); - u.pathname = path || "/"; - } - return u.toString().replace(/\/$/, ""); -} +const { createProvider } = require("../lib/providers/index.js"); async function maybeGenerateAiSummary({ baseNotes, commitRefs, env }) { const enabled = (env.RELEASE_NOTES_AI || "").toLowerCase() === "true"; const endpoint = env.RELEASE_NOTES_AI_ENDPOINT; const apiKey = env.RELEASE_NOTES_AI_API_KEY; const model = env.RELEASE_NOTES_AI_MODEL || "gpt-4o-mini"; + const debug = (env.RELEASE_NOTES_AI_DEBUG || "").toLowerCase() === "true"; if (!enabled || !endpoint || !apiKey) return null; - const system = [ + const providerName = env.RELEASE_NOTES_AI_PROVIDER || "openai"; + + const systemPrompt = [ "You write release note summaries for enterprise change logs.", "You MUST NOT invent changes.", "You may ONLY summarize what appears in the provided release notes.", @@ -31,7 +25,7 @@ async function maybeGenerateAiSummary({ baseNotes, commitRefs, env }) { "No headings, no prose paragraphs, bullets only.", ].join(" "); - const user = [ + const userPrompt = [ "Allowed commit hashes:", commitRefs.join(", "), "", @@ -39,53 +33,23 @@ async function maybeGenerateAiSummary({ baseNotes, commitRefs, env }) { baseNotes, ].join("\n"); - const baseURL = normalizeOpenAIBaseUrl(endpoint); - const url = `${baseURL}/chat/completions`; - - const body = { - model, - temperature: 0, - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - }; - - const debug = (env.RELEASE_NOTES_AI_DEBUG || "").toLowerCase() === "true"; - - const res = await fetch(url, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - if (debug) { - const errBody = await res.text(); - console.warn("[release-notes-ai] API non-OK: %s %s", res.status, errBody.slice(0, 300)); - } + let text; + try { + const provider = createProvider({ + provider: providerName, + apiKey, + baseUrl: endpoint, + model, + }); + text = await provider.complete({ systemPrompt, userPrompt, temperature: 0 }); + } catch (e) { + if (debug) console.warn("[release-notes-ai] provider error:", e.message); return null; } - const data = await res.json(); - - const raw = - data.output_text || - data.text || - (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || - (data.output && - data.output[0] && - data.output[0].content && - data.output[0].content[0] && - data.output[0].content[0].text) || - ""; - const text = typeof raw === "string" ? raw : ""; - - if (debug) console.warn("[release-notes-ai] response text length: %d", text.length); - - const bullets = text + + if (debug) console.warn("[release-notes-ai] response text length: %d", (text || "").length); + + const bullets = (text || "") .split("\n") .map(s => s.trim()) .filter(Boolean) From aa1bce99249ed8032af7ed1240f9fcb0e57fe5f1 Mon Sep 17 00:00:00 2001 From: Renan Garcia Date: Wed, 1 Apr 2026 09:30:57 -0500 Subject: [PATCH 2/3] chore(cli): Remove unused import from CLI script This commit removes the unused `cyan` import from the `format.js` module in the CLI script. The `cyan` variable was not utilized in the code, which helps to keep the codebase clean and maintainable. By eliminating unnecessary imports, we reduce potential confusion for future developers and improve the overall readability of the code. This change does not affect any functionality or introduce any new features. --- bin/cli.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/cli.js b/bin/cli.js index 5542cd2..c0ce87d 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -14,7 +14,7 @@ const { commitFromFile, } = require("../lib/core/git.js"); const { getProviderNames, hasApiKey } = require("../lib/providers/index.js"); -const { formatCommitMessage, ok, warn: fmtWarn, fail, info, createSpinner, bold, dim, cyan, green } = require("../lib/core/format.js"); +const { formatCommitMessage, ok, warn: fmtWarn, fail, info, createSpinner, bold, dim, green } = require("../lib/core/format.js"); const { interactiveCommit } = require("../lib/core/interactive.js"); function presetPath() { From 10e78224b5662ffd3ba8d86378edf3f6f0c98c0b Mon Sep 17 00:00:00 2001 From: Renan Garcia Date: Mon, 6 Apr 2026 09:52:06 -0500 Subject: [PATCH 3/3] chore(cli): Update CLI commands and environment configuration This commit modifies the command-line interface for the AI-assisted conventional commit tool. The primary change is the renaming of the command from `commit-ai` to `ai-commit`, which enhances clarity and consistency across the tool's usage. Additionally, the environment configuration file has been updated to include new provider options and model settings. This change allows users to customize their AI provider and model more effectively, improving the overall flexibility of the tool. These updates aim to streamline user experience and provide clearer guidance on configuration options. --- .env-example | 23 ++++++- bin/cli.js | 28 ++++----- tools/lib/pr-ai.js | 153 --------------------------------------------- 3 files changed, 35 insertions(+), 169 deletions(-) delete mode 100644 tools/lib/pr-ai.js diff --git a/.env-example b/.env-example index 2ac5491..aa8652b 100644 --- a/.env-example +++ b/.env-example @@ -1,9 +1,28 @@ # ------------------------------------------------------------ # @verndale/ai-commit (pnpm commit / ai-commit run) # ------------------------------------------------------------ + +# --- Provider configuration --- +# COMMIT_AI_PROVIDER=openai # openai (default), anthropic, ollama, azure-openai +# COMMIT_AI_MODEL=gpt-4o-mini # Model override (default depends on provider) +# COMMIT_AI_API_KEY= # Universal API key (or use provider-specific below) +# COMMIT_AI_BASE_URL= # Custom API base URL + +# --- OpenAI (default provider) --- # @verndale/ai-commit — OPENAI_API_KEY: OpenAI API key for conventional commit messages (ai-commit run; optional for prepare-commit-msg with AI). OPENAI_API_KEY= -# Optional — default is gpt-4o-mini -# COMMIT_AI_MODEL= +# --- Anthropic --- +# ANTHROPIC_API_KEY= + +# --- Azure OpenAI --- +# AZURE_OPENAI_API_KEY= +# AZURE_OPENAI_ENDPOINT=https://.openai.azure.com +# AZURE_OPENAI_DEPLOYMENT= +# AZURE_OPENAI_API_VERSION=2024-08-01-preview + +# --- Ollama (local, no API key needed) --- +# COMMIT_AI_PROVIDER=ollama +# COMMIT_AI_BASE_URL=http://localhost:11434 +# COMMIT_AI_MODEL=llama3.2 diff --git a/bin/cli.js b/bin/cli.js index 2aabda9..ee0952c 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -41,15 +41,15 @@ function commitlintCliPath() { } function printHelp() { - process.stdout.write(`${bold("commit-ai")} — AI-assisted conventional commits with bundled commitlint + process.stdout.write(`${bold("ai-commit")} — AI-assisted conventional commits with bundled commitlint ${bold("Usage:")} - commit-ai run [options] - commit-ai init [--force] [--env-only] [--husky] [--workspace] - commit-ai prepare-commit-msg [source] - commit-ai lint --edit - commit-ai config [--init] - commit-ai hooks install + ai-commit run [options] + ai-commit init [--force] [--env-only] [--husky] [--workspace] + ai-commit prepare-commit-msg [source] + ai-commit lint --edit + ai-commit config [--init] + ai-commit hooks install ${bold("Commands:")} ${green("run")} Generate a message from the staged diff and run git commit. @@ -57,7 +57,7 @@ ${bold("Commands:")} ${green("prepare-commit-msg")} Git hook: fill an empty commit message file. ${green("lint")} Run commitlint with the bundled config (for commit-msg hook). ${green("config")} Show resolved configuration (or --init to create config file). - ${green("hooks")} Install git hooks (commit-ai hooks install). + ${green("hooks")} Install git hooks (ai-commit hooks install). ${bold("Run Options:")} --dry-run Generate and display the message without committing. @@ -311,7 +311,7 @@ function stripGitComments(text) { async function cmdRun(flags) { assertInGitRepo(); if (!hasStagedChanges()) { - fail("No staged changes. Stage files before running commit-ai."); + fail("No staged changes. Stage files before running ai-commit."); process.exit(1); } @@ -399,7 +399,7 @@ function cmdLint(editFile) { function cmdConfig(argv) { if (argv.includes("--init")) { - const configPath = path.join(process.cwd(), ".commit-airc.json"); + const configPath = path.join(process.cwd(), ".ai-commitrc.json"); if (fs.existsSync(configPath)) { info(`Config file already exists: ${configPath}`); return; @@ -451,8 +451,8 @@ function cmdHooksInstall() { const huskyDir = path.join(cwd, ".husky"); const gitHooksDir = path.join(cwd, ".git", "hooks"); - const prepareCommitMsg = 'commit-ai prepare-commit-msg "$1" "$2"\n'; - const commitMsg = 'commit-ai lint --edit "$1"\n'; + const prepareCommitMsg = 'ai-commit prepare-commit-msg "$1" "$2"\n'; + const commitMsg = 'ai-commit lint --edit "$1"\n'; if (fs.existsSync(huskyDir)) { const prepareFile = path.join(huskyDir, "prepare-commit-msg"); @@ -524,9 +524,9 @@ async function main() { cmdHooksInstall(); return; } - throw new Error("Usage: commit-ai hooks install"); + throw new Error("Usage: ai-commit hooks install"); } - throw new Error(`Unknown command: ${cmd}. Run commit-ai --help for usage.`); + throw new Error(`Unknown command: ${cmd}. Run ai-commit --help for usage.`); } main().catch((e) => { diff --git a/tools/lib/pr-ai.js b/tools/lib/pr-ai.js deleted file mode 100644 index 71cdf91..0000000 --- a/tools/lib/pr-ai.js +++ /dev/null @@ -1,153 +0,0 @@ -"use strict"; - -const { createProvider } = require("../../lib/providers/index.js"); - -function isAiEnabled() { - return (process.env.PR_AI || "").toLowerCase() === "true"; -} - -function getPrProvider() { - const apiKey = process.env.PR_AI_API_KEY; - const endpoint = process.env.PR_AI_ENDPOINT; - if (!apiKey) throw new Error("PR_AI is enabled but missing PR_AI_API_KEY"); - if (!endpoint) throw new Error("PR_AI is enabled but missing PR_AI_ENDPOINT"); - - const providerName = process.env.PR_AI_PROVIDER || "openai"; - return createProvider({ - provider: providerName, - apiKey, - baseUrl: endpoint, - model: process.env.PR_AI_MODEL || "gpt-4o-mini", - }); -} - -function extractAllowedShortHashes(commits) { - return commits.map(c => c.hash.slice(0, 7).toLowerCase()); -} - -function validateAiBullets(bullets, allowedHashes) { - const minBullets = allowedHashes.length === 1 ? 1 : 2; - const maxBullets = Math.min(12, Math.max(2, allowedHashes.length)); - - if (!Array.isArray(bullets)) return false; - if (bullets.length < minBullets || bullets.length > maxBullets) return false; - - const allowedSet = new Set(allowedHashes.map(h => h.toLowerCase())); - - for (const b of bullets) { - if (typeof b !== "string") return false; - - const line = b.trim(); - - if (!line.startsWith("- ")) return false; - - const hasAllowed = allowedHashes.some(h => line.toLowerCase().includes(h.toLowerCase())); - if (!hasAllowed) return false; - - const looksLikeHash = line.match(/\b[a-f0-9]{7}\b/gi) || []; - for (const h of looksLikeHash) { - if (!allowedSet.has(h.toLowerCase())) return false; - } - } - - return true; -} - -function replaceSummarySection(body, summaryBullets) { - const replacement = ["## Summary (AI, bounded)", ...summaryBullets, ""].join("\n"); - return body.replace(/## Summary \(AI, bounded\)[\s\S]*?\n\n/, `${replacement}\n`); -} - -async function generateAiSummary({ title, commits, fileChanges, allowedHashes }) { - const provider = getPrProvider(); - const minBullets = Math.min(6, allowedHashes.length); - const maxBullets = Math.min(12, allowedHashes.length); - const targetBullets = Math.min(10, Math.ceil(allowedHashes.length * 0.8)); - - const systemPrompt = [ - "You write pull request summaries for an enterprise repo.", - "You MUST NOT invent changes, files, or behaviors.", - "You may ONLY summarize what is present in the provided commits and file list.", - `Output MUST contain between ${minBullets} and ${maxBullets} bullet points.`, - `Aim for ${targetBullets} bullet points.`, - "Each bullet MUST reference a DIFFERENT commit when possible.", - "EACH bullet MUST include at least one allowed short commit hash (7 chars).", - "Do not add headings. Bullets only.", - "Do not collapse multiple commits into a single generic bullet.", - ].join(" "); - - const commitList = commits.map(c => `- ${c.hash.slice(0, 7)} ${c.subjectLine}`).join("\n"); - const fileList = fileChanges.files.map(f => `- ${f.status} ${f.path}`).join("\n") || "- (none)"; - const diffStat = fileChanges.stat || "(no diff)"; - - const userPrompt = [ - `PR Title: ${title}`, - "", - `Allowed short hashes: ${allowedHashes.join(", ")}`, - "", - "Commits in this PR:", - commitList, - "", - "Files changed:", - fileList, - "", - "Diff stats:", - diffStat, - "", - "Write the PR Summary bullets now.", - ].join("\n"); - - const text = await provider.complete({ systemPrompt, userPrompt }); - - const bullets = String(text) - .split("\n") - .map(s => s.trim()) - .filter(Boolean) - .filter(s => s.startsWith("- ")); - - return bullets; -} - -async function generateAiLabelsAndChecklist({ title, body, commits }) { - const provider = getPrProvider(); - - const systemPrompt = [ - "You suggest GitHub PR labels and a short review checklist for the author/CI.", - "Output ONLY valid markdown in this exact structure (no extra text):", - "## Suggested labels / Review checklist", - "", - "**Suggested labels:** (comma-separated or bullet list; use common labels like docs, frontend, a11y, testing)", - "", - "**Review checklist:**", - "- [ ] Item 1 (e.g. Verify EE, Check a11y, Test in X)", - "- [ ] Item 2", - "Keep checklist to 3–6 items. Base suggestions only on the PR title, summary, and commit list.", - ].join("\n"); - - const commitList = commits.map(c => `- ${c.hash.slice(0, 7)} ${c.subjectLine}`).join("\n"); - const userPrompt = [ - `PR Title: ${title}`, - "", - "PR body (excerpt):", - body.slice(0, 4000), - "", - "Commits:", - commitList, - "", - 'Output the "Suggested labels / Review checklist" section only.', - ].join("\n"); - - const text = await provider.complete({ systemPrompt, userPrompt }); - const trimmed = String(text).trim(); - if (!trimmed) return ""; - return trimmed.startsWith("## ") ? trimmed : `## Suggested labels / Review checklist\n\n${trimmed}`; -} - -module.exports = { - isAiEnabled, - extractAllowedShortHashes, - validateAiBullets, - replaceSummarySection, - generateAiSummary, - generateAiLabelsAndChecklist, -};