Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-tests.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ai-tests.tsxBlame442 lines · 1 contributor
1e162a8Claude1/**
2 * Block D8 — AI-generated test suite route.
3 *
4 * GET /:owner/:repo/ai/tests?path=...&ref=...
5 * Renders a form to pick a source file and generate a failing test
6 * stub for it. When `path` is provided the form is pre-filled with
7 * the currently-selected file so the user can "Generate" with one
8 * click.
9 *
10 * GET /:owner/:repo/ai/tests?path=...&format=raw
11 * Returns `c.text(result.code, 200, {"Content-Type": ...})` for CLI
12 * consumption (e.g. `curl | bat`). No HTML shell.
13 *
14 * POST /:owner/:repo/ai/tests/generate
15 * Auth required. Actually runs the model and renders the result
16 * page with highlighted source, highlighted test, a copy-to-clipboard
17 * button, a review warning, and a regenerate button.
18 */
19
20import { Hono } from "hono";
21import { html, raw } from "hono/html";
22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import { repositories, users } from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader } from "../views/components";
27import { IssueNav } from "./issues";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import {
31 getBlob,
32 getDefaultBranch,
33 getTree,
34 resolveRef,
35} from "../git/repository";
36import type { GitTreeEntry } from "../git/repository";
37import { highlightCode } from "../lib/highlight";
38import {
39 contentTypeFor,
40 detectLanguage,
41 detectTestFramework,
42 generateTestStub,
43} from "../lib/ai-tests";
44
45const aiTestsRoutes = new Hono<AuthEnv>();
46
47interface ResolvedRepo {
48 ownerId: string;
49 ownerUsername: string;
50 repoId: string;
51 repoName: string;
52}
53
54async function resolveRepo(
55 ownerName: string,
56 repoName: string
57): Promise<ResolvedRepo | null> {
58 try {
59 const [ownerRow] = await db
60 .select()
61 .from(users)
62 .where(eq(users.username, ownerName))
63 .limit(1);
64 if (!ownerRow) return null;
65 const [repoRow] = await db
66 .select()
67 .from(repositories)
68 .where(
69 and(
70 eq(repositories.ownerId, ownerRow.id),
71 eq(repositories.name, repoName)
72 )
73 )
74 .limit(1);
75 if (!repoRow) return null;
76 return {
77 ownerId: ownerRow.id,
78 ownerUsername: ownerRow.username,
79 repoId: repoRow.id,
80 repoName: repoRow.name,
81 };
82 } catch {
83 return null;
84 }
85}
86
87/**
88 * Shallow listing of source blobs reachable from the tree root and a couple
89 * of common top-level source directories. Kept intentionally small — the
90 * picker in the form is just a convenience, users can also type a path
91 * directly.
92 */
93async function listRepoFiles(
94 owner: string,
95 repo: string,
96 ref: string
97): Promise<string[]> {
98 const out: string[] = [];
99 let root: GitTreeEntry[] = [];
100 try {
101 root = await getTree(owner, repo, ref, "");
102 } catch {
103 root = [];
104 }
105 for (const entry of root) {
106 if (entry.type === "blob") {
107 out.push(entry.name);
108 }
109 }
110 const candidates = ["src", "lib", "app", "server", "pkg", "tests"];
111 for (const dir of candidates) {
112 const hit = root.find((e) => e.type === "tree" && e.name === dir);
113 if (!hit) continue;
114 let children: GitTreeEntry[] = [];
115 try {
116 children = await getTree(owner, repo, ref, dir);
117 } catch {
118 children = [];
119 }
120 for (const child of children) {
121 if (child.type === "blob") {
122 out.push(`${dir}/${child.name}`);
123 } else if (child.type === "tree") {
124 let grand: GitTreeEntry[] = [];
125 try {
126 grand = await getTree(owner, repo, ref, `${dir}/${child.name}`);
127 } catch {
128 grand = [];
129 }
130 for (const g of grand) {
131 if (g.type === "blob") {
132 out.push(`${dir}/${child.name}/${g.name}`);
133 }
134 }
135 }
136 }
137 if (out.length > 500) break;
138 }
139 return out;
140}
141
142function renderPicker(
143 ownerName: string,
144 repoName: string,
145 allFiles: string[],
146 currentPath: string,
147 ref: string
148) {
149 const trimmed = allFiles.slice(0, 200);
150 return (
151 <form
b9968e3Claude152 method="post"
1e162a8Claude153 action={`/${ownerName}/${repoName}/ai/tests/generate`}
154 style="margin-top: 16px; display: flex; flex-direction: column; gap: 12px; max-width: 720px;"
155 >
156 <input type="hidden" name="ref" value={ref} />
157 <label style="display: flex; flex-direction: column; gap: 6px;">
158 <span style="font-weight: 600;">Source file</span>
159 <input
160 type="text"
161 name="path"
162 value={currentPath}
163 placeholder="src/lib/foo.ts"
164 required
165 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
166 />
167 </label>
168 {trimmed.length > 0 && (
169 <label style="display: flex; flex-direction: column; gap: 6px;">
170 <span style="font-weight: 600;">…or pick from the repo</span>
171 <select
172 name="pickPath"
173 onchange="this.form.path.value = this.value"
174 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
175 >
176 <option value="">— select a file —</option>
177 {trimmed.map((f) => (
178 <option value={f} selected={f === currentPath}>
179 {f}
180 </option>
181 ))}
182 </select>
183 </label>
184 )}
185 <div>
186 <button type="submit" class="star-btn" style="padding: 6px 14px;">
187 Generate tests
188 </button>
189 </div>
190 </form>
191 );
192}
193
194aiTestsRoutes.get("/:owner/:repo/ai/tests", softAuth, async (c) => {
195 const { owner, repo } = c.req.param();
196 const user = c.get("user");
197 const path = (c.req.query("path") || "").trim();
198 const reqRef = (c.req.query("ref") || "").trim();
199 const format = (c.req.query("format") || "").trim().toLowerCase();
200
201 const resolved = await resolveRepo(owner, repo);
202 if (!resolved) {
203 return c.html(
204 <Layout title="Not Found" user={user}>
205 <div class="empty-state">
206 <h2>Repository not found</h2>
207 </div>
208 </Layout>,
209 404
210 );
211 }
212
213 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
214 const ref = reqRef || defaultBranch;
215 const sha = (await resolveRef(owner, repo, ref)) || ref;
216
217 // Raw format: just emit the generated code (for CLI use).
218 if (format === "raw") {
219 if (!path) {
220 return c.text("missing ?path=", 400, {
221 "Content-Type": "text/plain; charset=utf-8",
222 });
223 }
224 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
225 if (!blob || blob.isBinary) {
226 return c.text("file not found", 404, {
227 "Content-Type": "text/plain; charset=utf-8",
228 });
229 }
230 const language = detectLanguage(path);
231 const repoFiles = await listRepoFiles(owner, repo, sha);
232 const framework = detectTestFramework(language, repoFiles);
233 const result = await generateTestStub({
234 path,
235 language,
236 framework,
237 sourceCode: blob.content,
238 });
239 return c.text(result.code, 200, {
240 "Content-Type": contentTypeFor(result.language),
241 });
242 }
243
244 // HTML form mode.
245 const repoFiles = await listRepoFiles(owner, repo, sha);
246 const detectedLang = path ? detectLanguage(path) : "other";
247 const detectedFramework = detectTestFramework(detectedLang, repoFiles);
248
249 return c.html(
250 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
251 <RepoHeader owner={owner} repo={repo} />
252 <IssueNav owner={owner} repo={repo} active="code" />
253 <div style="margin: 16px 0;">
254 <h2 style="margin: 0 0 8px;">AI-generated tests</h2>
255 <p style="color: var(--text-muted); margin: 0;">
256 Pick a source file and gluecron will ask Claude to draft a{" "}
257 <strong>failing</strong> test stub that exercises its public surface.
258 Treat the output as a starting-point — always review before
259 committing.
260 </p>
261 {path && (
262 <p style="color: var(--text-muted); margin: 8px 0 0; font-size: 13px;">
263 Detected language: <code>{detectedLang}</code> · framework:{" "}
264 <code>{detectedFramework}</code>
265 </p>
266 )}
267 </div>
268 {renderPicker(owner, repo, repoFiles, path, ref)}
269 </Layout>
270 );
271});
272
273aiTestsRoutes.post(
274 "/:owner/:repo/ai/tests/generate",
275 requireAuth,
276 async (c) => {
277 const { owner, repo } = c.req.param();
278 const user = c.get("user")!;
279 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
280 const path = String(body.path || "").trim();
281 const reqRef = String(body.ref || "").trim();
282
283 const resolved = await resolveRepo(owner, repo);
284 if (!resolved) {
285 return c.html(
286 <Layout title="Not Found" user={user}>
287 <div class="empty-state">
288 <h2>Repository not found</h2>
289 </div>
290 </Layout>,
291 404
292 );
293 }
294
295 if (!path) {
296 return c.redirect(`/${owner}/${repo}/ai/tests`);
297 }
298
299 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
300 const ref = reqRef || defaultBranch;
301 const sha = (await resolveRef(owner, repo, ref)) || ref;
302
303 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
304 if (!blob || blob.isBinary) {
305 return c.html(
306 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
307 <RepoHeader owner={owner} repo={repo} />
308 <IssueNav owner={owner} repo={repo} active="code" />
309 <div class="empty-state">
310 <h2>Couldn't read that file</h2>
311 <p>
312 No such path at <code>{ref}</code>, or the file is binary.
313 </p>
314 <p>
315 <a href={`/${owner}/${repo}/ai/tests`}>Back to the picker</a>
316 </p>
317 </div>
318 </Layout>,
319 404
320 );
321 }
322
323 const language = detectLanguage(path);
324 const repoFiles = await listRepoFiles(owner, repo, sha);
325 const framework = detectTestFramework(language, repoFiles);
326
327 const result = await generateTestStub({
328 path,
329 language,
330 framework,
331 sourceCode: blob.content,
332 });
333
334 const sourceHl = highlightCode(blob.content, path);
335 const testHl = highlightCode(result.code || "", result.suggestedPath);
336
337 const aiFailed = result.framework === "fallback" || !result.code;
338
339 return c.html(
340 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
341 <RepoHeader owner={owner} repo={repo} />
342 <IssueNav owner={owner} repo={repo} active="code" />
343 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
344 <div>
345 <h2 style="margin: 0;">AI-generated tests for <code>{path}</code></h2>
346 <p style="color: var(--text-muted); margin: 4px 0 0; font-size: 13px;">
347 Detected language: <code>{language}</code> · framework:{" "}
348 <code>{aiFailed ? "fallback" : framework}</code> · ref{" "}
349 <code>{ref}</code>
350 </p>
351 </div>
352 <form
b9968e3Claude353 method="post"
1e162a8Claude354 action={`/${owner}/${repo}/ai/tests/generate`}
355 style="display: inline;"
356 >
357 <input type="hidden" name="path" value={path} />
358 <input type="hidden" name="ref" value={ref} />
359 <button type="submit" class="star-btn">Regenerate</button>
360 </form>
361 </div>
362
363 <div
364 class="flash-warning"
365 style="border: 1px solid var(--border); background: rgba(210, 153, 34, 0.12); padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;"
366 >
367 <strong>Review before committing.</strong> These tests are a
368 starting-point only — they are intentionally written to{" "}
369 <em>fail</em> so you are forced to supply real expected values.
370 Gluecron does not verify the behaviour is correct.
371 </div>
372
373 {aiFailed && (
374 <div
375 class="empty-state"
376 style="border: 1px dashed var(--border); padding: 16px; border-radius: 6px; margin-bottom: 16px;"
377 >
378 <p style="margin: 0;">
379 Couldn't generate a test stub. The AI backend may not be
380 configured, or the model returned an empty response. A suggested
381 path was still computed: <code>{result.suggestedPath}</code>.
382 </p>
383 </div>
384 )}
385
386 <section style="margin-bottom: 24px;">
387 <h3 style="margin: 0 0 8px; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
388 Source — <code>{path}</code>
389 </h3>
390 <pre class="hljs" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto;">
391 <code>{raw(sourceHl.html)}</code>
392 </pre>
393 </section>
394
395 <section>
396 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
397 <h3 style="margin: 0; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
398 Suggested test — <code>{result.suggestedPath}</code>
399 </h3>
400 <button
401 type="button"
402 class="star-btn"
403 id="copy-test-btn"
404 data-test-code-id="ai-test-code"
405 >
406 Copy
407 </button>
408 </div>
409 <pre
410 class="hljs"
411 id="ai-test-code"
412 style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto; white-space: pre;"
413 >
414 <code>{result.code ? raw(testHl.html) : "// (no output)"}</code>
415 </pre>
416 </section>
417
418 {html`<script>
419 (function () {
420 var btn = document.getElementById('copy-test-btn');
421 if (!btn) return;
422 btn.addEventListener('click', function () {
423 var id = btn.getAttribute('data-test-code-id');
424 var el = id ? document.getElementById(id) : null;
425 var text = el ? el.innerText : '';
426 if (!text) return;
427 if (navigator.clipboard && navigator.clipboard.writeText) {
428 navigator.clipboard.writeText(text).then(function () {
429 var prev = btn.textContent;
430 btn.textContent = 'Copied';
431 setTimeout(function () { btn.textContent = prev; }, 1500);
432 });
433 }
434 });
435 })();
436 </script>`}
437 </Layout>
438 );
439 }
440);
441
442export default aiTestsRoutes;