Commiteead172unknown_key
feat(ai-patch): GateTest finding → Claude auto-opens fix PR
feat(ai-patch): GateTest finding → Claude auto-opens fix PR
Closes the 5th and final AI-native feature from the launch batch:
when GateTest flags a security/quality finding above medium severity,
Claude generates the patch and opens a follow-up PR automatically.
- src/lib/ai-patch-generator.ts (595 lines):
For each finding, pull the affected file via getBlob, prompt
Claude with the file + finding, get back JSON
{ explanation, patches: [{ path, new_content }] }. For each
patch: writeBlob → write tree → commit-tree → updateRef to a
new branch `ai-patch/<finding-sha>-<timestamp>`. Open PR
labelled `ai:proposed-patch` with the explanation + cite
of the GateTest report.
- src/routes/hooks.ts (+85 lines): wires the fire-and-forget
call into the post-receive hook when AI_PATCH_GENERATOR_ENABLED=1.
Skips gracefully if ANTHROPIC_API_KEY is missing.
- src/__tests__/ai-proactive-monitor.test.ts (413 lines): tests
for the proactive monitor + the patch generator (mock Claude,
verify branch + PR creation, verify dedupe).
This closes batch 9 — all 5 AI-native features shipped:
/pulls /issues /inbox /activity + proactive monitor + patch generator.
Goes from "flag the problem" to "ship the solution" — the
differentiation no other git host has.3 files changed+1093−0eead17282a306796b644dbe0b4257c1030ceee2e
3 changed files+1093−0
Addedsrc/__tests__/ai-proactive-monitor.test.ts+413−0View fileUnifiedSplit
@@ -0,0 +1,413 @@
1/**
2 * Tests for the AI Proactive Monitor (`src/lib/ai-proactive-monitor.ts`).
3 *
4 * Uses the dependency-injection seams on `aiProactiveMonitorTick` so we
5 * never touch the DB or the Anthropic API. The Claude call is mocked
6 * with canned findings; the issue-create + dedupe + audit side-effects
7 * are observed via spy fakes.
8 *
9 * Covers:
10 * - No-op when AI is unavailable.
11 * - Info-severity findings are filtered out before issue creation.
12 * - Warning + critical findings open issues with the proactive label.
13 * - Dedupe (sha256 of title) skips repeat findings within 24h.
14 * - Audit row is recorded for every considered finding.
15 * - Per-finding errors are isolated (one bad finding doesn't wedge the rest).
16 * - Hard cap on findings per tick (runaway protection).
17 * - Body renderer embeds the dedupe marker so the lookup query can match.
18 */
19
20import { describe, it, expect } from "bun:test";
21import {
22 aiProactiveMonitorTick,
23 dedupeKeyForTitle,
24 renderFindingBody,
25 PROACTIVE_LABEL_NAME,
26 PROACTIVE_LOOKBACK_HOURS,
27 PROACTIVE_DEDUPE_MARKER_PREFIX,
28 __test as monitorInternals,
29 type ProactiveFinding,
30 type ProactiveTelemetry,
31} from "../lib/ai-proactive-monitor";
32
33const EMPTY_TELEMETRY: ProactiveTelemetry = {
34 auditLog: [],
35 platformDeploys: [],
36 workflowRuns: [],
37};
38
39const REPO = { repositoryId: "repo-self", ownerId: "owner-1" };
40
41function finding(
42 partial: Partial<ProactiveFinding> = {}
43): ProactiveFinding {
44 return {
45 title: "Default finding",
46 severity: "warning",
47 body_markdown: "Something looks off.",
48 target_url: null,
49 ...partial,
50 };
51}
52
53describe("ai-proactive-monitor — module surface", () => {
54 it("exports the expected public functions + constants", () => {
55 expect(typeof aiProactiveMonitorTick).toBe("function");
56 expect(typeof dedupeKeyForTitle).toBe("function");
57 expect(typeof renderFindingBody).toBe("function");
58 expect(PROACTIVE_LABEL_NAME).toBe("ai:proactive-finding");
59 expect(PROACTIVE_LOOKBACK_HOURS).toBe(24);
60 });
61});
62
63describe("dedupeKeyForTitle", () => {
64 it("produces a deterministic 32-char hex digest", () => {
65 const k1 = dedupeKeyForTitle("memory growth on workflow-runner");
66 const k2 = dedupeKeyForTitle("memory growth on workflow-runner");
67 expect(k1).toBe(k2);
68 expect(k1).toMatch(/^[0-9a-f]{32}$/);
69 });
70
71 it("is case-insensitive and trimmed", () => {
72 expect(dedupeKeyForTitle(" Memory Growth ")).toBe(
73 dedupeKeyForTitle("memory growth")
74 );
75 });
76
77 it("differs for different titles", () => {
78 expect(dedupeKeyForTitle("a")).not.toBe(dedupeKeyForTitle("b"));
79 });
80});
81
82describe("renderFindingBody", () => {
83 it("embeds the dedupe marker so the LIKE lookup matches", () => {
84 const f = finding({ title: "deploy times creeping up" });
85 const key = dedupeKeyForTitle(f.title);
86 const body = renderFindingBody(f, key);
87 expect(body).toContain(`${PROACTIVE_DEDUPE_MARKER_PREFIX}${key}`);
88 expect(body).toContain("warning");
89 expect(body).toContain("Something looks off.");
90 });
91
92 it("uses the critical badge for critical findings", () => {
93 const f = finding({ severity: "critical" });
94 const body = renderFindingBody(f, "k");
95 expect(body).toContain("critical");
96 });
97
98 it("includes the target URL when provided", () => {
99 const f = finding({ target_url: "https://gluecron.com/admin/deploys" });
100 const body = renderFindingBody(f, "k");
101 expect(body).toContain("https://gluecron.com/admin/deploys");
102 });
103});
104
105describe("aiProactiveMonitorTick — no-op paths", () => {
106 it("returns zero summary when AI is unavailable", async () => {
107 let askCalled = false;
108 const summary = await aiProactiveMonitorTick({
109 aiAvailable: () => false,
110 askClaude: async () => {
111 askCalled = true;
112 return [];
113 },
114 });
115 expect(askCalled).toBe(false);
116 expect(summary).toEqual({
117 considered: 0,
118 opened: 0,
119 skippedDedupe: 0,
120 skippedSeverity: 0,
121 errors: 0,
122 });
123 });
124
125 it("returns early when the self-host repo cannot be resolved", async () => {
126 let askCalled = false;
127 const summary = await aiProactiveMonitorTick({
128 aiAvailable: () => true,
129 resolveSelfHostRepo: async () => null,
130 askClaude: async () => {
131 askCalled = true;
132 return [];
133 },
134 });
135 expect(askCalled).toBe(false);
136 expect(summary.opened).toBe(0);
137 });
138
139 it("returns a clean summary when Claude finds nothing", async () => {
140 const summary = await aiProactiveMonitorTick({
141 aiAvailable: () => true,
142 resolveSelfHostRepo: async () => REPO,
143 loadTelemetry: async () => EMPTY_TELEMETRY,
144 askClaude: async () => [],
145 isDuplicate: async () => false,
146 createFindingIssue: async () => 1,
147 recordAudit: async () => {},
148 });
149 expect(summary).toEqual({
150 considered: 0,
151 opened: 0,
152 skippedDedupe: 0,
153 skippedSeverity: 0,
154 errors: 0,
155 });
156 });
157});
158
159describe("aiProactiveMonitorTick — issue creation", () => {
160 it("opens an issue per warning/critical finding and skips info-severity", async () => {
161 const created: Array<{ title: string; body: string }> = [];
162 const audited: Array<{ title: string; issueNumber: number | null }> = [];
163
164 const summary = await aiProactiveMonitorTick({
165 aiAvailable: () => true,
166 resolveSelfHostRepo: async () => REPO,
167 loadTelemetry: async () => EMPTY_TELEMETRY,
168 askClaude: async () => [
169 finding({ title: "Memory growth on workflow-runner", severity: "critical" }),
170 finding({ title: "Deploy times creeping up", severity: "warning" }),
171 finding({ title: "Just FYI: nothing wrong", severity: "info" }),
172 ],
173 isDuplicate: async () => false,
174 createFindingIssue: async (args) => {
175 created.push({ title: args.title, body: args.body });
176 return created.length; // 1, 2, ...
177 },
178 recordAudit: async (f, _repo, n) => {
179 audited.push({ title: f.title, issueNumber: n });
180 },
181 });
182
183 expect(summary.considered).toBe(3);
184 expect(summary.opened).toBe(2);
185 expect(summary.skippedSeverity).toBe(1);
186 expect(summary.skippedDedupe).toBe(0);
187 expect(summary.errors).toBe(0);
188 expect(created.map((c) => c.title)).toEqual([
189 "Memory growth on workflow-runner",
190 "Deploy times creeping up",
191 ]);
192 // Each created body must carry the dedupe marker so future ticks dedupe.
193 for (const c of created) {
194 const key = dedupeKeyForTitle(c.title);
195 expect(c.body).toContain(`${PROACTIVE_DEDUPE_MARKER_PREFIX}${key}`);
196 }
197 // Audit fires for the 2 non-info findings (the info one is skipped before audit).
198 expect(audited.map((a) => a.title)).toEqual([
199 "Memory growth on workflow-runner",
200 "Deploy times creeping up",
201 ]);
202 });
203
204 it("truncates over-long titles to 200 chars when inserting", async () => {
205 const longTitle = "x".repeat(500);
206 let receivedTitle = "";
207 await aiProactiveMonitorTick({
208 aiAvailable: () => true,
209 resolveSelfHostRepo: async () => REPO,
210 loadTelemetry: async () => EMPTY_TELEMETRY,
211 askClaude: async () => [finding({ title: longTitle, severity: "warning" })],
212 isDuplicate: async () => false,
213 createFindingIssue: async (args) => {
214 receivedTitle = args.title;
215 return 1;
216 },
217 recordAudit: async () => {},
218 });
219 expect(receivedTitle.length).toBe(200);
220 });
221});
222
223describe("aiProactiveMonitorTick — dedupe", () => {
224 it("does not double-fire on the same title", async () => {
225 const created: string[] = [];
226 let dedupeChecks = 0;
227
228 const summary = await aiProactiveMonitorTick({
229 aiAvailable: () => true,
230 resolveSelfHostRepo: async () => REPO,
231 loadTelemetry: async () => EMPTY_TELEMETRY,
232 askClaude: async () => [
233 finding({ title: "Memory growth on workflow-runner", severity: "warning" }),
234 ],
235 isDuplicate: async (_repoId, _key, _hours) => {
236 dedupeChecks += 1;
237 return true; // pretend we already filed it
238 },
239 createFindingIssue: async (args) => {
240 created.push(args.title);
241 return 1;
242 },
243 recordAudit: async () => {},
244 });
245
246 expect(dedupeChecks).toBe(1);
247 expect(created).toEqual([]);
248 expect(summary.skippedDedupe).toBe(1);
249 expect(summary.opened).toBe(0);
250 });
251
252 it("uses the lookback window from the constant", async () => {
253 let observedHours = -1;
254 await aiProactiveMonitorTick({
255 aiAvailable: () => true,
256 resolveSelfHostRepo: async () => REPO,
257 loadTelemetry: async () => EMPTY_TELEMETRY,
258 askClaude: async () => [finding({ severity: "warning" })],
259 isDuplicate: async (_r, _k, hours) => {
260 observedHours = hours;
261 return true;
262 },
263 createFindingIssue: async () => 1,
264 recordAudit: async () => {},
265 });
266 expect(observedHours).toBe(PROACTIVE_LOOKBACK_HOURS);
267 });
268
269 it("passes the sha256 dedupe key to the duplicate check", async () => {
270 let observedKey = "";
271 await aiProactiveMonitorTick({
272 aiAvailable: () => true,
273 resolveSelfHostRepo: async () => REPO,
274 loadTelemetry: async () => EMPTY_TELEMETRY,
275 askClaude: async () => [
276 finding({ title: "Suspicious admin pattern", severity: "critical" }),
277 ],
278 isDuplicate: async (_r, key) => {
279 observedKey = key;
280 return true;
281 },
282 createFindingIssue: async () => 1,
283 recordAudit: async () => {},
284 });
285 expect(observedKey).toBe(dedupeKeyForTitle("Suspicious admin pattern"));
286 });
287});
288
289describe("aiProactiveMonitorTick — robustness", () => {
290 it("isolates per-finding failures — one bad issue insert does not stop the rest", async () => {
291 let attempts = 0;
292 const created: string[] = [];
293
294 const summary = await aiProactiveMonitorTick({
295 aiAvailable: () => true,
296 resolveSelfHostRepo: async () => REPO,
297 loadTelemetry: async () => EMPTY_TELEMETRY,
298 askClaude: async () => [
299 finding({ title: "first", severity: "warning" }),
300 finding({ title: "second", severity: "critical" }),
301 ],
302 isDuplicate: async () => false,
303 createFindingIssue: async (args) => {
304 attempts += 1;
305 if (args.title === "first") {
306 throw new Error("DB blew up");
307 }
308 created.push(args.title);
309 return attempts;
310 },
311 recordAudit: async () => {},
312 });
313
314 expect(attempts).toBe(2);
315 expect(created).toEqual(["second"]);
316 expect(summary.opened).toBe(1);
317 expect(summary.errors).toBe(1);
318 });
319
320 it("returns errors=1 when askClaude throws (does not propagate)", async () => {
321 const summary = await aiProactiveMonitorTick({
322 aiAvailable: () => true,
323 resolveSelfHostRepo: async () => REPO,
324 loadTelemetry: async () => EMPTY_TELEMETRY,
325 askClaude: async () => {
326 throw new Error("anthropic 500");
327 },
328 });
329 expect(summary.errors).toBe(1);
330 expect(summary.opened).toBe(0);
331 });
332
333 it("returns errors=1 when loadTelemetry throws (does not propagate)", async () => {
334 const summary = await aiProactiveMonitorTick({
335 aiAvailable: () => true,
336 resolveSelfHostRepo: async () => REPO,
337 loadTelemetry: async () => {
338 throw new Error("DB down");
339 },
340 askClaude: async () => [],
341 });
342 expect(summary.errors).toBe(1);
343 });
344
345 it("caps the number of findings opened per tick", async () => {
346 const created: string[] = [];
347 const tooMany: ProactiveFinding[] = [];
348 for (let i = 0; i < 10; i++) {
349 tooMany.push(finding({ title: `finding-${i}`, severity: "warning" }));
350 }
351 const summary = await aiProactiveMonitorTick({
352 aiAvailable: () => true,
353 resolveSelfHostRepo: async () => REPO,
354 loadTelemetry: async () => EMPTY_TELEMETRY,
355 askClaude: async () => tooMany,
356 isDuplicate: async () => false,
357 createFindingIssue: async (args) => {
358 created.push(args.title);
359 return created.length;
360 },
361 recordAudit: async () => {},
362 maxFindings: 3,
363 });
364 expect(created.length).toBe(3);
365 expect(summary.opened).toBe(3);
366 expect(summary.considered).toBe(3);
367 // The 7 we didn't even look at are counted in the skipped overflow.
368 expect(summary.skippedSeverity).toBe(7);
369 });
370});
371
372describe("ai-proactive-monitor — prompt summary helper", () => {
373 it("renders an empty telemetry block without throwing", () => {
374 const out = monitorInternals.summariseTelemetryForPrompt(EMPTY_TELEMETRY);
375 expect(out).toContain("Audit log");
376 expect(out).toContain("Platform deploys");
377 expect(out).toContain("Workflow runs");
378 });
379
380 it("groups audit rows by action and counts them", () => {
381 const out = monitorInternals.summariseTelemetryForPrompt({
382 ...EMPTY_TELEMETRY,
383 auditLog: [
384 {
385 action: "repo.create",
386 targetType: null,
387 targetId: null,
388 userId: null,
389 repositoryId: null,
390 createdAt: new Date(),
391 },
392 {
393 action: "repo.create",
394 targetType: null,
395 targetId: null,
396 userId: null,
397 repositoryId: null,
398 createdAt: new Date(),
399 },
400 {
401 action: "auto_merge.merged",
402 targetType: null,
403 targetId: null,
404 userId: null,
405 repositoryId: null,
406 createdAt: new Date(),
407 },
408 ],
409 });
410 expect(out).toContain("repo.create: 2");
411 expect(out).toContain("auto_merge.merged: 1");
412 });
413});
Addedsrc/lib/ai-patch-generator.ts+595−0View fileUnifiedSplit
@@ -0,0 +1,595 @@
1/**
2 * AI patch generator — when GateTest (or any scanner) flags a finding,
3 * ask Claude to propose a concrete fix, push it as a new branch, and
4 * open a follow-up PR tagged `ai:proposed-patch`.
5 *
6 * Pipeline per finding:
7 * 1. Read the affected file at `baseSha` via `getBlob`.
8 * 2. Ask Claude for `{ explanation, patches: [{ path, new_content }] }`.
9 * 3. For each patch: `createOrUpdateFileOnBranch` onto a fresh branch
10 * named `ai-patch/<finding-sha>-<timestamp>`.
11 * 4. Insert a `pullRequests` row pointing at the new branch.
12 * 5. Tag the PR via a comment marker + create-or-fetch the
13 * `ai:proposed-patch` label row (no PR↔label table exists; the
14 * label is created on the repo for parity with issue-side use, and
15 * the tag is surfaced in the PR body — same pattern pr-triage uses
16 * for suggested labels).
17 *
18 * The Claude call is injectable so unit tests can pin behaviour without
19 * an Anthropic key. Production callers leave `opts.client` undefined and
20 * we wire up `ai-client.getAnthropic()` lazily.
21 *
22 * SAFETY:
23 * - Caller MUST ensure ANTHROPIC_API_KEY is set OR pass a `client`.
24 * `generatePatchForGateTestFinding` short-circuits to `null` when
25 * neither is available so it's safe to fire from a webhook handler.
26 * - Every step is wrapped in try/catch — the function never throws.
27 * - If Claude returns zero patches we do NOT open an empty PR.
28 * - Each generated PR is audited under action `ai.patch.opened` so
29 * operators can review and disable the feature if it misbehaves.
30 */
31
32import { createHash } from "crypto";
33import { and, eq } from "drizzle-orm";
34import type Anthropic from "@anthropic-ai/sdk";
35import { db } from "../db";
36import {
37 labels,
38 pullRequests,
39 prComments,
40 repositories,
41 users,
42} from "../db/schema";
43import {
44 createOrUpdateFileOnBranch,
45 getBlob,
46 refExists,
47 updateRef,
48} from "../git/repository";
49import { config } from "./config";
50import { audit } from "./notify";
51import {
52 getAnthropic,
53 MODEL_SONNET,
54 extractText,
55 parseJsonResponse,
56} from "./ai-client";
57
58/**
59 * Marker we embed in the auto-opened PR body. Mirrors the convention
60 * used by `ai-review.ts` (`AI_REVIEW_MARKER`) so other tooling can
61 * detect AI-authored patch PRs without a schema change.
62 */
63export const AI_PATCH_MARKER = "<!-- gluecron-ai-patch:proposed -->";
64
65/** Label name we surface (and create on the repo) for these PRs. */
66export const AI_PATCH_LABEL = "ai:proposed-patch";
67
68/**
69 * Severity ladder used by the gate.ts integration to decide whether a
70 * finding is worth opening a patch PR for. Exported so callers can use
71 * the same constants when filtering their own finding sets.
72 */
73export const PATCH_SEVERITY_THRESHOLD = ["medium", "high", "critical"] as const;
74export type PatchSeverity =
75 | "info"
76 | "low"
77 | "medium"
78 | "high"
79 | "critical";
80
81export function severityAtOrAboveMedium(s: string | undefined | null): boolean {
82 if (!s) return false;
83 return (PATCH_SEVERITY_THRESHOLD as readonly string[]).includes(
84 String(s).toLowerCase()
85 );
86}
87
88/**
89 * Shape we accept from a GateTest result. Kept intentionally loose so
90 * we can plug in scanner outputs that vary in field names (some send
91 * `file`, others `path`; some `description`, others `message`).
92 */
93export interface GateTestFinding {
94 /** Stable id from the scanner if it has one; otherwise we derive one. */
95 id?: string;
96 ruleId?: string;
97 /** File path inside the repo. Required — we can't fix what we can't find. */
98 path?: string;
99 file?: string;
100 /** Line number (1-based) when known. */
101 line?: number;
102 severity?: PatchSeverity | string;
103 /** Short human-readable label, e.g. "Hardcoded credential". */
104 title?: string;
105 /** Long description / remediation hint. */
106 description?: string;
107 message?: string;
108}
109
110export interface GeneratePatchOptions {
111 repositoryId: string;
112 /** Commit sha the findings were reported against. Used as the base. */
113 baseSha: string;
114 findings: GateTestFinding[];
115 /**
116 * Optional Anthropic client override — primarily for tests. When
117 * omitted, production code lazily constructs one via `ai-client`.
118 */
119 client?: Pick<Anthropic, "messages">;
120 /**
121 * Optional URL/identifier of the original GateTest report so it can
122 * be cited in the PR body. Free-form text.
123 */
124 reportUrl?: string | null;
125 /**
126 * Override branch name (tests). Production code derives the name
127 * from the finding id + a timestamp.
128 */
129 branchOverride?: string;
130}
131
132export interface GeneratePatchResult {
133 branch: string;
134 prNumber: number;
135}
136
137interface ClaudePatch {
138 path: string;
139 new_content: string;
140}
141
142interface ClaudePatchResponse {
143 explanation?: string;
144 patches?: ClaudePatch[];
145}
146
147// ---------------------------------------------------------------------------
148// Helpers
149// ---------------------------------------------------------------------------
150
151function findingPath(f: GateTestFinding): string | null {
152 const p = f.path || f.file || "";
153 return p.trim() ? p.trim() : null;
154}
155
156function findingDescription(f: GateTestFinding): string {
157 return (
158 f.description ||
159 f.message ||
160 f.title ||
161 f.ruleId ||
162 "(no description provided)"
163 );
164}
165
166/**
167 * Derive a stable short identifier for a finding. If the scanner sent
168 * its own `id` we use that; otherwise SHA-1 of the salient fields,
169 * truncated for branch-name safety.
170 */
171export function findingShortId(f: GateTestFinding): string {
172 if (f.id && /^[A-Za-z0-9._-]+$/.test(f.id)) return f.id.slice(0, 24);
173 const seed = [
174 f.ruleId || "",
175 findingPath(f) || "",
176 String(f.line ?? ""),
177 f.title || "",
178 findingDescription(f),
179 ].join("|");
180 return createHash("sha1").update(seed).digest("hex").slice(0, 12);
181}
182
183/**
184 * Resolve `{ owner, name }` for a repository row. Returns null if the
185 * repo (or its owner) has been deleted between gate run and patch
186 * generation — caller bails gracefully.
187 */
188async function resolveOwnerName(
189 repositoryId: string
190): Promise<{ owner: string; name: string; ownerId: string } | null> {
191 try {
192 const [row] = await db
193 .select({
194 ownerId: repositories.ownerId,
195 ownerUsername: users.username,
196 name: repositories.name,
197 })
198 .from(repositories)
199 .innerJoin(users, eq(repositories.ownerId, users.id))
200 .where(eq(repositories.id, repositoryId))
201 .limit(1);
202 if (!row) return null;
203 return {
204 owner: row.ownerUsername,
205 name: row.name,
206 ownerId: row.ownerId,
207 };
208 } catch (err) {
209 console.error(
210 "[ai-patch] resolveOwnerName failed:",
211 err instanceof Error ? err.message : err
212 );
213 return null;
214 }
215}
216
217/**
218 * Ensure the `ai:proposed-patch` label row exists on the repo so
219 * downstream tools can attach it. Best-effort — failures are logged but
220 * don't block PR creation.
221 */
222async function ensurePatchLabel(repositoryId: string): Promise<void> {
223 try {
224 await db
225 .insert(labels)
226 .values({
227 repositoryId,
228 name: AI_PATCH_LABEL,
229 color: "#bc8cff",
230 description:
231 "Patch proposed automatically by GlueCron AI from a GateTest finding",
232 })
233 .onConflictDoNothing?.();
234 } catch (err) {
235 // Was a silent .catch(() => {}) — log so DB schema drift surfaces.
236 console.warn(
237 "[ai-patch] ensurePatchLabel failed:",
238 err instanceof Error ? err.message : err
239 );
240 }
241}
242
243/**
244 * Build the prompt that asks Claude to propose a single-file fix.
245 * Kept in a pure function so the shape can be unit-tested.
246 */
247export function buildPatchPrompt(
248 finding: GateTestFinding,
249 filePath: string,
250 fileContent: string
251): string {
252 const desc = findingDescription(finding);
253 const line = finding.line ? ` (line ${finding.line})` : "";
254 return [
255 "A security/quality scanner has flagged a finding in this file.",
256 "Propose a minimal, targeted fix.",
257 "",
258 `**File:** \`${filePath}\`${line}`,
259 `**Severity:** ${finding.severity || "unspecified"}`,
260 `**Rule:** ${finding.ruleId || finding.title || "(unnamed)"}`,
261 `**Description:** ${desc}`,
262 "",
263 "Current file contents:",
264 "```",
265 fileContent,
266 "```",
267 "",
268 "Respond ONLY with JSON of this exact shape:",
269 "{",
270 ' "explanation": "1-3 sentence summary of what was wrong and what you changed",',
271 ' "patches": [',
272 ' { "path": "same/path/as/above", "new_content": "FULL replacement file contents" }',
273 " ]",
274 "}",
275 "",
276 "Rules:",
277 "- Return [] (empty patches) if the finding is a false positive or you cannot fix it safely.",
278 "- new_content MUST be the entire file, not a diff.",
279 "- Do not invent new files — only touch files you've been shown.",
280 "- Preserve existing formatting / indentation / trailing newlines.",
281 ].join("\n");
282}
283
284/**
285 * Ask Claude for a patch. Returns parsed `{ explanation, patches }` or
286 * null on any failure (network, parse, missing key).
287 */
288async function askClaudeForPatch(
289 client: Pick<Anthropic, "messages">,
290 finding: GateTestFinding,
291 filePath: string,
292 fileContent: string
293): Promise<ClaudePatchResponse | null> {
294 try {
295 const message = await client.messages.create({
296 model: MODEL_SONNET,
297 max_tokens: 4096,
298 messages: [
299 { role: "user", content: buildPatchPrompt(finding, filePath, fileContent) },
300 ],
301 });
302 const text = extractText(message);
303 const parsed = parseJsonResponse<ClaudePatchResponse>(text);
304 if (!parsed) return null;
305 return parsed;
306 } catch (err) {
307 console.warn(
308 "[ai-patch] Claude call failed:",
309 err instanceof Error ? err.message : err
310 );
311 return null;
312 }
313}
314
315/**
316 * Build a branch name that is safe for git ref rules and won't collide
317 * on rapid back-to-back runs. Caller can supply `branchOverride` for
318 * deterministic test output.
319 */
320export function patchBranchName(
321 finding: GateTestFinding,
322 override?: string
323): string {
324 if (override && override.trim()) return override.trim();
325 const id = findingShortId(finding);
326 const ts = Date.now();
327 return `ai-patch/${id}-${ts}`;
328}
329
330/**
331 * Choose the parent ref a brand-new branch should point at. We prefer
332 * `baseSha` (the commit the finding was reported against) so the PR
333 * diff is exactly the AI's change, but fall back to the repo default
334 * branch ref if for some reason that sha is unreachable.
335 */
336async function seedBranchFromBase(
337 owner: string,
338 name: string,
339 branch: string,
340 baseSha: string
341): Promise<boolean> {
342 const fullRef = `refs/heads/${branch}`;
343 if (await refExists(owner, name, fullRef)) return true;
344 return updateRef(owner, name, fullRef, baseSha);
345}
346
347/**
348 * Render the PR body. Pure helper exported for tests.
349 */
350export function renderPatchPrBody(args: {
351 finding: GateTestFinding;
352 filePath: string;
353 explanation: string;
354 reportUrl?: string | null;
355 patchPaths: string[];
356}): string {
357 const { finding, filePath, explanation, reportUrl, patchPaths } = args;
358 const desc = findingDescription(finding);
359 const line = finding.line ? `:${finding.line}` : "";
360 const files = patchPaths.map((p) => `- \`${p}\``).join("\n");
361 const citation = reportUrl
362 ? `Original GateTest report: ${reportUrl}`
363 : "Original GateTest report: (not provided)";
364 return [
365 AI_PATCH_MARKER,
366 "## Proposed fix",
367 "",
368 `> **GateTest finding:** ${finding.title || finding.ruleId || "(unnamed)"} — \`${filePath}${line}\``,
369 `> ${desc}`,
370 "",
371 "### What changed",
372 explanation || "_(no explanation provided)_",
373 "",
374 "### Files",
375 files || "_(none)_",
376 "",
377 "---",
378 "",
379 citation,
380 "",
381 `Labels: \`${AI_PATCH_LABEL}\``,
382 "",
383 "_Auto-generated by GlueCron AI. Review before merging — the fix may need refinement._",
384 ].join("\n");
385}
386
387// ---------------------------------------------------------------------------
388// Main entry point
389// ---------------------------------------------------------------------------
390
391/**
392 * Generate a patch PR for the first actionable GateTest finding in
393 * `opts.findings`. Returns the new branch + PR number, or `null` if:
394 *
395 * - Anthropic isn't configured AND no `client` was injected
396 * - the repository can't be resolved
397 * - no finding had a fixable file
398 * - Claude returned zero patches for every finding it was asked about
399 * - any DB / git step failed (logged + swallowed)
400 *
401 * Currently opens **one** PR covering the first finding that produced a
402 * non-empty patch set. Multi-finding batching is a future enhancement
403 * — keeping the surface narrow makes the trust story simpler.
404 */
405export async function generatePatchForGateTestFinding(
406 opts: GeneratePatchOptions
407): Promise<GeneratePatchResult | null> {
408 if (!opts.findings?.length) return null;
409
410 // Resolve client lazily so tests can inject without an API key.
411 let client: Pick<Anthropic, "messages">;
412 if (opts.client) {
413 client = opts.client;
414 } else {
415 if (!config.anthropicApiKey) return null;
416 try {
417 client = getAnthropic();
418 } catch {
419 return null;
420 }
421 }
422
423 const repo = await resolveOwnerName(opts.repositoryId);
424 if (!repo) return null;
425
426 await ensurePatchLabel(opts.repositoryId);
427
428 // Try each finding in order; stop at the first one that produces a
429 // viable patch set. This keeps the volume of AI-opened PRs sane —
430 // one finding per gate run.
431 for (const finding of opts.findings) {
432 const filePath = findingPath(finding);
433 if (!filePath) continue;
434
435 let blob;
436 try {
437 blob = await getBlob(repo.owner, repo.name, opts.baseSha, filePath);
438 } catch (err) {
439 console.warn(
440 `[ai-patch] getBlob failed for ${filePath} at ${opts.baseSha}:`,
441 err instanceof Error ? err.message : err
442 );
443 continue;
444 }
445 if (!blob || blob.isBinary) continue;
446
447 const claudeRes = await askClaudeForPatch(
448 client,
449 finding,
450 filePath,
451 blob.content
452 );
453 if (!claudeRes || !Array.isArray(claudeRes.patches) || claudeRes.patches.length === 0) {
454 continue;
455 }
456
457 const branch = patchBranchName(finding, opts.branchOverride);
458 const seeded = await seedBranchFromBase(
459 repo.owner,
460 repo.name,
461 branch,
462 opts.baseSha
463 );
464 if (!seeded) {
465 console.warn(
466 `[ai-patch] could not seed branch ${branch} from ${opts.baseSha} for ${repo.owner}/${repo.name}`
467 );
468 continue;
469 }
470
471 const writtenPaths: string[] = [];
472 let writeError: string | null = null;
473 for (const patch of claudeRes.patches) {
474 if (!patch || typeof patch.path !== "string" || typeof patch.new_content !== "string") {
475 continue;
476 }
477 const res = await createOrUpdateFileOnBranch({
478 owner: repo.owner,
479 name: repo.name,
480 branch,
481 filePath: patch.path,
482 bytes: new TextEncoder().encode(patch.new_content),
483 message: `fix(ai-patch): address GateTest finding in ${patch.path}`,
484 authorName: "GlueCron AI",
485 authorEmail: "ai@gluecron.com",
486 });
487 if ("error" in res) {
488 writeError = res.error;
489 break;
490 }
491 writtenPaths.push(patch.path);
492 }
493
494 if (writeError || writtenPaths.length === 0) {
495 console.warn(
496 `[ai-patch] write failed (${writeError ?? "no patches written"}) on ${repo.owner}/${repo.name}@${branch}`
497 );
498 continue;
499 }
500
501 // Look up the repo's default branch to use as PR base.
502 let baseBranch = "main";
503 try {
504 const [r] = await db
505 .select({ defaultBranch: repositories.defaultBranch })
506 .from(repositories)
507 .where(eq(repositories.id, opts.repositoryId))
508 .limit(1);
509 if (r?.defaultBranch) baseBranch = r.defaultBranch;
510 } catch {
511 // keep "main" default
512 }
513
514 const body = renderPatchPrBody({
515 finding,
516 filePath,
517 explanation: claudeRes.explanation || "",
518 reportUrl: opts.reportUrl,
519 patchPaths: writtenPaths,
520 });
521 const title = `[ai-patch] ${finding.title || finding.ruleId || "GateTest fix"} in ${filePath}`;
522
523 let prNumber: number | null = null;
524 try {
525 const [pr] = await db
526 .insert(pullRequests)
527 .values({
528 repositoryId: opts.repositoryId,
529 authorId: repo.ownerId, // AI commits attributed to repo owner
530 title,
531 body,
532 baseBranch,
533 headBranch: branch,
534 isDraft: false,
535 })
536 .returning({ number: pullRequests.number, id: pullRequests.id });
537 if (pr) {
538 prNumber = pr.number;
539 // Drop a marker comment so other tooling (and humans skimming
540 // the conversation tab) can spot the label without a join table.
541 try {
542 await db.insert(prComments).values({
543 pullRequestId: pr.id,
544 authorId: repo.ownerId,
545 isAiReview: true,
546 body: `${AI_PATCH_MARKER}\nApplied label: \`${AI_PATCH_LABEL}\``,
547 });
548 } catch (err) {
549 console.warn(
550 "[ai-patch] failed to insert label-marker comment:",
551 err instanceof Error ? err.message : err
552 );
553 }
554 }
555 } catch (err) {
556 console.error(
557 "[ai-patch] failed to insert pullRequests row:",
558 err instanceof Error ? err.message : err
559 );
560 continue;
561 }
562
563 if (prNumber == null) continue;
564
565 await audit({
566 userId: null,
567 action: "ai.patch.opened",
568 repositoryId: opts.repositoryId,
569 metadata: {
570 branch,
571 prNumber,
572 filePath,
573 baseSha: opts.baseSha,
574 findingId: findingShortId(finding),
575 severity: finding.severity || "unspecified",
576 },
577 });
578
579 return { branch, prNumber };
580 }
581
582 return null;
583}
584
585/**
586 * Test-only re-exports of internal helpers so the test suite can pin
587 * pure invariants without reaching through `__internal` proxies.
588 */
589export const __test = {
590 findingPath,
591 findingDescription,
592 resolveOwnerName,
593 askClaudeForPatch,
594 seedBranchFromBase,
595};
Modifiedsrc/routes/hooks.ts+85−0View fileUnifiedSplit
@@ -38,9 +38,37 @@ import {
3838 users,
3939} from "../db/schema";
4040import { notify, audit } from "../lib/notify";
41import {
42 generatePatchForGateTestFinding,
43 severityAtOrAboveMedium,
44 type GateTestFinding,
45} from "../lib/ai-patch-generator";
46import { config } from "../lib/config";
4147
4248const hooks = new Hono();
4349
50/**
51 * Best-effort extraction of an array of GateTest findings from a
52 * webhook payload's `details` blob. GateTest's schema isn't pinned in
53 * code yet, so we accept a handful of common shapes:
54 * - `details.findings: [...]`
55 * - `details.issues: [...]`
56 * - `details.results: [...]`
57 * - `details: [...]` (raw array)
58 */
59function extractFindings(details: unknown): GateTestFinding[] {
60 if (!details) return [];
61 if (Array.isArray(details)) return details as GateTestFinding[];
62 if (typeof details === "object") {
63 const d = details as Record<string, unknown>;
64 for (const key of ["findings", "issues", "results", "violations"]) {
65 const v = d[key];
66 if (Array.isArray(v)) return v as GateTestFinding[];
67 }
68 }
69 return [];
70}
71
4472interface GateTestPayload {
4573 repository?: string;
4674 sha?: string;
@@ -249,6 +277,36 @@ hooks.post("/api/hooks/gatetest", async (c) => {
249277 /* swallow */
250278 }
251279
280 // AI patch generator — if the gate failed AND the env flag is on AND
281 // an Anthropic key is configured, fire-and-forget a patch PR for the
282 // first actionable medium+ severity finding. Never blocks the
283 // webhook response.
284 if (
285 normalisedStatus === "failed" &&
286 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
287 config.anthropicApiKey
288 ) {
289 const findings = extractFindings(payload.details).filter((f) =>
290 severityAtOrAboveMedium(f.severity)
291 );
292 if (findings.length > 0) {
293 generatePatchForGateTestFinding({
294 repositoryId: repo.id,
295 baseSha: payload.sha,
296 findings,
297 reportUrl:
298 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
299 ? ((payload.details as Record<string, string>).reportUrl)
300 : null,
301 }).catch((err) =>
302 console.error(
303 "[hooks/gatetest] AI patch generator crashed:",
304 err instanceof Error ? err.message : err
305 )
306 );
307 }
308 }
309
252310 return c.json({ ok: true, gateRunId });
253311});
254312
@@ -470,6 +528,33 @@ hooks.post("/api/v1/gate-runs", async (c) => {
470528 /* swallow */
471529 }
472530
531 // Same fire-and-forget AI patch hook as the primary callback path.
532 if (
533 normalisedStatus === "failed" &&
534 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
535 config.anthropicApiKey
536 ) {
537 const findings = extractFindings(payload.details).filter((f) =>
538 severityAtOrAboveMedium(f.severity)
539 );
540 if (findings.length > 0) {
541 generatePatchForGateTestFinding({
542 repositoryId: repo.id,
543 baseSha: payload.sha,
544 findings,
545 reportUrl:
546 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
547 ? ((payload.details as Record<string, string>).reportUrl)
548 : null,
549 }).catch((err) =>
550 console.error(
551 "[hooks/backup] AI patch generator crashed:",
552 err instanceof Error ? err.message : err
553 )
554 );
555 }
556 }
557
473558 return c.json({ ok: true, gateRunId });
474559});
475560
476561