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.
| 0c3eee5 | 1 | /** |
| 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 | ||
| 29 | import { and, eq, like } from "drizzle-orm"; | |
| 30 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 31 | import { db } from "../db"; | |
| 32 | import { | |
| 33 | labels, | |
| 34 | prComments, | |
| 35 | pullRequests, | |
| 36 | repositories, | |
| 37 | users, | |
| 38 | } from "../db/schema"; | |
| 39 | import { | |
| 40 | createOrUpdateFileOnBranch, | |
| 41 | getBlob, | |
| 42 | getRepoPath, | |
| 43 | refExists, | |
| 44 | resolveRef, | |
| 45 | updateRef, | |
| 46 | } from "../git/repository"; | |
| 47 | import { config } from "./config"; | |
| 48 | import { audit } from "./notify"; | |
| 49 | import { | |
| 50 | getAnthropic, | |
| 51 | MODEL_SONNET, | |
| 52 | extractText, | |
| 53 | parseJsonResponse, | |
| 54 | } from "./ai-client"; | |
| 55 | import { detectLanguage, detectTestFramework } from "./ai-tests"; | |
| 56 | ||
| 57 | /** Marker embedded in PR comments so subsequent ticks dedupe cleanly. */ | |
| 58 | export const AI_TESTS_MARKER = "<!-- gluecron-ai-tests:added -->"; | |
| 59 | ||
| 60 | /** Label name attached (and ensured present on the repo) for tagged PRs. */ | |
| 61 | export const AI_TESTS_LABEL = "ai:added-tests"; | |
| 62 | ||
| 63 | /** Existing PR-label marker we use to detect spec-generated PRs (avoid recursion). */ | |
| 64 | const AI_SPEC_LABEL = "ai:spec-implementation"; | |
| 65 | const 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. */ | |
| 68 | export const MAX_FILES_PER_RUN = 5; | |
| 69 | ||
| 70 | /** Hard cap on Claude prompt size — large source files get truncated. */ | |
| 71 | const MAX_SOURCE_BYTES_PER_FILE = 24_000; | |
| 72 | ||
| 73 | /** Test-file path heuristics — skip writing tests for things that already are tests. */ | |
| 74 | const 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. */ | |
| 77 | const 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. */ | |
| 91 | const 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. */ | |
| 94 | const 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 | ||
| 100 | export type TestGenMode = "append-commit" | "follow-up-pr"; | |
| 101 | ||
| 102 | export 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 | ||
| 115 | export 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 | ||
| 129 | interface ClaudeTestPatch { | |
| 130 | path: string; | |
| 131 | new_content: string; | |
| 132 | } | |
| 133 | ||
| 134 | interface ClaudeTestResponse { | |
| 135 | patches?: ClaudeTestPatch[]; | |
| 136 | } | |
| 137 | ||
| 138 | interface 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 | */ | |
| 165 | export 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 | */ | |
| 179 | export 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 | */ | |
| 229 | export 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 | */ | |
| 242 | async 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 | */ | |
| 297 | async 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 | */ | |
| 328 | async 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 | */ | |
| 356 | async 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 | */ | |
| 379 | async 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 | */ | |
| 405 | export 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 | */ | |
| 415 | async 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 | */ | |
| 439 | async 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 | */ | |
| 474 | async 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 | */ | |
| 489 | async 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 | */ | |
| 525 | export 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 | */ | |
| 804 | export 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 | */ | |
| 836 | async 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 | */ | |
| 855 | export const __test = { | |
| 856 | loadPrFacts, | |
| 857 | listChangedSourceFiles, | |
| 858 | listRepoFiles, | |
| 859 | alreadyTagged, | |
| 860 | alreadyHasFollowUpPr, | |
| 861 | ensureTestsLabel, | |
| 862 | ensureBranchAt, | |
| 863 | readSourceContent, | |
| 864 | askClaudeForTests, | |
| 865 | dropMarkerComment, | |
| 866 | }; |