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-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.

ai-patch-generator.test.tsBlame500 lines · 1 contributor
93e0d17Claude1/**
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
24import { describe, it, expect, beforeAll, afterAll } from "bun:test";
25import { join } from "path";
26import { rm, mkdir } from "fs/promises";
27import {
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";
38import {
39 initBareRepo,
40 createOrUpdateFileOnBranch,
41 refExists,
42 resolveRef,
43 getBlob,
44} from "../git/repository";
45import { db } from "../db";
46import { eq } from "drizzle-orm";
47import {
48 pullRequests,
49 repositories,
50 users,
51} from "../db/schema";
52
53const HAS_DB = Boolean(process.env.DATABASE_URL);
54
55const TEST_REPOS = join(
56 import.meta.dir,
57 "../../.test-repos-ai-patch-" + Date.now()
58);
59
60beforeAll(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
67afterAll(async () => {
68 await rm(TEST_REPOS, { recursive: true, force: true });
69});
70
71// ---------------------------------------------------------------------------
72// Pure helpers
73// ---------------------------------------------------------------------------
74
75describe("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
107describe("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
123describe("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
139describe("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
162describe("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 */
205function 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
217const OWNER = "ai-patch-test-" + Date.now().toString(36);
218const REPO = "subject";
219
220async 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
242describe("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
476describe("__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});