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.
| eead172 | 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 | ||
| 32 | import { createHash } from "crypto"; | |
| 33 | import { and, eq } from "drizzle-orm"; | |
| 34 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 35 | import { db } from "../db"; | |
| 36 | import { | |
| 37 | labels, | |
| 38 | pullRequests, | |
| 39 | prComments, | |
| 40 | repositories, | |
| 41 | users, | |
| 42 | } from "../db/schema"; | |
| 43 | import { | |
| 44 | createOrUpdateFileOnBranch, | |
| 45 | getBlob, | |
| 46 | refExists, | |
| 47 | updateRef, | |
| 48 | } from "../git/repository"; | |
| 49 | import { config } from "./config"; | |
| 50 | import { audit } from "./notify"; | |
| 51 | import { | |
| 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 | */ | |
| 63 | export const AI_PATCH_MARKER = "<!-- gluecron-ai-patch:proposed -->"; | |
| 64 | ||
| 65 | /** Label name we surface (and create on the repo) for these PRs. */ | |
| 66 | export 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 | */ | |
| 73 | export const PATCH_SEVERITY_THRESHOLD = ["medium", "high", "critical"] as const; | |
| 74 | export type PatchSeverity = | |
| 75 | | "info" | |
| 76 | | "low" | |
| 77 | | "medium" | |
| 78 | | "high" | |
| 79 | | "critical"; | |
| 80 | ||
| 81 | export 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 | */ | |
| 93 | export 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 | ||
| 110 | export 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 | ||
| 132 | export interface GeneratePatchResult { | |
| 133 | branch: string; | |
| 134 | prNumber: number; | |
| 135 | } | |
| 136 | ||
| 137 | interface ClaudePatch { | |
| 138 | path: string; | |
| 139 | new_content: string; | |
| 140 | } | |
| 141 | ||
| 142 | interface ClaudePatchResponse { | |
| 143 | explanation?: string; | |
| 144 | patches?: ClaudePatch[]; | |
| 145 | } | |
| 146 | ||
| 147 | // --------------------------------------------------------------------------- | |
| 148 | // Helpers | |
| 149 | // --------------------------------------------------------------------------- | |
| 150 | ||
| 151 | function findingPath(f: GateTestFinding): string | null { | |
| 152 | const p = f.path || f.file || ""; | |
| 153 | return p.trim() ? p.trim() : null; | |
| 154 | } | |
| 155 | ||
| 156 | function 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 | */ | |
| 171 | export 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 | */ | |
| 188 | async 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 | */ | |
| 222 | async 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 | */ | |
| 247 | export 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 | */ | |
| 288 | async 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 | }); | |
| 0c3eee5 | 302 | 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 | } | |
| eead172 | 315 | 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 | */ | |
| 333 | export 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 | */ | |
| 349 | async 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 | */ | |
| 363 | export 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 | */ | |
| 418 | export 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 | */ | |
| 602 | export const __test = { | |
| 603 | findingPath, | |
| 604 | findingDescription, | |
| 605 | resolveOwnerName, | |
| 606 | askClaudeForPatch, | |
| 607 | seedBranchFromBase, | |
| 608 | }; |