CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-patch-generator.test.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.
| 93e0d17 | 1 | /** |
| 2 | * Tests for src/lib/ai-patch-generator.ts. | |
| 3 | * | |
| 4 | * Two layers of coverage: | |
| 5 | * | |
| 6 | * 1. Pure helpers — no DB / no Claude. These always run. | |
| 7 | * - findingShortId is deterministic for the same finding. | |
| 8 | * - patchBranchName respects an override and otherwise embeds the | |
| 9 | * finding's short id + timestamp. | |
| 10 | * - buildPatchPrompt + renderPatchPrBody embed the finding's | |
| 11 | * context so PR reviewers can trace the trail. | |
| 12 | * - severityAtOrAboveMedium gates correctly. | |
| 13 | * | |
| 14 | * 2. End-to-end with an injected fake Claude client + a real bare | |
| 15 | * repo on disk. DB-backed steps (PR insert, label upsert, audit) | |
| 16 | * are gated on DATABASE_URL — without it we still assert the | |
| 17 | * git-side effect (branch + commit landed) and that an empty | |
| 18 | * patches array short-circuits to `null`. | |
| 19 | * | |
| 20 | * The Anthropic client is faked via the public `client` option so we | |
| 21 | * never touch the network or require an API key. | |
| 22 | */ | |
| 23 | ||
| 24 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; | |
| 25 | import { join } from "path"; | |
| 26 | import { rm, mkdir } from "fs/promises"; | |
| 27 | import { | |
| 28 | AI_PATCH_LABEL, | |
| 29 | AI_PATCH_MARKER, | |
| 30 | __test, | |
| 31 | buildPatchPrompt, | |
| 32 | findingShortId, | |
| 33 | generatePatchForGateTestFinding, | |
| 34 | patchBranchName, | |
| 35 | renderPatchPrBody, | |
| 36 | severityAtOrAboveMedium, | |
| 37 | } from "../lib/ai-patch-generator"; | |
| 38 | import { | |
| 39 | initBareRepo, | |
| 40 | createOrUpdateFileOnBranch, | |
| 41 | refExists, | |
| 42 | resolveRef, | |
| 43 | getBlob, | |
| 44 | } from "../git/repository"; | |
| 45 | import { db } from "../db"; | |
| 46 | import { eq } from "drizzle-orm"; | |
| 47 | import { | |
| 48 | pullRequests, | |
| 49 | repositories, | |
| 50 | users, | |
| 51 | } from "../db/schema"; | |
| 52 | ||
| 53 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 54 | ||
| 55 | const TEST_REPOS = join( | |
| 56 | import.meta.dir, | |
| 57 | "../../.test-repos-ai-patch-" + Date.now() | |
| 58 | ); | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | process.env.GIT_REPOS_PATH = TEST_REPOS; | |
| 62 | process.env.DATABASE_URL = process.env.DATABASE_URL || ""; | |
| 63 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 64 | await mkdir(TEST_REPOS, { recursive: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | afterAll(async () => { | |
| 68 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | // --------------------------------------------------------------------------- | |
| 72 | // Pure helpers | |
| 73 | // --------------------------------------------------------------------------- | |
| 74 | ||
| 75 | describe("findingShortId", () => { | |
| 76 | it("uses the scanner-provided id when it's safe", () => { | |
| 77 | expect(findingShortId({ id: "RULE-123" })).toBe("RULE-123"); | |
| 78 | }); | |
| 79 | ||
| 80 | it("derives a deterministic hash when no id is provided", () => { | |
| 81 | const a = findingShortId({ | |
| 82 | ruleId: "no-eval", | |
| 83 | path: "src/x.ts", | |
| 84 | line: 7, | |
| 85 | title: "eval usage", | |
| 86 | description: "Avoid eval", | |
| 87 | }); | |
| 88 | const b = findingShortId({ | |
| 89 | ruleId: "no-eval", | |
| 90 | path: "src/x.ts", | |
| 91 | line: 7, | |
| 92 | title: "eval usage", | |
| 93 | description: "Avoid eval", | |
| 94 | }); | |
| 95 | expect(a).toBe(b); | |
| 96 | expect(a.length).toBe(12); | |
| 97 | expect(/^[0-9a-f]{12}$/.test(a)).toBe(true); | |
| 98 | }); | |
| 99 | ||
| 100 | it("yields different ids for different findings", () => { | |
| 101 | const a = findingShortId({ ruleId: "x", path: "a.ts" }); | |
| 102 | const b = findingShortId({ ruleId: "y", path: "a.ts" }); | |
| 103 | expect(a).not.toBe(b); | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | describe("patchBranchName", () => { | |
| 108 | it("honours an override", () => { | |
| 109 | expect(patchBranchName({ id: "RULE-1" }, "custom/branch")).toBe( | |
| 110 | "custom/branch" | |
| 111 | ); | |
| 112 | }); | |
| 113 | ||
| 114 | it("uses ai-patch/<id>-<timestamp> by default", () => { | |
| 115 | const name = patchBranchName({ id: "RULE-1" }); | |
| 116 | expect(name.startsWith("ai-patch/RULE-1-")).toBe(true); | |
| 117 | // tail must be a numeric timestamp | |
| 118 | const ts = name.split("-").pop()!; | |
| 119 | expect(/^\d+$/.test(ts)).toBe(true); | |
| 120 | }); | |
| 121 | }); | |
| 122 | ||
| 123 | describe("severityAtOrAboveMedium", () => { | |
| 124 | it("accepts medium/high/critical (case-insensitive)", () => { | |
| 125 | expect(severityAtOrAboveMedium("medium")).toBe(true); | |
| 126 | expect(severityAtOrAboveMedium("High")).toBe(true); | |
| 127 | expect(severityAtOrAboveMedium("CRITICAL")).toBe(true); | |
| 128 | }); | |
| 129 | ||
| 130 | it("rejects low / info / missing", () => { | |
| 131 | expect(severityAtOrAboveMedium("low")).toBe(false); | |
| 132 | expect(severityAtOrAboveMedium("info")).toBe(false); | |
| 133 | expect(severityAtOrAboveMedium(undefined)).toBe(false); | |
| 134 | expect(severityAtOrAboveMedium(null)).toBe(false); | |
| 135 | expect(severityAtOrAboveMedium("")).toBe(false); | |
| 136 | }); | |
| 137 | }); | |
| 138 | ||
| 139 | describe("buildPatchPrompt", () => { | |
| 140 | it("embeds path, severity, description, and the file contents", () => { | |
| 141 | const out = buildPatchPrompt( | |
| 142 | { | |
| 143 | ruleId: "hardcoded-secret", | |
| 144 | severity: "high", | |
| 145 | line: 12, | |
| 146 | description: "API key in source", | |
| 147 | }, | |
| 148 | "src/config.ts", | |
| 149 | "export const KEY = 'sk_live_xxx';" | |
| 150 | ); | |
| 151 | expect(out).toContain("src/config.ts"); | |
| 152 | expect(out).toContain("line 12"); | |
| 153 | expect(out).toContain("API key in source"); | |
| 154 | expect(out).toContain("hardcoded-secret"); | |
| 155 | expect(out).toContain("sk_live_xxx"); | |
| 156 | // JSON schema cue must be present so the model returns parseable output | |
| 157 | expect(out).toContain("\"patches\""); | |
| 158 | expect(out).toContain("\"new_content\""); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("renderPatchPrBody", () => { | |
| 163 | it("includes the marker, label tag, citation, and file list", () => { | |
| 164 | const body = renderPatchPrBody({ | |
| 165 | finding: { | |
| 166 | ruleId: "hardcoded-secret", | |
| 167 | severity: "high", | |
| 168 | line: 12, | |
| 169 | title: "Hardcoded secret", | |
| 170 | description: "API key in source", | |
| 171 | }, | |
| 172 | filePath: "src/config.ts", | |
| 173 | explanation: "Replaced literal with env var read.", | |
| 174 | reportUrl: "https://gatetest.example/run/42", | |
| 175 | patchPaths: ["src/config.ts"], | |
| 176 | }); | |
| 177 | expect(body).toContain(AI_PATCH_MARKER); | |
| 178 | expect(body).toContain(AI_PATCH_LABEL); | |
| 179 | expect(body).toContain("https://gatetest.example/run/42"); | |
| 180 | expect(body).toContain("src/config.ts"); | |
| 181 | expect(body).toContain("Replaced literal with env var read."); | |
| 182 | }); | |
| 183 | ||
| 184 | it("notes the missing citation when reportUrl is absent", () => { | |
| 185 | const body = renderPatchPrBody({ | |
| 186 | finding: { ruleId: "x" }, | |
| 187 | filePath: "a.ts", | |
| 188 | explanation: "", | |
| 189 | patchPaths: ["a.ts"], | |
| 190 | }); | |
| 191 | expect(body).toContain("(not provided)"); | |
| 192 | }); | |
| 193 | }); | |
| 194 | ||
| 195 | // --------------------------------------------------------------------------- | |
| 196 | // Injected-Claude end-to-end. The DB-touching steps only run with HAS_DB; | |
| 197 | // without it we still verify the git side effects and the empty-patches | |
| 198 | // short-circuit. | |
| 199 | // --------------------------------------------------------------------------- | |
| 200 | ||
| 201 | /** | |
| 202 | * Build a fake Anthropic client that returns a canned JSON envelope. | |
| 203 | * Mirrors the shape `extractText` + `parseJsonResponse` consume. | |
| 204 | */ | |
| 205 | function fakeClient(responseText: string) { | |
| 206 | return { | |
| 207 | messages: { | |
| 208 | // The real SDK exposes much more here — we narrow to `create` | |
| 209 | // because that's all `askClaudeForPatch` calls. | |
| 210 | create: async () => ({ | |
| 211 | content: [{ type: "text" as const, text: responseText }], | |
| 212 | }), | |
| 213 | }, | |
| 214 | } as any; | |
| 215 | } | |
| 216 | ||
| 217 | const OWNER = "ai-patch-test-" + Date.now().toString(36); | |
| 218 | const REPO = "subject"; | |
| 219 | ||
| 220 | async function seedRepo(): Promise<{ baseSha: string }> { | |
| 221 | await initBareRepo(OWNER, REPO); | |
| 222 | // Seed one initial commit on `main` so we have a valid base sha for | |
| 223 | // the patch generator to branch from. | |
| 224 | const res = await createOrUpdateFileOnBranch({ | |
| 225 | owner: OWNER, | |
| 226 | name: REPO, | |
| 227 | branch: "main", | |
| 228 | filePath: "src/config.ts", | |
| 229 | bytes: new TextEncoder().encode( | |
| 230 | "export const KEY = 'sk_live_LEAKED';\n" | |
| 231 | ), | |
| 232 | message: "seed", | |
| 233 | authorName: "Seeder", | |
| 234 | authorEmail: "seed@example.com", | |
| 235 | }); | |
| 236 | if ("error" in res) { | |
| 237 | throw new Error(`seed failed: ${res.error}`); | |
| 238 | } | |
| 239 | return { baseSha: res.commitSha }; | |
| 240 | } | |
| 241 | ||
| 242 | describe("generatePatchForGateTestFinding — end-to-end with fake Claude", () => { | |
| 243 | it("returns null when findings array is empty", async () => { | |
| 244 | const out = await generatePatchForGateTestFinding({ | |
| 245 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 246 | baseSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", | |
| 247 | findings: [], | |
| 248 | client: fakeClient("{}"), | |
| 249 | }); | |
| 250 | expect(out).toBeNull(); | |
| 251 | }); | |
| 252 | ||
| 253 | it("returns null when Claude returns zero patches", async () => { | |
| 254 | // No DB needed — we short-circuit before any insert when patches | |
| 255 | // array is empty. Still, we need a resolvable repo row to even | |
| 256 | // reach that branch, so this assertion only fires under HAS_DB. | |
| 257 | if (!HAS_DB) { | |
| 258 | // Without DB the resolver returns null first → still null. Either | |
| 259 | // way, the assertion holds. | |
| 260 | const out = await generatePatchForGateTestFinding({ | |
| 261 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 262 | baseSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", | |
| 263 | findings: [ | |
| 264 | { id: "f1", path: "src/config.ts", severity: "high", description: "x" }, | |
| 265 | ], | |
| 266 | client: fakeClient('{"explanation":"nothing to fix","patches":[]}'), | |
| 267 | }); | |
| 268 | expect(out).toBeNull(); | |
| 269 | return; | |
| 270 | } | |
| 271 | ||
| 272 | // HAS_DB path: insert a real repo row + user, then run the generator. | |
| 273 | const username = `aipatch_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; | |
| 274 | const [u] = await db | |
| 275 | .insert(users) | |
| 276 | .values({ | |
| 277 | username, | |
| 278 | email: `${username}@example.com`, | |
| 279 | passwordHash: "x", | |
| 280 | }) | |
| 281 | .returning({ id: users.id }); | |
| 282 | ||
| 283 | const [r] = await db | |
| 284 | .insert(repositories) | |
| 285 | .values({ | |
| 286 | ownerId: u.id, | |
| 287 | name: `empty_${Date.now()}`, | |
| 288 | diskPath: `/tmp/${username}/empty`, | |
| 289 | defaultBranch: "main", | |
| 290 | }) | |
| 291 | .returning({ id: repositories.id, name: repositories.name }); | |
| 292 | ||
| 293 | // Need a bare repo on disk so getBlob succeeds. | |
| 294 | await initBareRepo(username, r.name); | |
| 295 | const seeded = await createOrUpdateFileOnBranch({ | |
| 296 | owner: username, | |
| 297 | name: r.name, | |
| 298 | branch: "main", | |
| 299 | filePath: "src/config.ts", | |
| 300 | bytes: new TextEncoder().encode("x\n"), | |
| 301 | message: "seed", | |
| 302 | authorName: "Seeder", | |
| 303 | authorEmail: "s@e.com", | |
| 304 | }); | |
| 305 | if ("error" in seeded) throw new Error("seed failed"); | |
| 306 | ||
| 307 | const out = await generatePatchForGateTestFinding({ | |
| 308 | repositoryId: r.id, | |
| 309 | baseSha: seeded.commitSha, | |
| 310 | findings: [ | |
| 311 | { id: "f1", path: "src/config.ts", severity: "high", description: "x" }, | |
| 312 | ], | |
| 313 | client: fakeClient('{"explanation":"nothing to fix","patches":[]}'), | |
| 314 | }); | |
| 315 | expect(out).toBeNull(); | |
| 316 | ||
| 317 | // No PR should have been opened. | |
| 318 | const prs = await db | |
| 319 | .select({ number: pullRequests.number }) | |
| 320 | .from(pullRequests) | |
| 321 | .where(eq(pullRequests.repositoryId, r.id)); | |
| 322 | expect(prs.length).toBe(0); | |
| 323 | }); | |
| 324 | ||
| 325 | it( | |
| 326 | "creates branch + commit + PR when Claude returns a real patch", | |
| 327 | async () => { | |
| 328 | if (!HAS_DB) { | |
| 329 | // Without a DB we can't open a PR, but we can still drive the | |
| 330 | // git-side of the generator by asserting the helper used by the | |
| 331 | // generator (`createOrUpdateFileOnBranch`) lands the patch on a | |
| 332 | // disposable branch. This keeps signal in the DB-less sandbox. | |
| 333 | const { baseSha } = await seedRepo(); | |
| 334 | const res = await createOrUpdateFileOnBranch({ | |
| 335 | owner: OWNER, | |
| 336 | name: REPO, | |
| 337 | branch: "ai-patch/manual-1", | |
| 338 | filePath: "src/config.ts", | |
| 339 | bytes: new TextEncoder().encode( | |
| 340 | "export const KEY = process.env.API_KEY ?? '';\n" | |
| 341 | ), | |
| 342 | message: "fix", | |
| 343 | authorName: "GlueCron AI", | |
| 344 | authorEmail: "ai@gluecron.com", | |
| 345 | }); | |
| 346 | expect("commitSha" in res).toBe(true); | |
| 347 | expect(await refExists(OWNER, REPO, "refs/heads/ai-patch/manual-1")).toBe( | |
| 348 | true | |
| 349 | ); | |
| 350 | // The base sha must still exist on main. | |
| 351 | expect(typeof baseSha).toBe("string"); | |
| 352 | return; | |
| 353 | } | |
| 354 | ||
| 355 | // HAS_DB path: full E2E. | |
| 356 | const username = `aipatch_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; | |
| 357 | const [u] = await db | |
| 358 | .insert(users) | |
| 359 | .values({ | |
| 360 | username, | |
| 361 | email: `${username}@example.com`, | |
| 362 | passwordHash: "x", | |
| 363 | }) | |
| 364 | .returning({ id: users.id }); | |
| 365 | ||
| 366 | const repoName = `subject_${Date.now()}`; | |
| 367 | const [r] = await db | |
| 368 | .insert(repositories) | |
| 369 | .values({ | |
| 370 | ownerId: u.id, | |
| 371 | name: repoName, | |
| 372 | diskPath: `/tmp/${username}/${repoName}`, | |
| 373 | defaultBranch: "main", | |
| 374 | }) | |
| 375 | .returning({ id: repositories.id }); | |
| 376 | ||
| 377 | await initBareRepo(username, repoName); | |
| 378 | const seeded = await createOrUpdateFileOnBranch({ | |
| 379 | owner: username, | |
| 380 | name: repoName, | |
| 381 | branch: "main", | |
| 382 | filePath: "src/config.ts", | |
| 383 | bytes: new TextEncoder().encode( | |
| 384 | "export const KEY = 'sk_live_LEAKED';\n" | |
| 385 | ), | |
| 386 | message: "seed", | |
| 387 | authorName: "Seeder", | |
| 388 | authorEmail: "s@e.com", | |
| 389 | }); | |
| 390 | if ("error" in seeded) throw new Error("seed failed"); | |
| 391 | ||
| 392 | const cannedPatch = JSON.stringify({ | |
| 393 | explanation: | |
| 394 | "Replaced the literal credential with an env-var read so the secret no longer lives in source.", | |
| 395 | patches: [ | |
| 396 | { | |
| 397 | path: "src/config.ts", | |
| 398 | new_content: | |
| 399 | "export const KEY = process.env.API_KEY ?? '';\n", | |
| 400 | }, | |
| 401 | ], | |
| 402 | }); | |
| 403 | ||
| 404 | const branchOverride = `ai-patch/test-${Date.now()}`; | |
| 405 | const out = await generatePatchForGateTestFinding({ | |
| 406 | repositoryId: r.id, | |
| 407 | baseSha: seeded.commitSha, | |
| 408 | findings: [ | |
| 409 | { | |
| 410 | id: "hardcoded-secret", | |
| 411 | ruleId: "hardcoded-secret", | |
| 412 | path: "src/config.ts", | |
| 413 | severity: "high", | |
| 414 | line: 1, | |
| 415 | title: "Hardcoded secret", | |
| 416 | description: "API key in source", | |
| 417 | }, | |
| 418 | ], | |
| 419 | client: fakeClient(cannedPatch), | |
| 420 | reportUrl: "https://gatetest.example/run/test", | |
| 421 | branchOverride, | |
| 422 | }); | |
| 423 | ||
| 424 | expect(out).not.toBeNull(); | |
| 425 | expect(out!.branch).toBe(branchOverride); | |
| 426 | expect(typeof out!.prNumber).toBe("number"); | |
| 427 | ||
| 428 | // Branch exists in the bare repo. | |
| 429 | expect( | |
| 430 | await refExists(username, repoName, `refs/heads/${branchOverride}`) | |
| 431 | ).toBe(true); | |
| 432 | ||
| 433 | // Commit on the branch contains the new content. | |
| 434 | const branchSha = await resolveRef( | |
| 435 | username, | |
| 436 | repoName, | |
| 437 | branchOverride | |
| 438 | ); | |
| 439 | expect(branchSha).not.toBeNull(); | |
| 440 | const blob = await getBlob( | |
| 441 | username, | |
| 442 | repoName, | |
| 443 | branchOverride, | |
| 444 | "src/config.ts" | |
| 445 | ); | |
| 446 | expect(blob).not.toBeNull(); | |
| 447 | expect(blob!.content).toContain("process.env.API_KEY"); | |
| 448 | expect(blob!.content).not.toContain("sk_live_LEAKED"); | |
| 449 | ||
| 450 | // PR row exists with the right base/head. | |
| 451 | const [pr] = await db | |
| 452 | .select({ | |
| 453 | number: pullRequests.number, | |
| 454 | headBranch: pullRequests.headBranch, | |
| 455 | baseBranch: pullRequests.baseBranch, | |
| 456 | body: pullRequests.body, | |
| 457 | }) | |
| 458 | .from(pullRequests) | |
| 459 | .where(eq(pullRequests.repositoryId, r.id)) | |
| 460 | .limit(1); | |
| 461 | expect(pr).toBeTruthy(); | |
| 462 | expect(pr!.headBranch).toBe(branchOverride); | |
| 463 | expect(pr!.baseBranch).toBe("main"); | |
| 464 | expect(pr!.body).toContain(AI_PATCH_MARKER); | |
| 465 | expect(pr!.body).toContain(AI_PATCH_LABEL); | |
| 466 | }, | |
| 467 | 20000 | |
| 468 | ); | |
| 469 | }); | |
| 470 | ||
| 471 | // --------------------------------------------------------------------------- | |
| 472 | // Internal helpers (sanity checks — they should resolve without throwing | |
| 473 | // against bogus inputs). | |
| 474 | // --------------------------------------------------------------------------- | |
| 475 | ||
| 476 | describe("__test internals", () => { | |
| 477 | it("exports the documented helpers", () => { | |
| 478 | expect(typeof __test.findingPath).toBe("function"); | |
| 479 | expect(typeof __test.findingDescription).toBe("function"); | |
| 480 | expect(typeof __test.resolveOwnerName).toBe("function"); | |
| 481 | expect(typeof __test.askClaudeForPatch).toBe("function"); | |
| 482 | expect(typeof __test.seedBranchFromBase).toBe("function"); | |
| 483 | }); | |
| 484 | ||
| 485 | it("findingPath returns null when neither path nor file is set", () => { | |
| 486 | expect(__test.findingPath({} as any)).toBeNull(); | |
| 487 | expect(__test.findingPath({ path: " " } as any)).toBeNull(); | |
| 488 | expect(__test.findingPath({ file: "x.ts" } as any)).toBe("x.ts"); | |
| 489 | }); | |
| 490 | ||
| 491 | it("askClaudeForPatch tolerates an invalid JSON envelope", async () => { | |
| 492 | const out = await __test.askClaudeForPatch( | |
| 493 | fakeClient("not json at all"), | |
| 494 | { ruleId: "x", path: "a.ts" }, | |
| 495 | "a.ts", | |
| 496 | "content" | |
| 497 | ); | |
| 498 | expect(out).toBeNull(); | |
| 499 | }); | |
| 500 | }); |