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 aab6d60..ee0952c 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -16,6 +16,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, green } = require("../lib/core/format.js"); +const { interactiveCommit } = require("../lib/core/interactive.js"); const { mergeAiCommitEnvFile } = require("../lib/init-env.js"); const { resolveEnvExamplePath, findPackageRoot } = require("../lib/init-paths.js"); const { @@ -38,28 +41,55 @@ function commitlintCliPath() { } function printHelp() { - process.stdout.write(`ai-commit — conventional commits + bundled commitlint (mandatory deterministic scope; see README). + process.stdout.write(`${bold("ai-commit")} — AI-assisted conventional commits with bundled commitlint -Usage: - ai-commit run +${bold("Usage:")} + ai-commit run [options] ai-commit init [--force] [--env-only] [--husky] [--workspace] ai-commit prepare-commit-msg [source] ai-commit lint --edit - -Commands: - run Generate a message from the staged diff and run git commit. - init Merge env, then Husky + package.json + hooks (from a git repo). \`--env-only\` stops after env files. \`--husky\` skips package.json. Env merge targets \`.env.local\` when that file exists, else \`.env\`. \`--force\` replaces \`.env\` / example file / hooks (not a wholesale replace of \`.env.local\`). - 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). - -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). - -Loads \`.env\` then \`.env.local\` from the current working directory (\`.env.local\` overrides). + ai-commit config [--init] + ai-commit hooks install + +${bold("Commands:")} + ${green("run")} Generate a message from the staged diff and run git commit. + ${green("init")} Merge env, then Husky + package.json + hooks. Use --env-only, --husky, --workspace, --force. + ${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 (ai-commit hooks install). + +${bold("Run Options:")} + --dry-run Generate and display the message without committing. + -i, --interactive Review the message before committing (accept/edit/regenerate/cancel). + +${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]) { @@ -278,19 +308,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 ai-commit (e.g. pnpm commit).\n"); + fail("No staged changes. Stage files before running ai-commit."); 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) { @@ -333,6 +397,94 @@ function cmdLint(editFile) { process.exit(r.status ?? 1); } +function cmdConfig(argv) { + if (argv.includes("--init")) { + const configPath = path.join(process.cwd(), ".ai-commitrc.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 = '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"); + 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]; @@ -341,7 +493,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 === "init") { @@ -362,10 +515,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: ai-commit hooks install"); + } + throw new Error(`Unknown command: ${cmd}. Run ai-commit --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 e37a73b..c004912 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 984f056..eb5aaab 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "commitlint", "conventional-commits", "openai", + "anthropic", + "ollama", + "azure-openai", + "ai", "git", "husky" ], @@ -56,6 +60,14 @@ "dotenv": "^16.4.7", "openai": "^6.33.0" }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.30.0" + }, + "peerDependenciesMeta": { + "@anthropic-ai/sdk": { + "optional": true + } + }, "devDependencies": { "@verndale/ai-pr": "^1.1.2", "@semantic-release/changelog": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a0e645..113a763 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) @@ -69,6 +72,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'} @@ -77,6 +89,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'} @@ -809,6 +825,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==} @@ -1375,6 +1395,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'} @@ -1526,6 +1549,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 @@ -1534,6 +1561,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/runtime@7.29.2': {} + '@colors/colors@1.5.0': optional: true @@ -2330,6 +2359,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: {} @@ -2809,6 +2843,8 @@ snapshots: traverse@0.6.8: {} + ts-algebra@2.0.0: {} + tunnel@0.0.6: {} type-fest@1.4.0: {} 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)