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.tsxBlame443 lines · 2 contributors
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
2c3ba6ecopilot-swe-agent[bot]165 aria-label="Source file"
1e162a8Claude166 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
167 />
168 </label>
169 {trimmed.length > 0 && (
170 <label style="display: flex; flex-direction: column; gap: 6px;">
171 <span style="font-weight: 600;">…or pick from the repo</span>
172 <select
173 name="pickPath"
174 onchange="this.form.path.value = this.value"
175 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
176 >
177 <option value="">— select a file —</option>
178 {trimmed.map((f) => (
179 <option value={f} selected={f === currentPath}>
180 {f}
181 </option>
182 ))}
183 </select>
184 </label>
185 )}
186 <div>
187 <button type="submit" class="star-btn" style="padding: 6px 14px;">
188 Generate tests
189 </button>
190 </div>
191 </form>
192 );
193}
194
195aiTestsRoutes.get("/:owner/:repo/ai/tests", softAuth, async (c) => {
196 const { owner, repo } = c.req.param();
197 const user = c.get("user");
198 const path = (c.req.query("path") || "").trim();
199 const reqRef = (c.req.query("ref") || "").trim();
200 const format = (c.req.query("format") || "").trim().toLowerCase();
201
202 const resolved = await resolveRepo(owner, repo);
203 if (!resolved) {
204 return c.html(
205 <Layout title="Not Found" user={user}>
206 <div class="empty-state">
207 <h2>Repository not found</h2>
208 </div>
209 </Layout>,
210 404
211 );
212 }
213
214 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
215 const ref = reqRef || defaultBranch;
216 const sha = (await resolveRef(owner, repo, ref)) || ref;
217
218 // Raw format: just emit the generated code (for CLI use).
219 if (format === "raw") {
220 if (!path) {
221 return c.text("missing ?path=", 400, {
222 "Content-Type": "text/plain; charset=utf-8",
223 });
224 }
225 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
226 if (!blob || blob.isBinary) {
227 return c.text("file not found", 404, {
228 "Content-Type": "text/plain; charset=utf-8",
229 });
230 }
231 const language = detectLanguage(path);
232 const repoFiles = await listRepoFiles(owner, repo, sha);
233 const framework = detectTestFramework(language, repoFiles);
234 const result = await generateTestStub({
235 path,
236 language,
237 framework,
238 sourceCode: blob.content,
239 });
240 return c.text(result.code, 200, {
241 "Content-Type": contentTypeFor(result.language),
242 });
243 }
244
245 // HTML form mode.
246 const repoFiles = await listRepoFiles(owner, repo, sha);
247 const detectedLang = path ? detectLanguage(path) : "other";
248 const detectedFramework = detectTestFramework(detectedLang, repoFiles);
249
250 return c.html(
251 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
252 <RepoHeader owner={owner} repo={repo} />
253 <IssueNav owner={owner} repo={repo} active="code" />
254 <div style="margin: 16px 0;">
255 <h2 style="margin: 0 0 8px;">AI-generated tests</h2>
256 <p style="color: var(--text-muted); margin: 0;">
257 Pick a source file and gluecron will ask Claude to draft a{" "}
258 <strong>failing</strong> test stub that exercises its public surface.
259 Treat the output as a starting-point — always review before
260 committing.
261 </p>
262 {path && (
263 <p style="color: var(--text-muted); margin: 8px 0 0; font-size: 13px;">
264 Detected language: <code>{detectedLang}</code> · framework:{" "}
265 <code>{detectedFramework}</code>
266 </p>
267 )}
268 </div>
269 {renderPicker(owner, repo, repoFiles, path, ref)}
270 </Layout>
271 );
272});
273
274aiTestsRoutes.post(
275 "/:owner/:repo/ai/tests/generate",
276 requireAuth,
277 async (c) => {
278 const { owner, repo } = c.req.param();
279 const user = c.get("user")!;
280 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
281 const path = String(body.path || "").trim();
282 const reqRef = String(body.ref || "").trim();
283
284 const resolved = await resolveRepo(owner, repo);
285 if (!resolved) {
286 return c.html(
287 <Layout title="Not Found" user={user}>
288 <div class="empty-state">
289 <h2>Repository not found</h2>
290 </div>
291 </Layout>,
292 404
293 );
294 }
295
296 if (!path) {
297 return c.redirect(`/${owner}/${repo}/ai/tests`);
298 }
299
300 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
301 const ref = reqRef || defaultBranch;
302 const sha = (await resolveRef(owner, repo, ref)) || ref;
303
304 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
305 if (!blob || blob.isBinary) {
306 return c.html(
307 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
308 <RepoHeader owner={owner} repo={repo} />
309 <IssueNav owner={owner} repo={repo} active="code" />
310 <div class="empty-state">
311 <h2>Couldn't read that file</h2>
312 <p>
313 No such path at <code>{ref}</code>, or the file is binary.
314 </p>
315 <p>
316 <a href={`/${owner}/${repo}/ai/tests`}>Back to the picker</a>
317 </p>
318 </div>
319 </Layout>,
320 404
321 );
322 }
323
324 const language = detectLanguage(path);
325 const repoFiles = await listRepoFiles(owner, repo, sha);
326 const framework = detectTestFramework(language, repoFiles);
327
328 const result = await generateTestStub({
329 path,
330 language,
331 framework,
332 sourceCode: blob.content,
333 });
334
335 const sourceHl = highlightCode(blob.content, path);
336 const testHl = highlightCode(result.code || "", result.suggestedPath);
337
338 const aiFailed = result.framework === "fallback" || !result.code;
339
340 return c.html(
341 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
342 <RepoHeader owner={owner} repo={repo} />
343 <IssueNav owner={owner} repo={repo} active="code" />
344 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
345 <div>
346 <h2 style="margin: 0;">AI-generated tests for <code>{path}</code></h2>
347 <p style="color: var(--text-muted); margin: 4px 0 0; font-size: 13px;">
348 Detected language: <code>{language}</code> · framework:{" "}
349 <code>{aiFailed ? "fallback" : framework}</code> · ref{" "}
350 <code>{ref}</code>
351 </p>
352 </div>
353 <form
b9968e3Claude354 method="post"
1e162a8Claude355 action={`/${owner}/${repo}/ai/tests/generate`}
356 style="display: inline;"
357 >
358 <input type="hidden" name="path" value={path} />
359 <input type="hidden" name="ref" value={ref} />
360 <button type="submit" class="star-btn">Regenerate</button>
361 </form>
362 </div>
363
364 <div
365 class="flash-warning"
366 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;"
367 >
368 <strong>Review before committing.</strong> These tests are a
369 starting-point only — they are intentionally written to{" "}
370 <em>fail</em> so you are forced to supply real expected values.
371 Gluecron does not verify the behaviour is correct.
372 </div>
373
374 {aiFailed && (
375 <div
376 class="empty-state"
377 style="border: 1px dashed var(--border); padding: 16px; border-radius: 6px; margin-bottom: 16px;"
378 >
379 <p style="margin: 0;">
380 Couldn't generate a test stub. The AI backend may not be
381 configured, or the model returned an empty response. A suggested
382 path was still computed: <code>{result.suggestedPath}</code>.
383 </p>
384 </div>
385 )}
386
387 <section style="margin-bottom: 24px;">
388 <h3 style="margin: 0 0 8px; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
389 Source — <code>{path}</code>
390 </h3>
391 <pre class="hljs" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto;">
392 <code>{raw(sourceHl.html)}</code>
393 </pre>
394 </section>
395
396 <section>
397 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
398 <h3 style="margin: 0; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
399 Suggested test — <code>{result.suggestedPath}</code>
400 </h3>
401 <button
402 type="button"
403 class="star-btn"
404 id="copy-test-btn"
405 data-test-code-id="ai-test-code"
406 >
407 Copy
408 </button>
409 </div>
410 <pre
411 class="hljs"
412 id="ai-test-code"
413 style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto; white-space: pre;"
414 >
415 <code>{result.code ? raw(testHl.html) : "// (no output)"}</code>
416 </pre>
417 </section>
418
419 {html`<script>
420 (function () {
421 var btn = document.getElementById('copy-test-btn');
422 if (!btn) return;
423 btn.addEventListener('click', function () {
424 var id = btn.getAttribute('data-test-code-id');
425 var el = id ? document.getElementById(id) : null;
426 var text = el ? el.innerText : '';
427 if (!text) return;
428 if (navigator.clipboard && navigator.clipboard.writeText) {
429 navigator.clipboard.writeText(text).then(function () {
430 var prev = btn.textContent;
431 btn.textContent = 'Copied';
432 setTimeout(function () { btn.textContent = prev; }, 1500);
433 });
434 }
435 });
436 })();
437 </script>`}
438 </Layout>
439 );
440 }
441);
442
443export default aiTestsRoutes;