Commit9018b1funknown_key
feat(ai-commit): gluecron commit + prepare-commit-msg hook — Claude writes commit messages
7 files changed+1294−09018b1f424487a0d75d7d5b3374393eecbc99214
7 changed files+1298−0
Addedcli/commands/commit.ts+319−0View fileUnifiedSplit
@@ -0,0 +1,319 @@
1/**
2 * `gluecron commit` — wrap `git commit` with an AI-drafted message.
3 *
4 * Flow:
5 * 1. If `-m` is provided, just shell out to git commit verbatim.
6 * 2. Otherwise:
7 * a. Stage all if `-a` was passed.
8 * b. Read `git diff --cached`.
9 * c. POST to `/api/v2/ai/commit-message`.
10 * d. Print the proposed subject + body and prompt
11 * [y]es / [e]dit / [n]o.
12 * e. yes → git commit -m <subject> -m <body>
13 * edit → drop into $EDITOR with the message pre-filled,
14 * then `git commit -F <tmp>` after the editor exits.
15 * no → abort.
16 *
17 * The CLI is dependency-free Bun; we shell out to `git` via Bun.spawnSync.
18 * All exported helpers are pure functions so the test suite can exercise
19 * them without a real git repository.
20 */
21
22import { spawnSync } from "node:child_process";
23import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
24import { tmpdir } from "node:os";
25import { join } from "node:path";
26
27export interface CommitMessage {
28 subject: string;
29 body: string;
30}
31
32export interface CommitConfig {
33 host: string;
34 token?: string;
35}
36
37// ──────────────────────────── arg parsing ────────────────────────────
38
39export interface CommitArgs {
40 stageAll: boolean;
41 message?: string;
42 bodyExtra?: string;
43 style: "conventional" | "plain";
44 yes: boolean; // --yes / -y → skip the prompt
45}
46
47export function parseCommitArgs(argv: string[]): CommitArgs {
48 const args: CommitArgs = {
49 stageAll: false,
50 style: "conventional",
51 yes: false,
52 };
53 for (let i = 0; i < argv.length; i++) {
54 const a = argv[i];
55 if (a === "-a" || a === "--all") {
56 args.stageAll = true;
57 } else if (a === "-m" || a === "--message") {
58 args.message = argv[++i];
59 } else if (a === "-y" || a === "--yes") {
60 args.yes = true;
61 } else if (a === "--plain") {
62 args.style = "plain";
63 } else if (a === "--conventional") {
64 args.style = "conventional";
65 }
66 }
67 return args;
68}
69
70// ──────────────────────────── git wrappers ────────────────────────────
71
72function git(
73 argv: string[],
74 opts: { input?: string; cwd?: string } = {}
75): { stdout: string; stderr: string; exitCode: number } {
76 const r = spawnSync("git", argv, {
77 cwd: opts.cwd ?? process.cwd(),
78 input: opts.input,
79 encoding: "utf8",
80 stdio: ["pipe", "pipe", "pipe"],
81 });
82 return {
83 stdout: r.stdout || "",
84 stderr: r.stderr || "",
85 exitCode: r.status ?? 1,
86 };
87}
88
89export function readStagedDiff(stageAll: boolean): string {
90 // -a in `git commit` stages tracked changes before committing. We
91 // emulate that by passing `--cached` after a `git add -u` (tracked
92 // only — `git add -A` would sweep up untracked, which is NOT what
93 // `git commit -a` does).
94 if (stageAll) {
95 git(["add", "-u"]);
96 }
97 const r = git(["diff", "--cached"]);
98 if (r.exitCode !== 0) return "";
99 return r.stdout;
100}
101
102// ──────────────────────────── API call ────────────────────────────
103
104export async function requestCommitMessage(
105 cfg: CommitConfig,
106 diff: string,
107 style: "conventional" | "plain",
108 fetchImpl: typeof fetch = fetch
109): Promise<CommitMessage> {
110 const url = cfg.host.replace(/\/+$/, "") + "/api/v2/ai/commit-message";
111 const headers: Record<string, string> = {
112 "content-type": "application/json",
113 accept: "application/json",
114 };
115 if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
116
117 const res = await fetchImpl(url, {
118 method: "POST",
119 headers,
120 body: JSON.stringify({ diff, style }),
121 });
122 const text = await res.text();
123 let json: { subject?: string; body?: string; error?: string };
124 try {
125 json = JSON.parse(text);
126 } catch {
127 throw new Error(`Server returned non-JSON [${res.status}]: ${text.slice(0, 200)}`);
128 }
129 if (!res.ok) {
130 throw new Error(json?.error || `Server returned ${res.status}`);
131 }
132 return {
133 subject: typeof json.subject === "string" ? json.subject : "",
134 body: typeof json.body === "string" ? json.body : "",
135 };
136}
137
138// ──────────────────────────── prompt + editor ────────────────────────────
139
140export function formatProposedMessage(m: CommitMessage): string {
141 let out = `\nProposed commit message:\n\n ${m.subject}\n`;
142 if (m.body.trim()) {
143 out += "\n";
144 for (const line of m.body.split("\n")) {
145 out += ` ${line}\n`;
146 }
147 }
148 return out;
149}
150
151async function readChar(prompt: string): Promise<string> {
152 process.stdout.write(prompt);
153 return new Promise((resolve) => {
154 const onData = (chunk: Buffer | string) => {
155 process.stdin.removeListener("data", onData);
156 process.stdin.pause();
157 resolve(String(chunk).trim().toLowerCase());
158 };
159 process.stdin.resume();
160 process.stdin.once("data", onData);
161 });
162}
163
164export function editInEditor(m: CommitMessage): CommitMessage {
165 const editor = process.env.EDITOR || process.env.VISUAL || "vi";
166 const dir = mkdtempSync(join(tmpdir(), "gluecron-commit-"));
167 const file = join(dir, "COMMIT_EDITMSG");
168 const seed =
169 m.subject +
170 (m.body.trim() ? `\n\n${m.body}\n` : "\n") +
171 "\n# Edit the commit message above. Lines starting with '#' are ignored.\n";
172 writeFileSync(file, seed, "utf8");
173 // Bun has no synchronous spawn with TTY inheritance — node:child_process
174 // gives it to us cleanly. We block until the editor returns.
175 spawnSync(editor, [file], { stdio: "inherit" });
176 const raw = readFileSync(file, "utf8");
177 const lines = raw
178 .split("\n")
179 .filter((l) => !l.startsWith("#"))
180 .join("\n")
181 .trim();
182 if (!lines) return m;
183 const subject = (lines.split("\n")[0] || "").trim();
184 const body = lines.split("\n").slice(1).join("\n").trim();
185 return { subject, body };
186}
187
188// ──────────────────────────── main command ────────────────────────────
189
190export interface CommitDeps {
191 out: (msg: string) => void;
192 readChar?: (prompt: string) => Promise<string>;
193 fetchImpl?: typeof fetch;
194 editorEdit?: (m: CommitMessage) => CommitMessage;
195 // Test-only override — lets us bypass the network without a fake fetch.
196 generate?: (diff: string, style: "conventional" | "plain") => Promise<CommitMessage>;
197}
198
199/**
200 * Run `gluecron commit`. Returns the exit code.
201 *
202 * Pulled out from the dispatcher so the CLI test suite can drive it
203 * with synthetic deps and assert on behaviour without spawning git or
204 * hitting the network.
205 */
206export async function runCommit(
207 argv: string[],
208 cfg: CommitConfig,
209 deps: CommitDeps
210): Promise<number> {
211 const args = parseCommitArgs(argv);
212
213 // Fast path: -m provided → straight pass-through to git.
214 if (args.message) {
215 const gitArgs = ["commit"];
216 if (args.stageAll) gitArgs.push("-a");
217 gitArgs.push("-m", args.message);
218 if (args.bodyExtra) gitArgs.push("-m", args.bodyExtra);
219 const r = spawnSync("git", gitArgs, { stdio: "inherit" });
220 return r.status ?? 1;
221 }
222
223 // Pull the staged diff (after auto-adding tracked changes if -a).
224 const diff = readStagedDiff(args.stageAll);
225 if (!diff.trim()) {
226 deps.out(
227 "error: nothing to commit. Stage changes with `git add` (or pass -a)."
228 );
229 return 1;
230 }
231
232 // Ask the API for a message — or use the supplied generator (tests).
233 let proposed: CommitMessage;
234 try {
235 proposed = deps.generate
236 ? await deps.generate(diff, args.style)
237 : await requestCommitMessage(cfg, diff, args.style, deps.fetchImpl);
238 } catch (err) {
239 deps.out(`error: ${(err as Error).message}`);
240 return 1;
241 }
242
243 if (!proposed.subject.trim()) {
244 deps.out("error: AI returned an empty subject. Try again or pass -m.");
245 return 1;
246 }
247
248 deps.out(formatProposedMessage(proposed));
249
250 // Skip prompt when --yes was passed (CI / scripted commits).
251 let choice: string;
252 if (args.yes) {
253 choice = "y";
254 } else {
255 const rc = deps.readChar ?? readChar;
256 choice = await rc("Commit with this message? [y/e/n]: ");
257 }
258
259 if (choice === "n" || choice === "no") {
260 deps.out("Aborted.");
261 return 1;
262 }
263
264 let final = proposed;
265 if (choice === "e" || choice === "edit") {
266 final = (deps.editorEdit ?? editInEditor)(proposed);
267 if (!final.subject.trim()) {
268 deps.out("Aborted — empty subject after edit.");
269 return 1;
270 }
271 }
272
273 // Commit. We use two `-m` arguments so git formats the body the usual
274 // way (separated by a blank line from the subject).
275 const commitArgs = ["commit", "-m", final.subject];
276 if (final.body.trim()) {
277 commitArgs.push("-m", final.body);
278 }
279 const r = spawnSync("git", commitArgs, { stdio: "inherit" });
280 return r.status ?? 1;
281}
282
283// ──────────────────────────── `gluecron ai commit-msg` ────────────────────────────
284//
285// Used by the prepare-commit-msg hook. Reads diff from stdin (or runs
286// `git diff --cached`), asks the API for a message, prints just
287// `<subject>\n\n<body>` on stdout. No prompts, no edit flow.
288
289export interface AiCommitMsgDeps {
290 out: (msg: string) => void;
291 fetchImpl?: typeof fetch;
292 generate?: (diff: string, style: "conventional" | "plain") => Promise<CommitMessage>;
293 // Allow tests to supply an explicit diff (so we don't have to mock stdin).
294 diff?: string;
295}
296
297export async function runAiCommitMsg(
298 cfg: CommitConfig,
299 deps: AiCommitMsgDeps
300): Promise<number> {
301 const diff = deps.diff ?? readStagedDiff(false);
302 if (!diff.trim()) {
303 return 1; // hook will fall back to git's default empty message.
304 }
305 try {
306 const msg = deps.generate
307 ? await deps.generate(diff, "conventional")
308 : await requestCommitMessage(cfg, diff, "conventional", deps.fetchImpl);
309 if (!msg.subject.trim()) return 1;
310 if (msg.body.trim()) {
311 deps.out(`${msg.subject}\n\n${msg.body}`);
312 } else {
313 deps.out(msg.subject);
314 }
315 return 0;
316 } catch {
317 return 1;
318 }
319}
Modifiedcli/gluecron.ts+20−0View fileUnifiedSplit
@@ -20,6 +20,8 @@
2020import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2121import { homedir } from "node:os";
2222import { join } from "node:path";
23import { runCommit, runAiCommitMsg } from "./commands/commit";
24import { runHook } from "./install-hook";
2325
2426const VERSION = "0.1.0";
2527const DEFAULT_HOST = process.env.GLUECRON_HOST || "http://localhost:3000";
@@ -117,6 +119,11 @@ Usage:
117119 gluecron host [url] Get or set the server URL
118120 gluecron deploy [--repo owner/name] [--workflow id] [--ref branch] [--no-watch]
119121 Trigger a Hetzner deploy via GitHub Actions
122 gluecron commit [-a] [-m <msg>] [-y] [--plain]
123 Commit with an AI-drafted message
124 gluecron ai commit-msg Print an AI commit draft (used by hooks)
125 gluecron hook install commit-msg Install the prepare-commit-msg hook
126 gluecron hook uninstall commit-msg Remove the hook
120127 gluecron config set <key> <value> Set a config value (e.g. github-token)
121128 gluecron version Print version
122129 gluecron help Print this help
@@ -684,6 +691,19 @@ export async function dispatch(argv: string[], out = console.log): Promise<numbe
684691 return await handleGqlCmd(cfg, rest, out);
685692 case "deploy":
686693 return await handleDeployCmd(cfg, rest, out);
694 case "commit":
695 return await runCommit(rest, { host: cfg.host, token: cfg.token }, { out });
696 case "ai":
697 if (rest[0] === "commit-msg") {
698 return await runAiCommitMsg(
699 { host: cfg.host, token: cfg.token },
700 { out }
701 );
702 }
703 out("usage: gluecron ai commit-msg");
704 return 1;
705 case "hook":
706 return await runHook(rest, { out });
687707 case "config":
688708 return await handleConfigCmd(cfg, rest, out);
689709 default:
Addedcli/install-hook.ts+182−0View fileUnifiedSplit
@@ -0,0 +1,182 @@
1/**
2 * `gluecron hook install commit-msg` / `gluecron hook uninstall commit-msg`
3 *
4 * Installs a `.git/hooks/prepare-commit-msg` script that calls
5 * `gluecron ai commit-msg` and writes the AI-drafted message into the
6 * commit-message file — but only when the file is empty (so explicit
7 * `-m` and amends are untouched).
8 *
9 * Idempotent: re-running `install` overwrites the file; `uninstall`
10 * only removes a file that carries our marker (so a hand-written hook
11 * is never clobbered).
12 */
13
14import {
15 existsSync,
16 readFileSync,
17 writeFileSync,
18 chmodSync,
19 unlinkSync,
20 mkdirSync,
21} from "node:fs";
22import { spawnSync } from "node:child_process";
23import { join } from "node:path";
24
25export const HOOK_MARKER = "# gluecron-prepare-commit-msg-hook";
26
27/**
28 * Body of the hook script. Kept in a separate function so the test
29 * suite can assert on the contents without touching the filesystem.
30 *
31 * $1 — path to the message file
32 * $2 — source ("message" | "template" | "commit" | "merge" | "squash" | "")
33 * $3 — sha (when amending)
34 *
35 * We only fire when $2 is empty (a vanilla `git commit` with no -m).
36 * Otherwise we leave the message untouched so amends, merges, and
37 * `-m "..."` keep their existing behaviour.
38 */
39export function hookScript(binPath: string): string {
40 return `#!/usr/bin/env bash
41${HOOK_MARKER}
42#
43# Installed by \`gluecron hook install commit-msg\`. Calls the gluecron CLI
44# to draft a commit message when none was supplied.
45
46set -e
47
48COMMIT_MSG_FILE="$1"
49COMMIT_SOURCE="$2"
50
51# Only act on a plain \`git commit\` (no -m, no template, no amend).
52if [ -n "$COMMIT_SOURCE" ]; then
53 exit 0
54fi
55
56# Bail if the user already typed something.
57if [ -s "$COMMIT_MSG_FILE" ]; then
58 existing=$(grep -v '^#' "$COMMIT_MSG_FILE" | tr -d '[:space:]' || true)
59 if [ -n "$existing" ]; then
60 exit 0
61 fi
62fi
63
64DRAFT=$(${binPath} ai commit-msg 2>/dev/null || true)
65if [ -n "$DRAFT" ]; then
66 # Preserve any trailing comment lines (status summary git appends).
67 TRAILER=$(grep '^#' "$COMMIT_MSG_FILE" || true)
68 {
69 printf '%s\\n' "$DRAFT"
70 if [ -n "$TRAILER" ]; then
71 printf '\\n'
72 printf '%s\\n' "$TRAILER"
73 fi
74 } > "$COMMIT_MSG_FILE"
75fi
76
77exit 0
78`;
79}
80
81export function findGitDir(cwd: string = process.cwd()): string | null {
82 const r = spawnSync("git", ["rev-parse", "--git-dir"], {
83 cwd,
84 encoding: "utf8",
85 });
86 if (r.status !== 0) return null;
87 const dir = (r.stdout || "").trim();
88 if (!dir) return null;
89 return dir.startsWith("/") ? dir : join(cwd, dir);
90}
91
92export interface HookDeps {
93 out: (msg: string) => void;
94 cwd?: string;
95 /** Override the path used inside the generated hook script (defaults to "gluecron"). */
96 binPath?: string;
97 /** Override findGitDir for tests. */
98 findGitDirImpl?: typeof findGitDir;
99}
100
101export async function runHookInstall(
102 argv: string[],
103 deps: HookDeps
104): Promise<number> {
105 const target = argv[0];
106 if (target !== "commit-msg") {
107 deps.out("usage: gluecron hook install commit-msg");
108 return 1;
109 }
110 const gitDir = (deps.findGitDirImpl ?? findGitDir)(deps.cwd);
111 if (!gitDir) {
112 deps.out("error: not a git repository (run from inside a git checkout)");
113 return 1;
114 }
115 const hooksDir = join(gitDir, "hooks");
116 mkdirSync(hooksDir, { recursive: true });
117 const hookPath = join(hooksDir, "prepare-commit-msg");
118
119 // Refuse to clobber a hand-written hook unless it carries our marker.
120 if (existsSync(hookPath)) {
121 const existing = readFileSync(hookPath, "utf8");
122 if (!existing.includes(HOOK_MARKER)) {
123 deps.out(
124 `error: ${hookPath} already exists and was not installed by gluecron. ` +
125 "Remove it manually if you want gluecron to manage it."
126 );
127 return 1;
128 }
129 }
130
131 const bin = deps.binPath || "gluecron";
132 writeFileSync(hookPath, hookScript(bin), "utf8");
133 chmodSync(hookPath, 0o755);
134 deps.out(`installed ${hookPath}`);
135 deps.out(
136 "next commit with an empty message will auto-fill from the AI draft."
137 );
138 return 0;
139}
140
141export async function runHookUninstall(
142 argv: string[],
143 deps: HookDeps
144): Promise<number> {
145 const target = argv[0];
146 if (target !== "commit-msg") {
147 deps.out("usage: gluecron hook uninstall commit-msg");
148 return 1;
149 }
150 const gitDir = (deps.findGitDirImpl ?? findGitDir)(deps.cwd);
151 if (!gitDir) {
152 deps.out("error: not a git repository");
153 return 1;
154 }
155 const hookPath = join(gitDir, "hooks", "prepare-commit-msg");
156 if (!existsSync(hookPath)) {
157 deps.out("nothing to uninstall.");
158 return 0;
159 }
160 const existing = readFileSync(hookPath, "utf8");
161 if (!existing.includes(HOOK_MARKER)) {
162 deps.out(
163 `error: ${hookPath} exists but was not installed by gluecron — refusing to remove.`
164 );
165 return 1;
166 }
167 unlinkSync(hookPath);
168 deps.out(`removed ${hookPath}`);
169 return 0;
170}
171
172export async function runHook(
173 argv: string[],
174 deps: HookDeps
175): Promise<number> {
176 const sub = argv[0];
177 const rest = argv.slice(1);
178 if (sub === "install") return runHookInstall(rest, deps);
179 if (sub === "uninstall") return runHookUninstall(rest, deps);
180 deps.out("usage: gluecron hook (install|uninstall) commit-msg");
181 return 1;
182}
Addedsrc/__tests__/ai-commit-message.test.ts+302−0View fileUnifiedSplit
@@ -0,0 +1,302 @@
1/**
2 * Tests for src/lib/ai-commit-message.ts.
3 *
4 * We cover the pure / deterministic surface here (parsing, heuristics,
5 * truncation, prompt shape). The Anthropic-calling path is exercised
6 * indirectly via the heuristic fallback when ANTHROPIC_API_KEY is unset
7 * (the default in CI).
8 */
9
10import { describe, it, expect } from "bun:test";
11import {
12 generateCommitMessage,
13 truncateDiff,
14 DIFF_BYTE_CAP,
15 __test,
16} from "../lib/ai-commit-message";
17
18const { heuristicMessage, parseModelOutput, buildPrompt, CONVENTIONAL_TYPES } =
19 __test;
20
21const SAMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts
22index abc..def 100644
23--- a/src/foo.ts
24+++ b/src/foo.ts
25@@ -1,3 +1,3 @@
26-old line
27+new line
28 unchanged
29`;
30
31const NEW_FILE_DIFF = `diff --git a/src/feature.ts b/src/feature.ts
32new file mode 100644
33index 0000000..abc
34--- /dev/null
35+++ b/src/feature.ts
36@@ -0,0 +1,3 @@
37+export function hello() {
38+ return "world";
39+}
40`;
41
42const DOCS_DIFF = `diff --git a/README.md b/README.md
43index 1..2 100644
44--- a/README.md
45+++ b/README.md
46@@ -1,1 +1,1 @@
47-Old
48+New
49`;
50
51const TEST_DIFF = `diff --git a/src/__tests__/foo.test.ts b/src/__tests__/foo.test.ts
52new file mode 100644
53--- /dev/null
54+++ b/src/__tests__/foo.test.ts
55@@ -0,0 +1,1 @@
56+test("hi", () => {});
57`;
58
59// ──────────────────────────── truncation ────────────────────────────
60
61describe("truncateDiff", () => {
62 it("returns the diff unchanged when under cap", () => {
63 expect(truncateDiff("hello")).toBe("hello");
64 });
65
66 it("truncates to cap with a marker when over", () => {
67 const big = "x".repeat(DIFF_BYTE_CAP + 1000);
68 const out = truncateDiff(big);
69 expect(out.length).toBeLessThanOrEqual(DIFF_BYTE_CAP);
70 expect(out.endsWith("... (more)")).toBe(true);
71 });
72
73 it("respects a custom cap", () => {
74 const big = "x".repeat(2000);
75 const out = truncateDiff(big, 200);
76 expect(out.length).toBeLessThanOrEqual(200);
77 expect(out.endsWith("... (more)")).toBe(true);
78 });
79
80 it("does not append marker when input exactly fits", () => {
81 const exact = "x".repeat(50);
82 expect(truncateDiff(exact, 50)).toBe(exact);
83 });
84});
85
86// ──────────────────────────── heuristic ────────────────────────────
87
88describe("heuristicMessage", () => {
89 it("returns a chore message for empty input", () => {
90 const m = heuristicMessage("");
91 expect(m.subject).toBe("chore: update");
92 expect(m.body).toBe("");
93 });
94
95 it("classifies docs-only diffs as docs:", () => {
96 const m = heuristicMessage(DOCS_DIFF);
97 expect(m.subject.startsWith("docs")).toBe(true);
98 });
99
100 it("classifies test-only diffs as test:", () => {
101 const m = heuristicMessage(TEST_DIFF);
102 expect(m.subject.startsWith("test")).toBe(true);
103 });
104
105 it("classifies new-file diffs as feat:", () => {
106 const m = heuristicMessage(NEW_FILE_DIFF);
107 expect(m.subject.startsWith("feat")).toBe(true);
108 });
109
110 it("extracts a scope when all paths share a top-level dir", () => {
111 const m = heuristicMessage(NEW_FILE_DIFF);
112 expect(m.subject).toMatch(/^feat\(src\):/);
113 });
114
115 it("includes touched files in the body", () => {
116 const m = heuristicMessage(SAMPLE_DIFF);
117 expect(m.body).toContain("src/foo.ts");
118 });
119
120 it("caps subject at 72 chars", () => {
121 const longPaths = Array.from({ length: 50 }, (_, i) => `src/${i}/file${i}.ts`).join("\n");
122 const big = longPaths
123 .split("\n")
124 .map((p) => `diff --git a/${p} b/${p}\n+++ b/${p}\n`)
125 .join("");
126 const m = heuristicMessage(big);
127 expect(m.subject.length).toBeLessThanOrEqual(72);
128 });
129
130 it("supports plain style (no type prefix)", () => {
131 const m = heuristicMessage(SAMPLE_DIFF, "plain");
132 expect(m.subject).not.toMatch(/^(feat|fix|chore|docs|test|refactor):/);
133 expect(m.subject[0]).toBe(m.subject[0].toUpperCase());
134 });
135});
136
137// ──────────────────────────── parseModelOutput ────────────────────────────
138
139describe("parseModelOutput", () => {
140 it("parses well-formed JSON with subject + body", () => {
141 const m = parseModelOutput(
142 '{"subject": "feat(auth): add login", "body": "Because users need it."}'
143 );
144 expect(m.subject).toBe("feat(auth): add login");
145 expect(m.body).toBe("Because users need it.");
146 });
147
148 it("strips a ```json code fence", () => {
149 const text = '```json\n{"subject":"fix: bug","body":""}\n```';
150 const m = parseModelOutput(text);
151 expect(m.subject).toBe("fix: bug");
152 });
153
154 it("falls back to first-line/body parsing for plain text", () => {
155 const m = parseModelOutput("feat(api): expose new endpoint\n\nDetails here.");
156 expect(m.subject).toBe("feat(api): expose new endpoint");
157 expect(m.body).toContain("Details here.");
158 });
159
160 it("strips quotes around the subject", () => {
161 const m = parseModelOutput('"fix: oops"');
162 expect(m.subject).toBe("fix: oops");
163 });
164
165 it("normalises non-conventional subjects when conventional style is requested", () => {
166 const m = parseModelOutput("Add new feature", "conventional");
167 expect(m.subject.startsWith("chore:")).toBe(true);
168 });
169
170 it("preserves conventional subjects unchanged", () => {
171 const m = parseModelOutput("feat(x): do thing", "conventional");
172 expect(m.subject).toBe("feat(x): do thing");
173 });
174
175 it("caps overly long subjects at 72 chars", () => {
176 const long = "feat: " + "x".repeat(200);
177 const m = parseModelOutput(long);
178 expect(m.subject.length).toBeLessThanOrEqual(72);
179 expect(m.subject.endsWith("...")).toBe(true);
180 });
181
182 it("returns a safe default for empty / garbage input", () => {
183 const m = parseModelOutput("");
184 expect(m.subject).toBe("chore: update");
185 });
186});
187
188// ──────────────────────────── buildPrompt ────────────────────────────
189
190describe("buildPrompt", () => {
191 it("mentions every conventional-commit type", () => {
192 const p = buildPrompt("(diff)", "conventional");
193 for (const t of CONVENTIONAL_TYPES) {
194 expect(p).toContain(t);
195 }
196 });
197
198 it("requires JSON output", () => {
199 const p = buildPrompt("(diff)", "conventional");
200 expect(p).toContain("JSON");
201 expect(p).toContain('"subject"');
202 expect(p).toContain('"body"');
203 });
204
205 it("omits the type-list when plain style is requested", () => {
206 const p = buildPrompt("(diff)", "plain");
207 expect(p).toContain("plain-English");
208 // The plain prompt should NOT list conventional types as required types.
209 expect(p).not.toContain("MUST start with one of: feat");
210 });
211
212 it("includes the diff verbatim", () => {
213 const p = buildPrompt("DIFF_MARKER", "conventional");
214 expect(p).toContain("DIFF_MARKER");
215 });
216});
217
218// ──────────────────────────── generateCommitMessage ────────────────────────────
219
220describe("generateCommitMessage — fallback path", () => {
221 it("returns a heuristic message when ANTHROPIC_API_KEY is missing", async () => {
222 const before = process.env.ANTHROPIC_API_KEY;
223 delete process.env.ANTHROPIC_API_KEY;
224 try {
225 const m = await generateCommitMessage(SAMPLE_DIFF);
226 expect(m.subject.length).toBeGreaterThan(0);
227 // Heuristic always emits Conventional style by default.
228 expect(m.subject).toMatch(/^[a-z]+(\([^)]+\))?:/);
229 } finally {
230 if (before) process.env.ANTHROPIC_API_KEY = before;
231 }
232 });
233
234 it("returns an empty-commit placeholder for an empty diff", async () => {
235 const m = await generateCommitMessage("");
236 expect(m.subject).toContain("empty");
237 expect(m.body).toBe("");
238 });
239
240 it("respects the plain style option in fallback mode", async () => {
241 const before = process.env.ANTHROPIC_API_KEY;
242 delete process.env.ANTHROPIC_API_KEY;
243 try {
244 const m = await generateCommitMessage(SAMPLE_DIFF, { style: "plain" });
245 expect(m.subject).not.toMatch(/^[a-z]+:/);
246 } finally {
247 if (before) process.env.ANTHROPIC_API_KEY = before;
248 }
249 });
250
251 it("never throws on malformed input", async () => {
252 let threw = false;
253 try {
254 await generateCommitMessage("not a real diff at all 🤷");
255 } catch {
256 threw = true;
257 }
258 expect(threw).toBe(false);
259 });
260
261 it("handles a diff far over the truncation cap without crashing", async () => {
262 const before = process.env.ANTHROPIC_API_KEY;
263 delete process.env.ANTHROPIC_API_KEY;
264 try {
265 const big = "diff --git a/x.ts b/x.ts\n+++ b/x.ts\n" + "+x\n".repeat(200_000);
266 const m = await generateCommitMessage(big);
267 expect(m.subject.length).toBeGreaterThan(0);
268 expect(m.subject.length).toBeLessThanOrEqual(72);
269 } finally {
270 if (before) process.env.ANTHROPIC_API_KEY = before;
271 }
272 });
273});
274
275// ──────────────────────────── conventional format ────────────────────────────
276
277describe("Conventional Commits format", () => {
278 // Scope allows any non-paren chars — Conventional Commits' BNF
279 // is permissive and our heuristic might surface a filename like
280 // "README.md" as the scope.
281 const CONVENTIONAL_RE = /^[a-z]+(\([^)]+\))?(!?): .+/;
282
283 it("heuristic output for a code change matches conventional regex", () => {
284 const m = heuristicMessage(SAMPLE_DIFF);
285 expect(m.subject).toMatch(CONVENTIONAL_RE);
286 });
287
288 it("heuristic output for a new-file diff matches conventional regex", () => {
289 const m = heuristicMessage(NEW_FILE_DIFF);
290 expect(m.subject).toMatch(CONVENTIONAL_RE);
291 });
292
293 it("heuristic output for docs matches conventional regex", () => {
294 const m = heuristicMessage(DOCS_DIFF);
295 expect(m.subject).toMatch(CONVENTIONAL_RE);
296 });
297
298 it("parseModelOutput normalisation produces a conventional subject", () => {
299 const m = parseModelOutput("Just a sentence with no type.", "conventional");
300 expect(m.subject).toMatch(CONVENTIONAL_RE);
301 });
302});
Addedsrc/lib/ai-commit-message.ts+303−0View fileUnifiedSplit
@@ -0,0 +1,303 @@
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
21import {
22 getAnthropic,
23 MODEL_HAIKU,
24 extractText,
25 parseJsonResponse,
26 isAiAvailable,
27} from "./ai-client";
28
29export type CommitStyle = "conventional" | "plain";
30
31export interface CommitMessage {
32 subject: string;
33 body: string;
34}
35
36export interface GenerateOptions {
37 style?: CommitStyle;
38}
39
40/** Max bytes of diff we send to the model. Past this we truncate. */
41export const DIFF_BYTE_CAP = 50_000;
42const TRUNCATE_MARKER = "\n... (more)";
43
44/** Conventional-commits types we accept in the subject. */
45const 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
59type 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 */
65export 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 */
83export 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 */
169export 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 */
217export 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
227Rules:
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
235Respond ONLY with JSON in this exact shape:
236{"subject": "...", "body": "..."}
237
238If the change is trivial enough that no body is needed, return body as "".
239
240Diff:
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 */
253export 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({
276 model: MODEL_HAIKU,
277 max_tokens: 512,
278 messages: [
279 {
280 role: "user",
281 content: buildPrompt(truncated, style),
282 },
283 ],
284 });
285 const text = extractText(message);
286 if (!text.trim()) {
287 return heuristicMessage(trimmed, style);
288 }
289 return parseModelOutput(text, style);
290 } catch {
291 // Network/API failure → never block the developer; degrade.
292 return heuristicMessage(trimmed, style);
293 }
294}
295
296/** Test-only exports — not part of the public API. */
297export const __test = {
298 truncateDiff,
299 heuristicMessage,
300 parseModelOutput,
301 buildPrompt,
302 CONVENTIONAL_TYPES,
303};
Modifiedsrc/routes/api-v2.ts+113−0View fileUnifiedSplit
@@ -68,6 +68,10 @@ import {
6868 computeAiSavingsForUser,
6969 computeLifetimeAiSavingsForUser,
7070} from "../lib/ai-hours-saved";
71import {
72 generateCommitMessage,
73 DIFF_BYTE_CAP,
74} from "../lib/ai-commit-message";
7175
7276const apiv2 = new Hono<ApiAuthEnv & AgentAuthEnv>().basePath("/api/v2");
7377
@@ -280,6 +284,111 @@ apiv2.get("/me/ai-savings", requireApiAuth, async (c) => {
280284 });
281285});
282286
287// ─── AI: commit-message ─────────────────────────────────────────────────────
288//
289// POST /api/v2/ai/commit-message
290//
291// Body: { diff: string, style?: "conventional" | "plain" }
292// Returns: { subject, body }
293//
294// Powers the `gluecron commit` CLI + the `prepare-commit-msg` git hook.
295// Rate-limited to 60 req/min per token (this runs once per developer
296// commit; we don't want a runaway `git rebase -i exec` to burn $$$).
297// Falls back to a heuristic when ANTHROPIC_API_KEY is missing, so the
298// route never 5xx's on a misconfigured deploy — the CLI can always show
299// the developer something.
300
301interface AiCommitBucket {
302 count: number;
303 resetAt: number;
304}
305const _aiCommitBuckets = new Map<string, AiCommitBucket>();
306const AI_COMMIT_LIMIT = 60; // requests
307const AI_COMMIT_WINDOW_MS = 60_000; // per minute, per token
308
309function aiCommitRateLimit(c: {
310 req: { header: (k: string) => string | undefined };
311 get: (k: string) => unknown;
312}): { ok: true } | { ok: false; retryAfter: number } {
313 // Key on the raw bearer token where possible (so two CLIs sharing an
314 // IP each get their own bucket); fall back to user id for session
315 // callers (web UI) and to "anon" as a last resort.
316 const authHeader = c.req.header("Authorization") || "";
317 let key: string;
318 if (authHeader.toLowerCase().startsWith("bearer ")) {
319 // Hash the token so we don't keep the plaintext in memory forever.
320 const tok = authHeader.slice(7).trim();
321 const hasher = new Bun.CryptoHasher("sha256");
322 hasher.update(tok);
323 key = "tok:" + hasher.digest("hex").slice(0, 32);
324 } else {
325 const user = c.get("user") as { id?: string } | null | undefined;
326 key = user?.id ? `usr:${user.id}` : "anon";
327 }
328 const now = Date.now();
329 let bucket = _aiCommitBuckets.get(key);
330 if (!bucket || bucket.resetAt < now) {
331 bucket = { count: 0, resetAt: now + AI_COMMIT_WINDOW_MS };
332 _aiCommitBuckets.set(key, bucket);
333 }
334 bucket.count++;
335 if (bucket.count > AI_COMMIT_LIMIT) {
336 return {
337 ok: false,
338 retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
339 };
340 }
341 return { ok: true };
342}
343
344apiv2.post(
345 "/ai/commit-message",
346 requireApiAuth,
347 requireScope("repo"),
348 async (c) => {
349 // In test env we keep the bucket disabled (matches the global pattern
350 // in src/middleware/rate-limit.ts — see the isTestEnv branch there).
351 const isTestEnv =
352 process.env.NODE_ENV !== "production" &&
353 (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test");
354 if (!isTestEnv) {
355 const gate = aiCommitRateLimit(c);
356 if (!gate.ok) {
357 c.header("Retry-After", String(gate.retryAfter));
358 return c.json(
359 {
360 error: "Rate limit exceeded — 60 commit-message requests per minute per token.",
361 retryAfter: gate.retryAfter,
362 },
363 429
364 );
365 }
366 }
367
368 let body: { diff?: unknown; style?: unknown } = {};
369 try {
370 body = await c.req.json();
371 } catch {
372 return c.json({ error: "Invalid JSON body" }, 400);
373 }
374 const diff = typeof body.diff === "string" ? body.diff : "";
375 if (!diff.trim()) {
376 return c.json({ error: "diff is required" }, 400);
377 }
378 // Hard cap so we never blow memory even before the lib truncates.
379 // Library re-truncates as a defence-in-depth measure.
380 const capped = diff.length > DIFF_BYTE_CAP * 4 ? diff.slice(0, DIFF_BYTE_CAP * 4) : diff;
381
382 const style =
383 body.style === "plain" || body.style === "conventional"
384 ? body.style
385 : "conventional";
386
387 const result = await generateCommitMessage(capped, { style });
388 return c.json(result);
389 }
390);
391
283392apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
284393 const user = c.get("user")!;
285394 const body = await c.req.json<{
@@ -2678,6 +2787,10 @@ apiv2.get("/", (c) => {
26782787 activity: {
26792788 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
26802789 },
2790 ai: {
2791 "POST /api/v2/ai/commit-message":
2792 "Generate a Conventional Commits message from a staged diff (60/min/token)",
2793 },
26812794 actions: {
26822795 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
26832796 "Dispatch a workflow run (204 No Content)",
Modifiedsrc/routes/help.tsx+59−0View fileUnifiedSplit
@@ -546,6 +546,65 @@ help.get("/help", (c) => {
546546 </div>
547547 </section>
548548
549 {/* ─── Setup AI commits ─── */}
550 <section id="ai-commits" class="help-section">
551 <div class="help-section-head">
552 <div class="help-section-eyebrow">CLI</div>
553 <h2 class="help-section-title">Setup AI commits</h2>
554 <p class="help-section-desc">
555 Let Claude write your commit messages. Two ways in: an
556 explicit <code>gluecron commit</code> wrapper, or a git
557 hook that fires whenever <code>git commit</code> runs
558 without <code>-m</code>.
559 </p>
560 </div>
561 <div class="help-section-body">
562 <div class="help-item">
563 <strong>1. Install the CLI.</strong> Grab a personal
564 access token from{" "}
565 <a href="/settings/tokens">/settings/tokens</a>, then:
566 <pre class="help-code">
567{`bun build --compile --outfile gluecron cli/gluecron.ts
568sudo mv gluecron /usr/local/bin/
569gluecron login # paste the token`}
570 </pre>
571 </div>
572 <div class="help-item">
573 <strong>2. Use <code>gluecron commit</code>.</strong>{" "}
574 Stage your changes the usual way, then:
575 <pre class="help-code">
576{`gluecron commit # AI drafts → [y]es / [e]dit / [n]o
577gluecron commit -a # same, but stages tracked changes first
578gluecron commit -m "..." # plain pass-through, no AI`}
579 </pre>
580 The draft is a Conventional Commit by default
581 (<code>feat(scope): subject</code> + body explaining why).
582 Pass <code>--plain</code> for a plain-English subject.
583 </div>
584 <div class="help-item">
585 <strong>3. Or: install the git hook.</strong> One-time
586 setup; every plain <code>git commit</code> (no <code>-m</code>)
587 gets a draft pre-filled into the editor.
588 <pre class="help-code">
589{`cd /path/to/your/repo
590gluecron hook install commit-msg
591# undo:
592gluecron hook uninstall commit-msg`}
593 </pre>
594 The hook only fires when the message is empty — explicit
595 <code> -m</code>, <code>--amend</code>, and merge commits
596 are left untouched.
597 </div>
598 <div class="help-item">
599 All AI calls hit <code>POST /api/v2/ai/commit-message</code>,
600 rate-limited to <strong>60 requests/minute per token</strong>.
601 If <code>ANTHROPIC_API_KEY</code> is unset on the server,
602 the endpoint falls back to a deterministic heuristic so
603 the CLI keeps working.
604 </div>
605 </div>
606 </section>
607
549608 {/* ─── Shortcuts ─── */}
550609 <section id="shortcuts" class="help-section">
551610 <div class="help-section-head">
552611