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.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.tsBlame608 lines · 1 contributor
eead172Claude1/**
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 });
0c3eee5Claude302 try {
303 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
304 const usage = extractUsage(message);
305 await recordAiCost({
306 model: MODEL_SONNET,
307 inputTokens: usage.input,
308 outputTokens: usage.output,
309 category: "ai_patch",
310 sourceKind: "gatetest_finding",
311 });
312 } catch {
313 /* swallow — best-effort */
314 }
eead172Claude315 const text = extractText(message);
316 const parsed = parseJsonResponse<ClaudePatchResponse>(text);
317 if (!parsed) return null;
318 return parsed;
319 } catch (err) {
320 console.warn(
321 "[ai-patch] Claude call failed:",
322 err instanceof Error ? err.message : err
323 );
324 return null;
325 }
326}
327
328/**
329 * Build a branch name that is safe for git ref rules and won't collide
330 * on rapid back-to-back runs. Caller can supply `branchOverride` for
331 * deterministic test output.
332 */
333export function patchBranchName(
334 finding: GateTestFinding,
335 override?: string
336): string {
337 if (override && override.trim()) return override.trim();
338 const id = findingShortId(finding);
339 const ts = Date.now();
340 return `ai-patch/${id}-${ts}`;
341}
342
343/**
344 * Choose the parent ref a brand-new branch should point at. We prefer
345 * `baseSha` (the commit the finding was reported against) so the PR
346 * diff is exactly the AI's change, but fall back to the repo default
347 * branch ref if for some reason that sha is unreachable.
348 */
349async function seedBranchFromBase(
350 owner: string,
351 name: string,
352 branch: string,
353 baseSha: string
354): Promise<boolean> {
355 const fullRef = `refs/heads/${branch}`;
356 if (await refExists(owner, name, fullRef)) return true;
357 return updateRef(owner, name, fullRef, baseSha);
358}
359
360/**
361 * Render the PR body. Pure helper exported for tests.
362 */
363export function renderPatchPrBody(args: {
364 finding: GateTestFinding;
365 filePath: string;
366 explanation: string;
367 reportUrl?: string | null;
368 patchPaths: string[];
369}): string {
370 const { finding, filePath, explanation, reportUrl, patchPaths } = args;
371 const desc = findingDescription(finding);
372 const line = finding.line ? `:${finding.line}` : "";
373 const files = patchPaths.map((p) => `- \`${p}\``).join("\n");
374 const citation = reportUrl
375 ? `Original GateTest report: ${reportUrl}`
376 : "Original GateTest report: (not provided)";
377 return [
378 AI_PATCH_MARKER,
379 "## Proposed fix",
380 "",
381 `> **GateTest finding:** ${finding.title || finding.ruleId || "(unnamed)"} — \`${filePath}${line}\``,
382 `> ${desc}`,
383 "",
384 "### What changed",
385 explanation || "_(no explanation provided)_",
386 "",
387 "### Files",
388 files || "_(none)_",
389 "",
390 "---",
391 "",
392 citation,
393 "",
394 `Labels: \`${AI_PATCH_LABEL}\``,
395 "",
396 "_Auto-generated by GlueCron AI. Review before merging — the fix may need refinement._",
397 ].join("\n");
398}
399
400// ---------------------------------------------------------------------------
401// Main entry point
402// ---------------------------------------------------------------------------
403
404/**
405 * Generate a patch PR for the first actionable GateTest finding in
406 * `opts.findings`. Returns the new branch + PR number, or `null` if:
407 *
408 * - Anthropic isn't configured AND no `client` was injected
409 * - the repository can't be resolved
410 * - no finding had a fixable file
411 * - Claude returned zero patches for every finding it was asked about
412 * - any DB / git step failed (logged + swallowed)
413 *
414 * Currently opens **one** PR covering the first finding that produced a
415 * non-empty patch set. Multi-finding batching is a future enhancement
416 * — keeping the surface narrow makes the trust story simpler.
417 */
418export async function generatePatchForGateTestFinding(
419 opts: GeneratePatchOptions
420): Promise<GeneratePatchResult | null> {
421 if (!opts.findings?.length) return null;
422
423 // Resolve client lazily so tests can inject without an API key.
424 let client: Pick<Anthropic, "messages">;
425 if (opts.client) {
426 client = opts.client;
427 } else {
428 if (!config.anthropicApiKey) return null;
429 try {
430 client = getAnthropic();
431 } catch {
432 return null;
433 }
434 }
435
436 const repo = await resolveOwnerName(opts.repositoryId);
437 if (!repo) return null;
438
439 await ensurePatchLabel(opts.repositoryId);
440
441 // Try each finding in order; stop at the first one that produces a
442 // viable patch set. This keeps the volume of AI-opened PRs sane —
443 // one finding per gate run.
444 for (const finding of opts.findings) {
445 const filePath = findingPath(finding);
446 if (!filePath) continue;
447
448 let blob;
449 try {
450 blob = await getBlob(repo.owner, repo.name, opts.baseSha, filePath);
451 } catch (err) {
452 console.warn(
453 `[ai-patch] getBlob failed for ${filePath} at ${opts.baseSha}:`,
454 err instanceof Error ? err.message : err
455 );
456 continue;
457 }
458 if (!blob || blob.isBinary) continue;
459
460 const claudeRes = await askClaudeForPatch(
461 client,
462 finding,
463 filePath,
464 blob.content
465 );
466 if (!claudeRes || !Array.isArray(claudeRes.patches) || claudeRes.patches.length === 0) {
467 continue;
468 }
469
470 const branch = patchBranchName(finding, opts.branchOverride);
471 const seeded = await seedBranchFromBase(
472 repo.owner,
473 repo.name,
474 branch,
475 opts.baseSha
476 );
477 if (!seeded) {
478 console.warn(
479 `[ai-patch] could not seed branch ${branch} from ${opts.baseSha} for ${repo.owner}/${repo.name}`
480 );
481 continue;
482 }
483
484 const writtenPaths: string[] = [];
485 let writeError: string | null = null;
486 for (const patch of claudeRes.patches) {
487 if (!patch || typeof patch.path !== "string" || typeof patch.new_content !== "string") {
488 continue;
489 }
490 const res = await createOrUpdateFileOnBranch({
491 owner: repo.owner,
492 name: repo.name,
493 branch,
494 filePath: patch.path,
495 bytes: new TextEncoder().encode(patch.new_content),
496 message: `fix(ai-patch): address GateTest finding in ${patch.path}`,
497 authorName: "GlueCron AI",
498 authorEmail: "ai@gluecron.com",
499 });
500 if ("error" in res) {
501 writeError = res.error;
502 break;
503 }
504 writtenPaths.push(patch.path);
505 }
506
507 if (writeError || writtenPaths.length === 0) {
508 console.warn(
509 `[ai-patch] write failed (${writeError ?? "no patches written"}) on ${repo.owner}/${repo.name}@${branch}`
510 );
511 continue;
512 }
513
514 // Look up the repo's default branch to use as PR base.
515 let baseBranch = "main";
516 try {
517 const [r] = await db
518 .select({ defaultBranch: repositories.defaultBranch })
519 .from(repositories)
520 .where(eq(repositories.id, opts.repositoryId))
521 .limit(1);
522 if (r?.defaultBranch) baseBranch = r.defaultBranch;
523 } catch {
524 // keep "main" default
525 }
526
527 const body = renderPatchPrBody({
528 finding,
529 filePath,
530 explanation: claudeRes.explanation || "",
531 reportUrl: opts.reportUrl,
532 patchPaths: writtenPaths,
533 });
534 const title = `[ai-patch] ${finding.title || finding.ruleId || "GateTest fix"} in ${filePath}`;
535
536 let prNumber: number | null = null;
537 try {
538 const [pr] = await db
539 .insert(pullRequests)
540 .values({
541 repositoryId: opts.repositoryId,
542 authorId: repo.ownerId, // AI commits attributed to repo owner
543 title,
544 body,
545 baseBranch,
546 headBranch: branch,
547 isDraft: false,
548 })
549 .returning({ number: pullRequests.number, id: pullRequests.id });
550 if (pr) {
551 prNumber = pr.number;
552 // Drop a marker comment so other tooling (and humans skimming
553 // the conversation tab) can spot the label without a join table.
554 try {
555 await db.insert(prComments).values({
556 pullRequestId: pr.id,
557 authorId: repo.ownerId,
558 isAiReview: true,
559 body: `${AI_PATCH_MARKER}\nApplied label: \`${AI_PATCH_LABEL}\``,
560 });
561 } catch (err) {
562 console.warn(
563 "[ai-patch] failed to insert label-marker comment:",
564 err instanceof Error ? err.message : err
565 );
566 }
567 }
568 } catch (err) {
569 console.error(
570 "[ai-patch] failed to insert pullRequests row:",
571 err instanceof Error ? err.message : err
572 );
573 continue;
574 }
575
576 if (prNumber == null) continue;
577
578 await audit({
579 userId: null,
580 action: "ai.patch.opened",
581 repositoryId: opts.repositoryId,
582 metadata: {
583 branch,
584 prNumber,
585 filePath,
586 baseSha: opts.baseSha,
587 findingId: findingShortId(finding),
588 severity: finding.severity || "unspecified",
589 },
590 });
591
592 return { branch, prNumber };
593 }
594
595 return null;
596}
597
598/**
599 * Test-only re-exports of internal helpers so the test suite can pin
600 * pure invariants without reaching through `__internal` proxies.
601 */
602export const __test = {
603 findingPath,
604 findingDescription,
605 resolveOwnerName,
606 askClaudeForPatch,
607 seedBranchFromBase,
608};