Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitc81b57cunknown_key

feat(editor): "Suggest with AI" commit message button

feat(editor): "Suggest with AI" commit message button

generateCommitMessage() in src/lib/ai-generators.ts has been on disk
since the AI block landed but no caller ever invoked it — making it
the third orphan AI helper this session, alongside generatePrSummary
(now wired) and triageIssue (now wired).

UX:
- "Suggest with AI" button next to "Commit changes" on the file edit
  page. Disabled until the user has typed; status text indicates
  progress / errors.
- Click → small inline JS posts {ref, filePath, content} to the new
  endpoint, on success replaces the message Input (with a confirm
  prompt if the user has already typed something).

Endpoint:
- POST /:owner/:repo/ai/commit-message (requireAuth + write access).
  Reads the current blob via getBlob(owner, repo, ref, filePath),
  builds a minimal diff representation (Old/New sections, each
  truncated to 4KB), calls generateCommitMessage(), caps the result
  to one line + 100 chars (commit-msg convention).
  Returns JSON {ok:true, message} on success, {ok:false, error} on:
    - missing ANTHROPIC_API_KEY
    - missing ref/filePath
    - no changes (oldContent === newContent)
    - AI request failure / empty response
  Always 200; the script reads `ok` to decide what to do.

Pure helper:
- AI_COMMIT_MSG_SCRIPT(args) emits the inline JS as a string.
  JSON-escapes URL/ref/filePath against </script> breakouts.
  Defensive DOM lookups; confirm-overwrite prompt; status surface
  for "Drafting..." / "Filled from AI..." / errors.

Only wired on the EDIT form (the new-file form has no diff to
summarise). Skipped intentionally — generateCommitMessage was
designed for diff text, and asking AI to invent a message for a
fresh file would be lower-quality.

Tests: +2 (auth-guard contracts: unauthenticated → /login redirect;
bogus bearer → 401/403). Total suite 1185 pass / 8 skip / 0 fail
(was 1183 / 8 / 0).

https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
Claude committed on April 30, 2026Parent: a9ada5f
2 files changed+1831c81b57ccf52b32ed99c5ed6fa07284719866493d
2 changed files+183−1
Addedsrc/__tests__/editor-ai-commit.test.ts+44−0View fileUnifiedSplit
1/**
2 * Smoke tests for the new AI commit-message endpoint:
3 * POST /:owner/:repo/ai/commit-message
4 *
5 * The route requires write access. Anonymous and bogus-bearer callers
6 * should never see a 200 / leaked AI body.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11
12describe("POST /:owner/:repo/ai/commit-message — auth guard", () => {
13 it("redirects to /login when unauthenticated", async () => {
14 const res = await app.request(
15 "/alice/demo/ai/commit-message",
16 {
17 method: "POST",
18 headers: { "content-type": "application/x-www-form-urlencoded" },
19 body: "ref=main&filePath=README.md&content=hello",
20 redirect: "manual",
21 }
22 );
23 expect([301, 302, 303, 307, 401, 403, 404, 503]).toContain(res.status);
24 if (res.status === 302 || res.status === 303 || res.status === 307) {
25 const loc = res.headers.get("location") || "";
26 expect(loc).toContain("/login");
27 }
28 });
29
30 it("rejects bogus bearer tokens", async () => {
31 const res = await app.request(
32 "/alice/demo/ai/commit-message",
33 {
34 method: "POST",
35 headers: {
36 "content-type": "application/x-www-form-urlencoded",
37 authorization: "Bearer glct_definitely-not-valid",
38 },
39 body: "ref=main&filePath=README.md&content=hello",
40 }
41 );
42 expect([401, 403, 404, 503]).toContain(res.status);
43 });
44});
Modifiedsrc/routes/editor.tsx+139−1View fileUnifiedSplit
2323 getRepoPath,
2424 repoExists,
2525} from "../git/repository";
26import { generateCommitMessage } from "../lib/ai-generators";
27import { isAiAvailable } from "../lib/ai-client";
2628import { softAuth, requireAuth } from "../middleware/auth";
2729import type { AuthEnv } from "../middleware/auth";
2830import { requireRepoAccess } from "../middleware/repo-access";
3234
3335editor.use("*", softAuth);
3436
37/**
38 * Inline JS for the editor's "Suggest with AI" commit-message button.
39 * Picks up the textarea content + form-pinned ref/filePath, POSTs JSON
40 * to the suggest endpoint, fills the message Input on success.
41 *
42 * Built as a string so we don't need a bundler. JSON-escapes against
43 * </script> breakout. Defensive DOM lookups (silent no-op on absence).
44 */
45function AI_COMMIT_MSG_SCRIPT(args: {
46 endpoint: string;
47 ref: string;
48 filePath: string;
49}): string {
50 const safe = (v: string) =>
51 JSON.stringify(v)
52 .split("<").join("\\u003C")
53 .split(">").join("\\u003E")
54 .split("&").join("\\u0026");
55 const url = safe(args.endpoint);
56 const ref = safe(args.ref);
57 const filePath = safe(args.filePath);
58 return (
59 "(function(){try{" +
60 "var btn=document.getElementById('ai-commit-msg-btn');" +
61 "var status=document.getElementById('ai-commit-msg-status');" +
62 "var input=document.getElementById('commit-message-input');" +
63 "var ta=document.querySelector('textarea[name=\"content\"]');" +
64 "if(!btn||!input||!ta)return;" +
65 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
66 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
67 "var fd='ref='+encodeURIComponent(" + ref + ")+'&filePath='+encodeURIComponent(" + filePath + ")+'&content='+encodeURIComponent(ta.value||'');" +
68 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:fd,credentials:'same-origin'})" +
69 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
70 ".then(function(j){btn.disabled=false;" +
71 "if(j&&j.ok&&typeof j.message==='string'){" +
72 "if(input.value&&input.value.trim().length>0){if(!confirm('Replace existing message?')){if(status)status.textContent='Cancelled.';return;}}" +
73 "input.value=j.message;if(status)status.textContent='Filled from AI. Edit before committing.';" +
74 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
75 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
76 "});" +
77 "}catch(e){}})();"
78 );
79}
80
3581// New file form
3682editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
3783 const { owner, repo } = c.req.param();
229275 </FormGroup>
230276 <FormGroup label="Commit message">
231277 <Input
278 id="commit-message-input"
232279 name="message"
233280 placeholder={`Update ${filePath.split("/").pop()}`}
234281 required
235282 />
236283 </FormGroup>
237 <Flex gap={8}>
284 <Flex gap={8} align="center">
238285 <Button type="submit" variant="primary">
239286 Commit changes
240287 </Button>
288 <button
289 type="button"
290 id="ai-commit-msg-btn"
291 class="btn"
292 title="Generate a one-line commit message using Claude based on the diff"
293 >
294 Suggest with AI
295 </button>
296 <span
297 id="ai-commit-msg-status"
298 style="color:var(--text-muted);font-size:13px"
299 />
241300 <LinkButton href={`/${owner}/${repo}/blob/${ref}/${filePath}`}>
242301 Cancel
243302 </LinkButton>
244303 </Flex>
304 <script
305 dangerouslySetInnerHTML={{
306 __html: AI_COMMIT_MSG_SCRIPT({
307 endpoint: `/${owner}/${repo}/ai/commit-message`,
308 ref,
309 filePath,
310 }),
311 }}
312 />
245313 </Form>
246314 </Container>
247315 </Layout>
248316 );
249317});
250318
319// AI-suggested commit message — JSON endpoint driven by the editor button.
320// Reads the on-disk blob at (ref, filePath), diffs against the submitted
321// new content, and asks generateCommitMessage() for a one-liner. Returns
322// {ok:true, message} on success, {ok:false, error} otherwise. Always 200.
323editor.post(
324 "/:owner/:repo/ai/commit-message",
325 requireAuth,
326 requireRepoAccess("write"),
327 async (c) => {
328 const { owner, repo } = c.req.param();
329 if (!isAiAvailable()) {
330 return c.json({
331 ok: false,
332 error: "AI is not available — set ANTHROPIC_API_KEY.",
333 });
334 }
335 const body = await c.req.parseBody();
336 const ref = String(body.ref || "").trim();
337 const filePath = String(body.filePath || "").trim();
338 const newContent = String(body.content || "");
339 if (!ref || !filePath) {
340 return c.json({ ok: false, error: "ref + filePath required" });
341 }
342
343 let oldContent = "";
344 try {
345 const blob = await getBlob(owner, repo, ref, filePath);
346 oldContent = blob?.content || "";
347 } catch {
348 oldContent = "";
349 }
350
351 if (oldContent === newContent) {
352 return c.json({
353 ok: false,
354 error: "No changes to summarise.",
355 });
356 }
357
358 // Build a minimal unified-diff-ish summary the AI helper can consume.
359 // generateCommitMessage was written for git diff text; we feed a
360 // header + truncated old/new sample so it has shape to summarise.
361 const truncate = (s: string) => (s.length > 4000 ? s.slice(0, 4000) + "\n…(truncated)" : s);
362 const diff =
363 `--- a/${filePath}\n+++ b/${filePath}\n` +
364 "## Old:\n" +
365 truncate(oldContent) +
366 "\n\n## New:\n" +
367 truncate(newContent);
368
369 let message = "";
370 try {
371 message = await generateCommitMessage(diff);
372 } catch (err) {
373 const msg = err instanceof Error ? err.message : "AI request failed.";
374 return c.json({ ok: false, error: msg });
375 }
376 if (!message.trim()) {
377 return c.json({
378 ok: false,
379 error: "AI returned an empty draft.",
380 });
381 }
382 // Cap to one line + 100 chars (commit-message convention).
383 const oneLine = message.split("\n")[0]!.trim();
384 const capped = oneLine.length > 100 ? oneLine.slice(0, 97) + "..." : oneLine;
385 return c.json({ ok: true, message: capped });
386 }
387);
388
251389// Save edited file
252390editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
253391 const { owner, repo } = c.req.param();
254392