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-test-generator.ts

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-test-generator.tsBlame866 lines · 1 contributor
0c3eee5Claude1/**
2 * AI test generator — when a PR opens, autopilot reads the diff, asks
3 * Claude to write tests for the new code, and either pushes a test
4 * commit onto the same branch or opens a follow-up PR against the PR's
5 * head branch. Every merged PR self-improves the test suite.
6 *
7 * Pipeline per PR:
8 * 1. Resolve the PR row + repo + owner.
9 * 2. Diff `base...head` to find files added/modified. Drop test
10 * files, configs, docs, and binary blobs.
11 * 3. For each surviving source file, ask Claude to write tests
12 * matching whatever framework sibling test files use. The model
13 * returns `{ patches: [{ path, new_content }] }` — the same envelope
14 * `ai-patch-generator.ts` uses, so the write path is shared.
15 * 4. `append-commit` mode — write each patch onto the PR's headBranch.
16 * `follow-up-pr` mode — write onto a fresh `ai-tests/<n>-<ts>`
17 * branch seeded from headBranch, then insert
18 * a new pullRequests row pointing at the new
19 * branch with base = original headBranch.
20 * 5. Mark with label `ai:added-tests` (created on the repo, surfaced
21 * via a marker comment on whichever PR is being decorated).
22 * 6. Audit `ai.tests.added` so operators can review uptake.
23 *
24 * Idempotent — if any prior tick already added the `ai:added-tests`
25 * marker (comment on the PR for append-commit; comment on the original
26 * PR for follow-up-pr) we skip. Callers fire-and-forget; we never throw.
27 */
28
29import { and, eq, like } from "drizzle-orm";
30import type Anthropic from "@anthropic-ai/sdk";
31import { db } from "../db";
32import {
33 labels,
34 prComments,
35 pullRequests,
36 repositories,
37 users,
38} from "../db/schema";
39import {
40 createOrUpdateFileOnBranch,
41 getBlob,
42 getRepoPath,
43 refExists,
44 resolveRef,
45 updateRef,
46} from "../git/repository";
47import { config } from "./config";
48import { audit } from "./notify";
49import {
50 getAnthropic,
51 MODEL_SONNET,
52 extractText,
53 parseJsonResponse,
54} from "./ai-client";
55import { detectLanguage, detectTestFramework } from "./ai-tests";
56
57/** Marker embedded in PR comments so subsequent ticks dedupe cleanly. */
58export const AI_TESTS_MARKER = "<!-- gluecron-ai-tests:added -->";
59
60/** Label name attached (and ensured present on the repo) for tagged PRs. */
61export const AI_TESTS_LABEL = "ai:added-tests";
62
63/** Existing PR-label marker we use to detect spec-generated PRs (avoid recursion). */
64const AI_SPEC_LABEL = "ai:spec-implementation";
65const AI_SPEC_PR_MARKER = "<!-- gluecron:ai-spec-implementation:v1 -->";
66
67/** Default per-PR cap on how many new files we ask Claude to test in one run. */
68export const MAX_FILES_PER_RUN = 5;
69
70/** Hard cap on Claude prompt size — large source files get truncated. */
71const MAX_SOURCE_BYTES_PER_FILE = 24_000;
72
73/** Test-file path heuristics — skip writing tests for things that already are tests. */
74const TEST_PATH_RX = /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[a-z0-9]+$|(^|\/)test_[^/]+\.py$|_test\.go$/i;
75
76/** Path prefixes / extensions we never propose to write tests for. */
77const SKIP_PREFIX = [
78 ".git/",
79 "node_modules/",
80 "dist/",
81 "build/",
82 ".github/",
83 "drizzle/",
84 "public/",
85 "docs/",
86 ".vscode/",
87 ".gluecron/",
88];
89
90/** File extensions worth generating tests for. */
91const CODE_EXT_RX = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts|py|go|rs|java|kt|rb)$/i;
92
93/** Doc/config extensions to skip. */
94const DOC_OR_CONFIG_RX = /\.(md|mdx|txt|json|yml|yaml|toml|ini|env|lock|lockb|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff|woff2|ttf|otf|css|scss)$/i;
95
96// ---------------------------------------------------------------------------
97// Public types
98// ---------------------------------------------------------------------------
99
100export type TestGenMode = "append-commit" | "follow-up-pr";
101
102export interface GenerateTestsForPrArgs {
103 prId: string;
104 mode: TestGenMode;
105 /** Optional Anthropic client override (tests). */
106 client?: Pick<Anthropic, "messages">;
107 /** Optional file-list override (tests). Bypasses git diff scan. */
108 changedFilesOverride?: string[];
109 /** Optional per-file source resolver (tests). Bypasses getBlob. */
110 resolveSource?: (path: string) => Promise<string | null>;
111 /** Optional cap on files per run. */
112 maxFiles?: number;
113}
114
115export interface GenerateTestsForPrResult {
116 ok: boolean;
117 /** Branch the test commit(s) landed on. */
118 branch?: string;
119 /** New PR number when mode='follow-up-pr'. */
120 prNumber?: number;
121 /** Reason for ok=false. */
122 error?: string;
123 /** Number of test files written. */
124 written?: number;
125 /** Whether we short-circuited via the idempotent dedupe. */
126 alreadyDone?: boolean;
127}
128
129interface ClaudeTestPatch {
130 path: string;
131 new_content: string;
132}
133
134interface ClaudeTestResponse {
135 patches?: ClaudeTestPatch[];
136}
137
138interface PrFacts {
139 pr: {
140 id: string;
141 number: number;
142 title: string;
143 body: string | null;
144 baseBranch: string;
145 headBranch: string;
146 repositoryId: string;
147 authorId: string;
148 state: string;
149 };
150 ownerName: string;
151 repoName: string;
152 repoOwnerId: string;
153 defaultBranch: string;
154}
155
156// ---------------------------------------------------------------------------
157// Pure helpers — exported so the test suite can pin invariants
158// ---------------------------------------------------------------------------
159
160/**
161 * Decide whether a given changed file path is worth asking Claude to test.
162 * Drops test files, configs, docs, build output, binaries, and anything
163 * outside the project's source layout heuristic.
164 */
165export function isCandidateSourceFile(path: string): boolean {
166 if (!path || typeof path !== "string") return false;
167 if (path.includes("..")) return false;
168 if (SKIP_PREFIX.some((p) => path.startsWith(p))) return false;
169 if (TEST_PATH_RX.test(path)) return false;
170 if (DOC_OR_CONFIG_RX.test(path)) return false;
171 if (!CODE_EXT_RX.test(path)) return false;
172 return true;
173}
174
175/**
176 * Build the per-file prompt that asks Claude to write tests. Pure so it's
177 * easy to assert against in unit tests.
178 */
179export function buildTestsForPrPrompt(args: {
180 filePath: string;
181 language: string;
182 framework: string;
183 sourceCode: string;
184 prTitle: string;
185}): string {
186 const trimmed =
187 args.sourceCode.length > MAX_SOURCE_BYTES_PER_FILE
188 ? args.sourceCode.slice(0, MAX_SOURCE_BYTES_PER_FILE) +
189 "\n// ... (truncated)"
190 : args.sourceCode;
191 return [
192 "Write tests for the new code below. Match the existing test framework",
193 "the repository already uses — look at the framework hint and write idioms",
194 "that fit (do not introduce a new test runner).",
195 "",
196 `**Pull request:** ${args.prTitle}`,
197 `**Source file:** \`${args.filePath}\``,
198 `**Language:** ${args.language}`,
199 `**Framework:** ${args.framework}`,
200 "",
201 "Source file contents:",
202 "```",
203 trimmed,
204 "```",
205 "",
206 "Respond ONLY with JSON of this exact shape:",
207 "{",
208 ' "patches": [',
209 ' { "path": "path/to/new/test/file", "new_content": "FULL file contents" }',
210 " ]",
211 "}",
212 "",
213 "Rules:",
214 "- Return an empty patches array if you cannot write meaningful tests safely.",
215 "- new_content MUST be the entire file (not a diff).",
216 "- Pick a sensible test-file path that matches the repo's conventions for",
217 ` framework \`${args.framework}\` (e.g. \`src/__tests__/<name>.test.ts\``,
218 " for bun:test, `test_<name>.py` for pytest, `<name>_test.go` for go test).",
219 "- Tests SHOULD compile/import cleanly and exercise the file's public surface.",
220 "- Use realistic assertions; avoid `expect(true).toBe(true)` placeholders.",
221 "- Do not modify the source file — only emit new test files.",
222 ].join("\n");
223}
224
225/**
226 * Compute a unique branch name for follow-up-pr mode. Caller can override
227 * for deterministic test output.
228 */
229export function testsBranchName(prNumber: number, override?: string): string {
230 if (override && override.trim()) return override.trim();
231 return `ai-tests/pr-${prNumber}-${Date.now()}`;
232}
233
234// ---------------------------------------------------------------------------
235// Internal helpers
236// ---------------------------------------------------------------------------
237
238/**
239 * Look up the PR + repo + owner row in one go. Returns null if any link
240 * is missing so callers bail before touching git.
241 */
242async function loadPrFacts(prId: string): Promise<PrFacts | null> {
243 try {
244 const [row] = await db
245 .select({
246 prId: pullRequests.id,
247 prNumber: pullRequests.number,
248 prTitle: pullRequests.title,
249 prBody: pullRequests.body,
250 baseBranch: pullRequests.baseBranch,
251 headBranch: pullRequests.headBranch,
252 repositoryId: pullRequests.repositoryId,
253 authorId: pullRequests.authorId,
254 state: pullRequests.state,
255 ownerName: users.username,
256 repoName: repositories.name,
257 repoOwnerId: repositories.ownerId,
258 defaultBranch: repositories.defaultBranch,
259 })
260 .from(pullRequests)
261 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
262 .innerJoin(users, eq(users.id, repositories.ownerId))
263 .where(eq(pullRequests.id, prId))
264 .limit(1);
265 if (!row) return null;
266 return {
267 pr: {
268 id: row.prId,
269 number: row.prNumber,
270 title: row.prTitle,
271 body: row.prBody,
272 baseBranch: row.baseBranch,
273 headBranch: row.headBranch,
274 repositoryId: row.repositoryId,
275 authorId: row.authorId,
276 state: row.state,
277 },
278 ownerName: row.ownerName,
279 repoName: row.repoName,
280 repoOwnerId: row.repoOwnerId,
281 defaultBranch: row.defaultBranch,
282 };
283 } catch (err) {
284 console.warn(
285 "[ai-test-generator] loadPrFacts failed:",
286 err instanceof Error ? err.message : err
287 );
288 return null;
289 }
290}
291
292/**
293 * Best-effort list of files changed between baseBranch...headBranch.
294 * Returns just paths (no patch text — we re-read each file from the
295 * head ref via getBlob so Claude sees the final state, not the diff).
296 */
297async function listChangedSourceFiles(
298 ownerName: string,
299 repoName: string,
300 baseBranch: string,
301 headBranch: string
302): Promise<string[]> {
303 try {
304 const cwd = getRepoPath(ownerName, repoName);
305 const proc = Bun.spawn(
306 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
307 { cwd, stdout: "pipe", stderr: "pipe" }
308 );
309 const text = await new Response(proc.stdout).text();
310 await proc.exited;
311 return text
312 .split("\n")
313 .map((s) => s.trim())
314 .filter(Boolean);
315 } catch (err) {
316 console.warn(
317 "[ai-test-generator] listChangedSourceFiles failed:",
318 err instanceof Error ? err.message : err
319 );
320 return [];
321 }
322}
323
324/**
325 * List every blob in the repo at `ref`. Used to derive the framework hint
326 * (jest vs vitest vs bun:test vs pytest, etc.) via `detectTestFramework`.
327 */
328async function listRepoFiles(
329 ownerName: string,
330 repoName: string,
331 ref: string,
332 cap = 2000
333): Promise<string[]> {
334 try {
335 const cwd = getRepoPath(ownerName, repoName);
336 const proc = Bun.spawn(
337 ["git", "ls-tree", "-r", "--name-only", ref],
338 { cwd, stdout: "pipe", stderr: "pipe" }
339 );
340 const text = await new Response(proc.stdout).text();
341 await proc.exited;
342 return text
343 .split("\n")
344 .map((s) => s.trim())
345 .filter(Boolean)
346 .slice(0, cap);
347 } catch {
348 return [];
349 }
350}
351
352/**
353 * Look for an existing `ai:added-tests` marker comment on the given PR.
354 * Returns true if found — caller short-circuits.
355 */
356async function alreadyTagged(pullRequestId: string): Promise<boolean> {
357 try {
358 const rows = await db
359 .select({ id: prComments.id })
360 .from(prComments)
361 .where(
362 and(
363 eq(prComments.pullRequestId, pullRequestId),
364 like(prComments.body, `%${AI_TESTS_MARKER}%`)
365 )
366 )
367 .limit(1);
368 return rows.length > 0;
369 } catch {
370 return false;
371 }
372}
373
374/**
375 * Look for an existing follow-up tests PR that targets this PR's head
376 * branch. The dedup is keyed off the PR body marker we always write, so
377 * a previously-opened follow-up never gets re-opened.
378 */
379async function alreadyHasFollowUpPr(
380 repositoryId: string,
381 originalPrNumber: number
382): Promise<boolean> {
383 try {
384 const needle = `${AI_TESTS_MARKER}\nfor PR #${originalPrNumber}`;
385 const rows = await db
386 .select({ id: pullRequests.id })
387 .from(pullRequests)
388 .where(
389 and(
390 eq(pullRequests.repositoryId, repositoryId),
391 like(pullRequests.body, `%${needle}%`)
392 )
393 )
394 .limit(1);
395 return rows.length > 0;
396 } catch {
397 return false;
398 }
399}
400
401/**
402 * Does the PR look AI-generated by spec-to-PR? We avoid recursing on
403 * those (the spec already produced both code + tests in most cases).
404 */
405export function looksLikeSpecPr(prBody: string | null | undefined): boolean {
406 if (!prBody) return false;
407 return prBody.includes(AI_SPEC_PR_MARKER) || prBody.includes(AI_SPEC_LABEL);
408}
409
410/**
411 * Ensure the `ai:added-tests` label row exists on the repo. Best-effort —
412 * label is a UX nicety, not load-bearing. Mirrors `ensurePatchLabel` from
413 * ai-patch-generator.
414 */
415async function ensureTestsLabel(repositoryId: string): Promise<void> {
416 try {
417 await db
418 .insert(labels)
419 .values({
420 repositoryId,
421 name: AI_TESTS_LABEL,
422 color: "#3fb950",
423 description:
424 "Tests auto-generated by Gluecron AI from a pull request diff",
425 })
426 .onConflictDoNothing?.();
427 } catch (err) {
428 console.warn(
429 "[ai-test-generator] ensureTestsLabel failed:",
430 err instanceof Error ? err.message : err
431 );
432 }
433}
434
435/**
436 * Ask Claude for tests. Returns parsed `{ patches }` or null on any
437 * failure (network, parse error, no key). Caller treats null as a skip.
438 */
439async function askClaudeForTests(
440 client: Pick<Anthropic, "messages">,
441 args: {
442 filePath: string;
443 language: string;
444 framework: string;
445 sourceCode: string;
446 prTitle: string;
447 }
448): Promise<ClaudeTestResponse | null> {
449 try {
450 const message = await client.messages.create({
451 model: MODEL_SONNET,
452 max_tokens: 4096,
453 messages: [
454 { role: "user", content: buildTestsForPrPrompt(args) },
455 ],
456 });
457 const text = extractText(message);
458 const parsed = parseJsonResponse<ClaudeTestResponse>(text);
459 if (!parsed) return null;
460 return parsed;
461 } catch (err) {
462 console.warn(
463 "[ai-test-generator] Claude call failed:",
464 err instanceof Error ? err.message : err
465 );
466 return null;
467 }
468}
469
470/**
471 * Seed `branch` from `parentSha` if it doesn't yet exist. Returns true on
472 * success or if it already exists.
473 */
474async function ensureBranchAt(
475 ownerName: string,
476 repoName: string,
477 branch: string,
478 parentSha: string
479): Promise<boolean> {
480 const fullRef = `refs/heads/${branch}`;
481 if (await refExists(ownerName, repoName, fullRef)) return true;
482 return updateRef(ownerName, repoName, fullRef, parentSha);
483}
484
485/**
486 * Drop the marker comment on `pullRequestId` so the next tick's dedupe
487 * sees us. Best-effort — failures are logged but don't stop the run.
488 */
489async function dropMarkerComment(
490 pullRequestId: string,
491 authorId: string,
492 bodySuffix: string
493): Promise<void> {
494 try {
495 await db.insert(prComments).values({
496 pullRequestId,
497 authorId,
498 isAiReview: true,
499 body: `${AI_TESTS_MARKER}\n${bodySuffix}`,
500 });
501 } catch (err) {
502 console.warn(
503 "[ai-test-generator] dropMarkerComment failed:",
504 err instanceof Error ? err.message : err
505 );
506 }
507}
508
509// ---------------------------------------------------------------------------
510// Main entry point
511// ---------------------------------------------------------------------------
512
513/**
514 * Generate tests for a PR. Returns a structured result; never throws.
515 *
516 * `mode='append-commit'`:
517 * Writes each generated test file onto the PR's existing headBranch.
518 * Surfaces the marker as a comment on the original PR.
519 *
520 * `mode='follow-up-pr'`:
521 * Branches off the PR's headBranch into `ai-tests/pr-<n>-<ts>`, writes
522 * each test file, then opens a new PR targeting the original headBranch
523 * as base. Surfaces the marker on both PRs.
524 */
525export async function generateTestsForPr(
526 args: GenerateTestsForPrArgs
527): Promise<GenerateTestsForPrResult> {
528 // 1. Resolve client. Lazy so tests can inject without an API key.
529 let client: Pick<Anthropic, "messages">;
530 if (args.client) {
531 client = args.client;
532 } else {
533 if (!config.anthropicApiKey) {
534 return { ok: false, error: "ANTHROPIC_API_KEY not configured" };
535 }
536 try {
537 client = getAnthropic();
538 } catch {
539 return { ok: false, error: "Failed to construct Anthropic client" };
540 }
541 }
542
543 // 2. Load PR row + repo + owner.
544 const facts = await loadPrFacts(args.prId);
545 if (!facts) return { ok: false, error: "PR not found" };
546
547 // 3. Avoid recursion on spec-driven PRs.
548 if (looksLikeSpecPr(facts.pr.body)) {
549 return { ok: false, error: "PR is AI-generated (ai:spec-implementation); skipping" };
550 }
551
552 // 4. Idempotency — both modes write a marker on the original PR.
553 if (await alreadyTagged(facts.pr.id)) {
554 return { ok: true, alreadyDone: true, written: 0 };
555 }
556 if (
557 args.mode === "follow-up-pr" &&
558 (await alreadyHasFollowUpPr(facts.pr.repositoryId, facts.pr.number))
559 ) {
560 return { ok: true, alreadyDone: true, written: 0 };
561 }
562
563 // 5. Discover changed source files.
564 const allChanged =
565 args.changedFilesOverride ??
566 (await listChangedSourceFiles(
567 facts.ownerName,
568 facts.repoName,
569 facts.pr.baseBranch,
570 facts.pr.headBranch
571 ));
572
573 const candidates = allChanged.filter(isCandidateSourceFile);
574 const cap = Math.max(1, args.maxFiles ?? MAX_FILES_PER_RUN);
575 const sliced = candidates.slice(0, cap);
576
577 if (sliced.length === 0) {
578 return { ok: false, error: "No candidate source files in diff" };
579 }
580
581 // 6. Framework hint via existing detector + repo tree listing.
582 const repoFiles = await listRepoFiles(
583 facts.ownerName,
584 facts.repoName,
585 facts.pr.headBranch
586 );
587
588 // 7. Resolve the head sha — needed both to seed a follow-up branch and
589 // to read source files at the head state.
590 const headSha = await resolveRef(
591 facts.ownerName,
592 facts.repoName,
593 facts.pr.headBranch
594 );
595 if (!headSha) {
596 return { ok: false, error: "Could not resolve head branch sha" };
597 }
598
599 // 8. Determine the branch we'll write commits to.
600 let writeBranch = facts.pr.headBranch;
601 if (args.mode === "follow-up-pr") {
602 writeBranch = testsBranchName(facts.pr.number);
603 const seeded = await ensureBranchAt(
604 facts.ownerName,
605 facts.repoName,
606 writeBranch,
607 headSha
608 );
609 if (!seeded) {
610 return { ok: false, error: `Could not seed branch ${writeBranch}` };
611 }
612 }
613
614 // 9. Per-file Claude loop. Each file's test patches are written
615 // individually so a single failure can't lose all the work.
616 await ensureTestsLabel(facts.pr.repositoryId);
617
618 const written: string[] = [];
619 const skipped: string[] = [];
620 for (const sourcePath of sliced) {
621 const sourceContent =
622 args.resolveSource !== undefined
623 ? await args.resolveSource(sourcePath)
624 : await readSourceContent(
625 facts.ownerName,
626 facts.repoName,
627 facts.pr.headBranch,
628 sourcePath
629 );
630 if (sourceContent == null || sourceContent === "") {
631 skipped.push(sourcePath);
632 continue;
633 }
634
635 const language = detectLanguage(sourcePath);
636 const framework = detectTestFramework(language, repoFiles);
637
638 const claudeRes = await askClaudeForTests(client, {
639 filePath: sourcePath,
640 language,
641 framework,
642 sourceCode: sourceContent,
643 prTitle: facts.pr.title,
644 });
645 if (!claudeRes || !Array.isArray(claudeRes.patches) || claudeRes.patches.length === 0) {
646 skipped.push(sourcePath);
647 continue;
648 }
649
650 for (const patch of claudeRes.patches) {
651 if (
652 !patch ||
653 typeof patch.path !== "string" ||
654 typeof patch.new_content !== "string"
655 ) {
656 continue;
657 }
658 // Safety: only allow new files inside the repo, with a test-file
659 // shaped path. We deliberately reject patches that try to rewrite
660 // the source file (Claude's job is to ADD tests, not edit code).
661 if (patch.path === sourcePath) continue;
662 if (patch.path.includes("..") || patch.path.startsWith("/")) continue;
663
664 const res = await createOrUpdateFileOnBranch({
665 owner: facts.ownerName,
666 name: facts.repoName,
667 branch: writeBranch,
668 filePath: patch.path,
669 bytes: new TextEncoder().encode(patch.new_content),
670 message: `test(ai): add tests for ${sourcePath}`,
671 authorName: "Gluecron AI",
672 authorEmail: "ai@gluecron.com",
673 });
674 if ("error" in res) {
675 skipped.push(patch.path);
676 continue;
677 }
678 written.push(patch.path);
679 }
680 }
681
682 if (written.length === 0) {
683 return {
684 ok: false,
685 error: "Claude produced no usable test patches",
686 written: 0,
687 };
688 }
689
690 // 10. Decorate the right PR(s) with the marker + label citation.
691 if (args.mode === "append-commit") {
692 await dropMarkerComment(
693 facts.pr.id,
694 facts.repoOwnerId,
695 [
696 `Applied label: \`${AI_TESTS_LABEL}\``,
697 `Added ${written.length} test file${written.length === 1 ? "" : "s"} on \`${writeBranch}\`:`,
698 ...written.map((p) => `- \`${p}\``),
699 ].join("\n")
700 );
701
702 await audit({
703 userId: null,
704 action: "ai.tests.added",
705 repositoryId: facts.pr.repositoryId,
706 targetType: "pull_request",
707 targetId: facts.pr.id,
708 metadata: {
709 mode: args.mode,
710 prNumber: facts.pr.number,
711 branch: writeBranch,
712 written,
713 },
714 });
715
716 return {
717 ok: true,
718 branch: writeBranch,
719 written: written.length,
720 };
721 }
722
723 // follow-up-pr mode — open the new PR targeting the original headBranch.
724 let newPrNumber: number | null = null;
725 let newPrId: string | null = null;
726 try {
727 const body = renderFollowUpPrBody({
728 originalPrNumber: facts.pr.number,
729 branch: writeBranch,
730 written,
731 });
732 const [pr] = await db
733 .insert(pullRequests)
734 .values({
735 repositoryId: facts.pr.repositoryId,
736 authorId: facts.repoOwnerId,
737 title: `[tests] +tests for #${facts.pr.number}`,
738 body,
739 baseBranch: facts.pr.headBranch,
740 headBranch: writeBranch,
741 isDraft: false,
742 })
743 .returning({ number: pullRequests.number, id: pullRequests.id });
744 if (pr) {
745 newPrNumber = pr.number;
746 newPrId = pr.id;
747 }
748 } catch (err) {
749 console.warn(
750 "[ai-test-generator] follow-up PR insert failed:",
751 err instanceof Error ? err.message : err
752 );
753 }
754
755 // Always drop the marker on the ORIGINAL PR so future ticks dedupe.
756 await dropMarkerComment(
757 facts.pr.id,
758 facts.repoOwnerId,
759 [
760 `Applied label: \`${AI_TESTS_LABEL}\``,
761 newPrNumber
762 ? `Opened follow-up tests PR #${newPrNumber} → \`${writeBranch}\``
763 : `Tests pushed to branch \`${writeBranch}\` (PR insert failed).`,
764 ...written.map((p) => `- \`${p}\``),
765 ].join("\n")
766 );
767
768 // Also drop a marker on the follow-up PR itself so direct lookups work.
769 if (newPrId) {
770 await dropMarkerComment(
771 newPrId,
772 facts.repoOwnerId,
773 `Applied label: \`${AI_TESTS_LABEL}\``
774 );
775 }
776
777 await audit({
778 userId: null,
779 action: "ai.tests.added",
780 repositoryId: facts.pr.repositoryId,
781 targetType: "pull_request",
782 targetId: facts.pr.id,
783 metadata: {
784 mode: args.mode,
785 prNumber: facts.pr.number,
786 followUpPrNumber: newPrNumber,
787 branch: writeBranch,
788 written,
789 },
790 });
791
792 return {
793 ok: true,
794 branch: writeBranch,
795 prNumber: newPrNumber ?? undefined,
796 written: written.length,
797 };
798}
799
800/**
801 * Render the PR body for the follow-up tests PR. Pure helper exported
802 * for tests.
803 */
804export function renderFollowUpPrBody(args: {
805 originalPrNumber: number;
806 branch: string;
807 written: string[];
808}): string {
809 const files = args.written.map((p) => `- \`${p}\``).join("\n");
810 return [
811 `${AI_TESTS_MARKER}`,
812 `for PR #${args.originalPrNumber}`,
813 "",
814 `## +tests for #${args.originalPrNumber}`,
815 "",
816 "Gluecron AI scanned the source changes in this PR's branch and added",
817 "tests that match the repository's existing test framework.",
818 "",
819 `Branch: \`${args.branch}\``,
820 "",
821 "### Files added",
822 files || "_(none)_",
823 "",
824 "---",
825 "",
826 `Labels: \`${AI_TESTS_LABEL}\``,
827 "",
828 "_Auto-generated by Gluecron AI. Review every assertion before merging._",
829 ].join("\n");
830}
831
832/**
833 * Read a file from the bare repo at the given ref, returning its text or
834 * null when missing/binary/too-large.
835 */
836async function readSourceContent(
837 ownerName: string,
838 repoName: string,
839 ref: string,
840 path: string
841): Promise<string | null> {
842 try {
843 const blob = await getBlob(ownerName, repoName, ref, path);
844 if (!blob) return null;
845 if (blob.isBinary) return null;
846 return blob.content;
847 } catch {
848 return null;
849 }
850}
851
852/**
853 * Test-only re-exports of internal helpers.
854 */
855export const __test = {
856 loadPrFacts,
857 listChangedSourceFiles,
858 listRepoFiles,
859 alreadyTagged,
860 alreadyHasFollowUpPr,
861 ensureTestsLabel,
862 ensureBranchAt,
863 readSourceContent,
864 askClaudeForTests,
865 dropMarkerComment,
866};