CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-commit-message.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 9018b1f | 1 | /** |
| 2 | * AI-generated commit messages. | |
| 3 | * | |
| 4 | * Powers the `gluecron commit` CLI and the `POST /api/v2/ai/commit-message` | |
| 5 | * endpoint. Given a unified diff (typically `git diff --cached`), returns a | |
| 6 | * { subject, body } pair. | |
| 7 | * | |
| 8 | * - Conventional Commits style by default: `feat(scope): subject` etc. | |
| 9 | * - The body explains WHY (not what — the diff already says what). | |
| 10 | * - Falls back to a deterministic heuristic when ANTHROPIC_API_KEY is | |
| 11 | * missing, the model errors out, or the diff is empty. The CLI must | |
| 12 | * never block a developer on a Claude outage. | |
| 13 | * - Diff is capped at ~50KB before being sent to the model — past that | |
| 14 | * the signal-to-noise ratio drops and we'd just be burning tokens. | |
| 15 | * | |
| 16 | * Tests live in src/__tests__/ai-commit-message.test.ts. The module | |
| 17 | * exports a `__test` bag for the parser + truncation helpers so the | |
| 18 | * unit tests don't have to call the network path. | |
| 19 | */ | |
| 20 | ||
| 21 | import { | |
| 22 | getAnthropic, | |
| 23 | MODEL_HAIKU, | |
| 24 | extractText, | |
| 25 | parseJsonResponse, | |
| 26 | isAiAvailable, | |
| 27 | } from "./ai-client"; | |
| 28 | ||
| 29 | export type CommitStyle = "conventional" | "plain"; | |
| 30 | ||
| 31 | export interface CommitMessage { | |
| 32 | subject: string; | |
| 33 | body: string; | |
| 34 | } | |
| 35 | ||
| 36 | export interface GenerateOptions { | |
| 37 | style?: CommitStyle; | |
| 38 | } | |
| 39 | ||
| 40 | /** Max bytes of diff we send to the model. Past this we truncate. */ | |
| 41 | export const DIFF_BYTE_CAP = 50_000; | |
| 42 | const TRUNCATE_MARKER = "\n... (more)"; | |
| 43 | ||
| 44 | /** Conventional-commits types we accept in the subject. */ | |
| 45 | const CONVENTIONAL_TYPES = [ | |
| 46 | "feat", | |
| 47 | "fix", | |
| 48 | "docs", | |
| 49 | "style", | |
| 50 | "refactor", | |
| 51 | "perf", | |
| 52 | "test", | |
| 53 | "build", | |
| 54 | "ci", | |
| 55 | "chore", | |
| 56 | "revert", | |
| 57 | ] as const; | |
| 58 | ||
| 59 | type ConventionalType = (typeof CONVENTIONAL_TYPES)[number]; | |
| 60 | ||
| 61 | /** | |
| 62 | * Truncate a diff to DIFF_BYTE_CAP bytes, appending a marker so the model | |
| 63 | * (and any human reader) knows the input was cut. | |
| 64 | */ | |
| 65 | export function truncateDiff(diff: string, cap = DIFF_BYTE_CAP): string { | |
| 66 | if (diff.length <= cap) return diff; | |
| 67 | return diff.slice(0, cap - TRUNCATE_MARKER.length) + TRUNCATE_MARKER; | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Deterministic fallback: scan filenames + counts and emit a plausible | |
| 72 | * conventional-commit. Never blocks on a network call, never throws. | |
| 73 | * | |
| 74 | * Heuristics: | |
| 75 | * - "test" files → test | |
| 76 | * - "doc"/"readme"/".md" only → docs | |
| 77 | * - new files dominate → feat | |
| 78 | * - otherwise → chore | |
| 79 | * | |
| 80 | * Subject ≤ 72 chars; body lists the top-3 touched paths so the human | |
| 81 | * still has signal even when Claude is unavailable. | |
| 82 | */ | |
| 83 | export function heuristicMessage( | |
| 84 | diff: string, | |
| 85 | style: CommitStyle = "conventional" | |
| 86 | ): CommitMessage { | |
| 87 | const trimmed = diff.trim(); | |
| 88 | if (!trimmed) { | |
| 89 | return { | |
| 90 | subject: style === "conventional" ? "chore: update" : "Update", | |
| 91 | body: "", | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | // Pull file paths out of standard unified-diff headers. | |
| 96 | // We look at `+++ b/<path>` to favour the *new* side of renames. | |
| 97 | const paths: string[] = []; | |
| 98 | let added = 0; | |
| 99 | let removed = 0; | |
| 100 | let newFiles = 0; | |
| 101 | for (const line of trimmed.split("\n")) { | |
| 102 | if (line.startsWith("+++ b/")) { | |
| 103 | const p = line.slice("+++ b/".length).trim(); | |
| 104 | if (p && p !== "/dev/null") paths.push(p); | |
| 105 | } else if (line.startsWith("new file mode")) { | |
| 106 | newFiles++; | |
| 107 | } else if (line.startsWith("+") && !line.startsWith("+++")) { | |
| 108 | added++; | |
| 109 | } else if (line.startsWith("-") && !line.startsWith("---")) { | |
| 110 | removed++; | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | const onlyDocs = | |
| 115 | paths.length > 0 && | |
| 116 | paths.every((p) => /\.(md|mdx|rst|txt)$/i.test(p) || /readme/i.test(p)); | |
| 117 | const onlyTests = | |
| 118 | paths.length > 0 && paths.every((p) => /(^|\/)(__tests__|tests?)\//.test(p) || /\.test\.[tj]sx?$/.test(p)); | |
| 119 | const allNew = newFiles > 0 && newFiles === paths.length; | |
| 120 | ||
| 121 | let type: ConventionalType = "chore"; | |
| 122 | if (onlyDocs) type = "docs"; | |
| 123 | else if (onlyTests) type = "test"; | |
| 124 | else if (allNew) type = "feat"; | |
| 125 | else if (removed > added * 2) type = "refactor"; | |
| 126 | else type = "chore"; | |
| 127 | ||
| 128 | // Scope = top-level directory if all paths share one. | |
| 129 | const tops = new Set(paths.map((p) => p.split("/")[0]).filter(Boolean)); | |
| 130 | const scope = tops.size === 1 ? [...tops][0] : ""; | |
| 131 | ||
| 132 | const fileWord = paths.length === 1 ? "file" : "files"; | |
| 133 | const subjectRaw = | |
| 134 | paths.length === 0 | |
| 135 | ? "update files" | |
| 136 | : `update ${paths.length} ${fileWord}`; | |
| 137 | ||
| 138 | const subject = | |
| 139 | style === "conventional" | |
| 140 | ? `${type}${scope ? `(${scope})` : ""}: ${subjectRaw}` | |
| 141 | : subjectRaw.charAt(0).toUpperCase() + subjectRaw.slice(1); | |
| 142 | ||
| 143 | const cappedSubject = | |
| 144 | subject.length > 72 ? subject.slice(0, 69) + "..." : subject; | |
| 145 | ||
| 146 | const topPaths = paths.slice(0, 3); | |
| 147 | const body = | |
| 148 | paths.length === 0 | |
| 149 | ? "" | |
| 150 | : `Touched files:\n${topPaths.map((p) => `- ${p}`).join("\n")}` + | |
| 151 | (paths.length > topPaths.length | |
| 152 | ? `\n- ...and ${paths.length - topPaths.length} more` | |
| 153 | : ""); | |
| 154 | ||
| 155 | return { subject: cappedSubject, body }; | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Coerce arbitrary model output into a clean { subject, body } shape. | |
| 160 | * | |
| 161 | * Accepts (in order): | |
| 162 | * 1. A JSON object with `subject` / `body` fields. | |
| 163 | * 2. A code-fenced block whose first line looks like a commit subject. | |
| 164 | * 3. Raw text — first line as subject, the rest as body. | |
| 165 | * | |
| 166 | * Always strips backticks, trims, and caps the subject at 72 chars. | |
| 167 | * Exported for unit tests. | |
| 168 | */ | |
| 169 | export function parseModelOutput( | |
| 170 | text: string, | |
| 171 | style: CommitStyle = "conventional" | |
| 172 | ): CommitMessage { | |
| 173 | const cleaned = text.replace(/^```[a-zA-Z]*\n|\n```$/g, "").trim(); | |
| 174 | ||
| 175 | // Try JSON first. | |
| 176 | const parsed = parseJsonResponse<{ subject?: string; body?: string }>( | |
| 177 | cleaned | |
| 178 | ); | |
| 179 | let subject = ""; | |
| 180 | let body = ""; | |
| 181 | if (parsed && typeof parsed.subject === "string") { | |
| 182 | subject = parsed.subject.trim(); | |
| 183 | body = typeof parsed.body === "string" ? parsed.body.trim() : ""; | |
| 184 | } else { | |
| 185 | // Fall back to "first line = subject, rest = body" parsing. | |
| 186 | const lines = cleaned.split("\n"); | |
| 187 | subject = (lines[0] || "").trim(); | |
| 188 | body = lines.slice(1).join("\n").trim(); | |
| 189 | // Drop a single blank line between subject + body if present. | |
| 190 | if (body.startsWith("\n")) body = body.slice(1); | |
| 191 | } | |
| 192 | ||
| 193 | // Strip surrounding quotes / backticks the model sometimes adds. | |
| 194 | subject = subject.replace(/^["'`]+|["'`]+$/g, "").trim(); | |
| 195 | if (subject.length > 72) { | |
| 196 | subject = subject.slice(0, 69) + "..."; | |
| 197 | } | |
| 198 | if (!subject) { | |
| 199 | return { subject: style === "conventional" ? "chore: update" : "Update", body: "" }; | |
| 200 | } | |
| 201 | ||
| 202 | // If conventional style was requested but the model returned a bare | |
| 203 | // sentence, lightly normalise it. We do NOT try to re-classify — that | |
| 204 | // would be guessing — but we do prefix `chore:` so commitlint stays happy. | |
| 205 | if (style === "conventional" && !/^[a-z]+(\([^)]+\))?(!?):/.test(subject)) { | |
| 206 | subject = `chore: ${subject.charAt(0).toLowerCase()}${subject.slice(1)}`; | |
| 207 | if (subject.length > 72) subject = subject.slice(0, 69) + "..."; | |
| 208 | } | |
| 209 | ||
| 210 | return { subject, body }; | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Build the prompt sent to Claude. Pulled out so the test suite can | |
| 215 | * sanity-check it without spinning up the API client. | |
| 216 | */ | |
| 217 | export function buildPrompt(diff: string, style: CommitStyle): string { | |
| 218 | const styleInstruction = | |
| 219 | style === "conventional" | |
| 220 | ? `Use Conventional Commits style — the subject MUST start with one of: ${CONVENTIONAL_TYPES.join(", ")}, optionally followed by (scope), then ": ", then a lowercase imperative summary. Example: "feat(auth): add passkey login".` | |
| 221 | : `Write a plain-English subject in imperative mood (e.g. "Add passkey login"). No type prefix.`; | |
| 222 | ||
| 223 | return `You are writing a git commit message for the following staged diff. | |
| 224 | ||
| 225 | ${styleInstruction} | |
| 226 | ||
| 227 | Rules: | |
| 228 | - Subject MUST be 72 characters or fewer. | |
| 229 | - Subject is one line. No trailing period. | |
| 230 | - Body explains WHY the change was made when it is non-obvious. Skip the body for trivial changes. | |
| 231 | - Body lines wrap at ~72 chars. Use blank lines between paragraphs. | |
| 232 | - Do NOT describe what every file does — the diff already shows that. Focus on intent. | |
| 233 | - Do NOT include code fences, markdown, or extra commentary. | |
| 234 | ||
| 235 | Respond ONLY with JSON in this exact shape: | |
| 236 | {"subject": "...", "body": "..."} | |
| 237 | ||
| 238 | If the change is trivial enough that no body is needed, return body as "". | |
| 239 | ||
| 240 | Diff: | |
| 241 | \`\`\`diff | |
| 242 | ${diff} | |
| 243 | \`\`\``; | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Generate a commit message for the given diff. | |
| 248 | * | |
| 249 | * Never throws — on any error (no API key, model timeout, parse failure) | |
| 250 | * we fall back to the deterministic heuristic so the caller can always | |
| 251 | * present a draft to the developer. | |
| 252 | */ | |
| 253 | export async function generateCommitMessage( | |
| 254 | diff: string, | |
| 255 | opts: GenerateOptions = {} | |
| 256 | ): Promise<CommitMessage> { | |
| 257 | const style: CommitStyle = opts.style === "plain" ? "plain" : "conventional"; | |
| 258 | const trimmed = diff.trim(); | |
| 259 | ||
| 260 | if (!trimmed) { | |
| 261 | return { | |
| 262 | subject: style === "conventional" ? "chore: empty commit" : "Empty commit", | |
| 263 | body: "", | |
| 264 | }; | |
| 265 | } | |
| 266 | ||
| 267 | if (!isAiAvailable()) { | |
| 268 | return heuristicMessage(trimmed, style); | |
| 269 | } | |
| 270 | ||
| 271 | const truncated = truncateDiff(trimmed); | |
| 272 | ||
| 273 | try { | |
| 274 | const client = getAnthropic(); | |
| 275 | const message = await client.messages.create({ | |
| a5705cf | 276 | model: MODEL_SONNET, |
| 9018b1f | 277 | max_tokens: 512, |
| 278 | messages: [ | |
| 279 | { | |
| 280 | role: "user", | |
| 281 | content: buildPrompt(truncated, style), | |
| 282 | }, | |
| 283 | ], | |
| 284 | }); | |
| 1d4ff60 | 285 | try { |
| 286 | const { recordAiCost, extractUsage } = await import("./ai-cost-tracker"); | |
| 287 | const usage = extractUsage(message); | |
| 288 | await recordAiCost({ | |
| a5705cf | 289 | model: MODEL_SONNET, |
| 1d4ff60 | 290 | inputTokens: usage.input, |
| 291 | outputTokens: usage.output, | |
| 292 | category: "other", | |
| 293 | sourceKind: "commit_message", | |
| 294 | }); | |
| 295 | } catch { | |
| 296 | /* swallow — best-effort */ | |
| 297 | } | |
| 9018b1f | 298 | const text = extractText(message); |
| 299 | if (!text.trim()) { | |
| 300 | return heuristicMessage(trimmed, style); | |
| 301 | } | |
| 302 | return parseModelOutput(text, style); | |
| 303 | } catch { | |
| 304 | // Network/API failure → never block the developer; degrade. | |
| 305 | return heuristicMessage(trimmed, style); | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | /** Test-only exports — not part of the public API. */ | |
| 310 | export const __test = { | |
| 311 | truncateDiff, | |
| 312 | heuristicMessage, | |
| 313 | parseModelOutput, | |
| 314 | buildPrompt, | |
| 315 | CONVENTIONAL_TYPES, | |
| 316 | }; |