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.
| 1e162a8 | 1 | /** |
| 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 | ||
| 20 | import { Hono } from "hono"; | |
| 21 | import { html, raw } from "hono/html"; | |
| 22 | import { and, eq } from "drizzle-orm"; | |
| 23 | import { db } from "../db"; | |
| 24 | import { repositories, users } from "../db/schema"; | |
| 25 | import { Layout } from "../views/layout"; | |
| 26 | import { RepoHeader } from "../views/components"; | |
| 27 | import { IssueNav } from "./issues"; | |
| 28 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 29 | import type { AuthEnv } from "../middleware/auth"; | |
| 30 | import { | |
| 31 | getBlob, | |
| 32 | getDefaultBranch, | |
| 33 | getTree, | |
| 34 | resolveRef, | |
| 35 | } from "../git/repository"; | |
| 36 | import type { GitTreeEntry } from "../git/repository"; | |
| 37 | import { highlightCode } from "../lib/highlight"; | |
| 38 | import { | |
| 39 | contentTypeFor, | |
| 40 | detectLanguage, | |
| 41 | detectTestFramework, | |
| 42 | generateTestStub, | |
| 43 | } from "../lib/ai-tests"; | |
| 44 | ||
| 45 | const aiTestsRoutes = new Hono<AuthEnv>(); | |
| 46 | ||
| 47 | interface ResolvedRepo { | |
| 48 | ownerId: string; | |
| 49 | ownerUsername: string; | |
| 50 | repoId: string; | |
| 51 | repoName: string; | |
| 52 | } | |
| 53 | ||
| 54 | async 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 | */ | |
| 93 | async 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 | ||
| 142 | function 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 | |
| b9968e3 | 152 | method="post" |
| 1e162a8 | 153 | 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 | ||
| 194 | aiTestsRoutes.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 | ||
| 273 | aiTestsRoutes.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 | |
| b9968e3 | 353 | method="post" |
| 1e162a8 | 354 | 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 | ||
| 442 | export default aiTestsRoutes; |