CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-auto-issues.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.
| 6efae38 | 1 | /** |
| 2 | * AI Auto-Issue Opener — scans every git push diff for common code quality | |
| 3 | * and security signals, then automatically opens issues for any findings. | |
| 4 | * | |
| 5 | * Feature is gated on env var `AI_AUTO_ISSUES=1`. If unset, the entry point | |
| 6 | * returns immediately. Never throws — all failures are caught so the push | |
| 7 | * path is never blocked. | |
| 8 | * | |
| 9 | * Patterns detected: | |
| 10 | * - TODO / FIXME / HACK / XXX / BUG / OPTIMIZE comments | |
| 11 | * - Hardcoded secrets (password=, api_key=, token=, etc.) | |
| 12 | * - SQL injection vectors (template literals inside SQL keywords) | |
| 13 | * - Debug console.log/debug/info calls left in production code | |
| 14 | * | |
| 15 | * Rate limiting: maximum MAX_ISSUES_PER_PUSH issues per push. If more findings | |
| 16 | * exist, a single summary issue is opened instead of the individual ones. | |
| 17 | */ | |
| 18 | ||
| 19 | import { and, eq } from "drizzle-orm"; | |
| 20 | import { db } from "../db"; | |
| 21 | import { | |
| 22 | issues, | |
| 23 | issueLabels, | |
| 24 | labels, | |
| 25 | repositories, | |
| 26 | users, | |
| 27 | } from "../db/schema"; | |
| 28 | import { getRepoPath } from "../git/repository"; | |
| 29 | ||
| 30 | // --------------------------------------------------------------------------- | |
| 31 | // Diff scanning patterns | |
| 32 | // --------------------------------------------------------------------------- | |
| 33 | ||
| 34 | const TODO_PATTERN = /^\+.*\b(TODO|FIXME|HACK|XXX|BUG|OPTIMIZE)\b.*$/gm; | |
| 35 | const SECRET_PATTERN = | |
| 36 | /^\+.*(password|secret|api_key|apikey|token|private_key|privatekey)\s*=\s*["'][^"']{8,}/gim; | |
| 37 | const SQL_INJECTION_PATTERN = | |
| 38 | /^\+.*\$\{.*\}.*(?:SELECT|INSERT|UPDATE|DELETE|DROP)/gim; | |
| 39 | const CONSOLE_LOG_PATTERN = /^\+.*console\.(log|debug|info)\(/gm; | |
| 40 | ||
| 41 | type FindingType = "todo" | "secret" | "sql-injection" | "console-log"; | |
| 42 | ||
| 43 | interface Finding { | |
| 44 | type: FindingType; | |
| 45 | filePath: string; | |
| 46 | lineNumber: number; | |
| 47 | matchText: string; | |
| 48 | } | |
| 49 | ||
| 50 | // --------------------------------------------------------------------------- | |
| 51 | // Constants | |
| 52 | // --------------------------------------------------------------------------- | |
| 53 | ||
| 54 | /** Maximum auto-issues opened per push before collapsing into a summary. */ | |
| 55 | const MAX_ISSUES_PER_PUSH = 5; | |
| 56 | ||
| 57 | /** Hard cap on diff size consumed (500 KB). */ | |
| 58 | const MAX_DIFF_BYTES = 500 * 1024; | |
| 59 | ||
| 60 | /** Label applied to every auto-opened issue. */ | |
| 61 | const LABEL_NAME = "ai-detected"; | |
| 62 | const LABEL_COLOR = "#e11d48"; // vivid red — stands out in the issue list | |
| 63 | ||
| 64 | // --------------------------------------------------------------------------- | |
| 65 | // Diff helpers | |
| 66 | // --------------------------------------------------------------------------- | |
| 67 | ||
| 68 | /** | |
| 69 | * Run `git diff <oldSha> <newSha>` inside the bare repo and return the output | |
| 70 | * capped at MAX_DIFF_BYTES. For an initial push where oldSha is all zeros, | |
| 71 | * runs `git show <newSha>` instead so we still get the diff. | |
| 72 | */ | |
| 73 | async function getDiff( | |
| 74 | owner: string, | |
| 75 | repo: string, | |
| 76 | oldSha: string, | |
| 77 | newSha: string | |
| 78 | ): Promise<string> { | |
| 79 | const cwd = getRepoPath(owner, repo); | |
| 80 | const allZero = /^0+$/.test(oldSha); | |
| 81 | const cmd = allZero | |
| 82 | ? ["git", "show", "--format=", newSha] | |
| 83 | : ["git", "diff", oldSha, newSha]; | |
| 84 | try { | |
| 85 | const proc = Bun.spawn(cmd, { | |
| 86 | cwd, | |
| 87 | stdout: "pipe", | |
| 88 | stderr: "ignore", | |
| 89 | }); | |
| 90 | // Read up to MAX_DIFF_BYTES to avoid huge diffs | |
| 91 | const reader = proc.stdout.getReader(); | |
| 92 | const chunks: Uint8Array[] = []; | |
| 93 | let totalBytes = 0; | |
| 94 | while (true) { | |
| 95 | const { done, value } = await reader.read(); | |
| 96 | if (done) break; | |
| 97 | if (value) { | |
| 98 | if (totalBytes + value.byteLength > MAX_DIFF_BYTES) { | |
| 99 | const remaining = MAX_DIFF_BYTES - totalBytes; | |
| 100 | if (remaining > 0) { | |
| 101 | chunks.push(value.slice(0, remaining)); | |
| 102 | } | |
| 103 | break; | |
| 104 | } | |
| 105 | chunks.push(value); | |
| 106 | totalBytes += value.byteLength; | |
| 107 | } | |
| 108 | } | |
| 109 | reader.cancel(); | |
| 110 | await proc.exited; | |
| 111 | const decoder = new TextDecoder(); | |
| 112 | return chunks.map((c) => decoder.decode(c)).join(""); | |
| 113 | } catch { | |
| 114 | return ""; | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | // --------------------------------------------------------------------------- | |
| 119 | // Diff parser | |
| 120 | // --------------------------------------------------------------------------- | |
| 121 | ||
| 122 | /** | |
| 123 | * Parse a unified diff text into a list of findings. Groups findings by | |
| 124 | * (filePath, type) to avoid opening many issues for a single noisy file. | |
| 125 | */ | |
| 126 | export function parseDiffForFindings(diff: string): Finding[] { | |
| 127 | const findings: Finding[] = []; | |
| 128 | ||
| 129 | // Track current file and current line offset within the new file | |
| 130 | let currentFile = ""; | |
| 131 | let currentNewLine = 0; | |
| 132 | ||
| 133 | const lines = diff.split("\n"); | |
| 134 | ||
| 135 | for (const line of lines) { | |
| 136 | // File header: diff --git a/... b/... | |
| 137 | const fileMatch = line.match(/^diff --git a\/.* b\/(.+)$/); | |
| 138 | if (fileMatch) { | |
| 139 | currentFile = fileMatch[1]; | |
| 140 | currentNewLine = 0; | |
| 141 | continue; | |
| 142 | } | |
| 143 | ||
| 144 | // +++ b/... header (fallback for file name) | |
| 145 | const plusHeader = line.match(/^\+\+\+ b\/(.+)$/); | |
| 146 | if (plusHeader) { | |
| 147 | currentFile = plusHeader[1]; | |
| 148 | continue; | |
| 149 | } | |
| 150 | ||
| 151 | // Hunk header: @@ -x,y +a,b @@ | |
| 152 | const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | |
| 153 | if (hunkMatch) { | |
| 154 | currentNewLine = parseInt(hunkMatch[1], 10) - 1; // will be incremented below | |
| 155 | continue; | |
| 156 | } | |
| 157 | ||
| 158 | // Track new-file line numbers | |
| 159 | if (line.startsWith("+") && !line.startsWith("+++")) { | |
| 160 | currentNewLine++; | |
| 161 | } else if (line.startsWith("-") && !line.startsWith("---")) { | |
| 162 | // Deleted lines don't advance the new-file line counter | |
| 163 | continue; | |
| 164 | } else if (!line.startsWith("\\")) { | |
| 165 | // Context line or header — advance counter only for context lines | |
| 166 | if (!line.startsWith("diff") && !line.startsWith("index") && | |
| 167 | !line.startsWith("---") && !line.startsWith("+++")) { | |
| 168 | currentNewLine++; | |
| 169 | } | |
| 170 | continue; | |
| 171 | } | |
| 172 | ||
| 173 | if (!line.startsWith("+") || !currentFile) continue; | |
| 174 | } | |
| 175 | ||
| 176 | // Second pass: collect matches with proper line tracking | |
| 177 | return scanDiffLines(diff); | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Scan diff lines with proper hunk-aware line number tracking. Returns one | |
| 182 | * Finding per matched line — callers should deduplicate by (file, type). | |
| 183 | */ | |
| 184 | function scanDiffLines(diff: string): Finding[] { | |
| 185 | const findings: Finding[] = []; | |
| 186 | let currentFile = ""; | |
| 187 | let currentNewLine = 0; | |
| 188 | let lineIdx = 0; | |
| 189 | ||
| 190 | const lines = diff.split("\n"); | |
| 191 | ||
| 192 | for (const line of lines) { | |
| 193 | lineIdx++; | |
| 194 | ||
| 195 | // File header | |
| 196 | const fileMatch = line.match(/^diff --git a\/.* b\/(.+)$/); | |
| 197 | if (fileMatch) { | |
| 198 | currentFile = fileMatch[1]; | |
| 199 | currentNewLine = 0; | |
| 200 | continue; | |
| 201 | } | |
| 202 | ||
| 203 | const plusHeader = line.match(/^\+\+\+ b\/(.+)$/); | |
| 204 | if (plusHeader) { | |
| 205 | currentFile = plusHeader[1]; | |
| 206 | continue; | |
| 207 | } | |
| 208 | ||
| 209 | // Hunk header | |
| 210 | const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | |
| 211 | if (hunkMatch) { | |
| 212 | currentNewLine = parseInt(hunkMatch[1], 10) - 1; | |
| 213 | continue; | |
| 214 | } | |
| 215 | ||
| 216 | if (line.startsWith("-") && !line.startsWith("---")) { | |
| 217 | // Removed lines — skip (don't open issues for deleted code) | |
| 218 | continue; | |
| 219 | } | |
| 220 | ||
| 221 | if (line.startsWith("+") && !line.startsWith("+++")) { | |
| 222 | currentNewLine++; | |
| 223 | ||
| 224 | if (!currentFile) continue; | |
| 225 | ||
| 226 | // Match each pattern against the added line | |
| 227 | const matchText = line.slice(0, 200); // cap at 200 chars | |
| 228 | ||
| 229 | if (TODO_PATTERN.test(line)) { | |
| 230 | findings.push({ | |
| 231 | type: "todo", | |
| 232 | filePath: currentFile, | |
| 233 | lineNumber: currentNewLine, | |
| 234 | matchText, | |
| 235 | }); | |
| 236 | } | |
| 237 | TODO_PATTERN.lastIndex = 0; | |
| 238 | ||
| 239 | if (SECRET_PATTERN.test(line)) { | |
| 240 | findings.push({ | |
| 241 | type: "secret", | |
| 242 | filePath: currentFile, | |
| 243 | lineNumber: currentNewLine, | |
| 244 | matchText: maskSecretValue(matchText), | |
| 245 | }); | |
| 246 | } | |
| 247 | SECRET_PATTERN.lastIndex = 0; | |
| 248 | ||
| 249 | if (SQL_INJECTION_PATTERN.test(line)) { | |
| 250 | findings.push({ | |
| 251 | type: "sql-injection", | |
| 252 | filePath: currentFile, | |
| 253 | lineNumber: currentNewLine, | |
| 254 | matchText, | |
| 255 | }); | |
| 256 | } | |
| 257 | SQL_INJECTION_PATTERN.lastIndex = 0; | |
| 258 | ||
| 259 | if (CONSOLE_LOG_PATTERN.test(line)) { | |
| 260 | findings.push({ | |
| 261 | type: "console-log", | |
| 262 | filePath: currentFile, | |
| 263 | lineNumber: currentNewLine, | |
| 264 | matchText, | |
| 265 | }); | |
| 266 | } | |
| 267 | CONSOLE_LOG_PATTERN.lastIndex = 0; | |
| 268 | } else { | |
| 269 | // Context line | |
| 270 | currentNewLine++; | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | return findings; | |
| 275 | } | |
| 276 | ||
| 277 | /** Replace the value portion of a secret match with asterisks. */ | |
| 278 | function maskSecretValue(text: string): string { | |
| 279 | return text.replace( | |
| 280 | /(password|secret|api_key|apikey|token|private_key|privatekey)\s*=\s*["'][^"']{0,200}/gi, | |
| 281 | (m) => { | |
| 282 | const eqIdx = m.indexOf("="); | |
| 283 | const quoteIdx = m.indexOf('"', eqIdx) !== -1 | |
| 284 | ? m.indexOf('"', eqIdx) | |
| 285 | : m.indexOf("'", eqIdx); | |
| 286 | return m.slice(0, quoteIdx + 1) + "****"; | |
| 287 | } | |
| 288 | ); | |
| 289 | } | |
| 290 | ||
| 291 | // --------------------------------------------------------------------------- | |
| 292 | // Deduplication | |
| 293 | // --------------------------------------------------------------------------- | |
| 294 | ||
| 295 | interface GroupedFinding { | |
| 296 | type: FindingType; | |
| 297 | filePath: string; | |
| 298 | /** All line numbers in this file for this finding type. */ | |
| 299 | lineNumbers: number[]; | |
| 300 | /** First matched text (representative sample). */ | |
| 301 | sampleText: string; | |
| 302 | } | |
| 303 | ||
| 304 | function groupFindings(findings: Finding[]): GroupedFinding[] { | |
| 305 | const map = new Map<string, GroupedFinding>(); | |
| 306 | for (const f of findings) { | |
| 307 | const key = `${f.type}::${f.filePath}`; | |
| 308 | const existing = map.get(key); | |
| 309 | if (existing) { | |
| 310 | existing.lineNumbers.push(f.lineNumber); | |
| 311 | } else { | |
| 312 | map.set(key, { | |
| 313 | type: f.type, | |
| 314 | filePath: f.filePath, | |
| 315 | lineNumbers: [f.lineNumber], | |
| 316 | sampleText: f.matchText, | |
| 317 | }); | |
| 318 | } | |
| 319 | } | |
| 320 | return Array.from(map.values()); | |
| 321 | } | |
| 322 | ||
| 323 | // --------------------------------------------------------------------------- | |
| 324 | // Issue rendering | |
| 325 | // --------------------------------------------------------------------------- | |
| 326 | ||
| 327 | const FINDING_LABELS: Record<FindingType, string> = { | |
| 328 | "todo": "TODO/FIXME", | |
| 329 | "secret": "Potential Secret Exposure", | |
| 330 | "sql-injection": "SQL Injection Risk", | |
| 331 | "console-log": "Debug Console Log", | |
| 332 | }; | |
| 333 | ||
| 334 | function renderIssueTitle( | |
| 335 | group: GroupedFinding, | |
| 336 | pusherUsername: string | |
| 337 | ): string { | |
| 338 | const label = FINDING_LABELS[group.type]; | |
| 339 | const loc = `${group.filePath}`; | |
| 340 | return `[AI] ${label} found in ${loc} (pushed by @${pusherUsername})`; | |
| 341 | } | |
| 342 | ||
| 343 | function renderIssueBody( | |
| 344 | group: GroupedFinding, | |
| 345 | owner: string, | |
| 346 | repo: string, | |
| 347 | commitSha: string, | |
| 348 | pusherUsername: string | |
| 349 | ): string { | |
| 350 | const label = FINDING_LABELS[group.type]; | |
| 351 | const shortSha = commitSha.slice(0, 7); | |
| 352 | const lineList = group.lineNumbers.slice(0, 10).join(", "); | |
| 353 | const firstLine = group.lineNumbers[0]; | |
| 354 | ||
| 355 | const fileLink = `[\`${group.filePath}:${firstLine}\`](/${owner}/${repo}/blob/${commitSha}/${group.filePath}#L${firstLine})`; | |
| 356 | ||
| 357 | const description = findingDescription(group.type); | |
| 358 | ||
| 359 | const lines = [ | |
| 360 | `**Automated AI scan** detected a **${label}** in commit \`${shortSha}\` pushed by @${pusherUsername}.`, | |
| 361 | "", | |
| 362 | `**File:** ${fileLink}`, | |
| 363 | `**Line(s):** ${lineList}${group.lineNumbers.length > 10 ? ` (and ${group.lineNumbers.length - 10} more)` : ""}`, | |
| 364 | "", | |
| 365 | "## Matched code", | |
| 366 | "```", | |
| 367 | group.sampleText.trim(), | |
| 368 | "```", | |
| 369 | "", | |
| 370 | "## Why this matters", | |
| 371 | description, | |
| 372 | "", | |
| 373 | "---", | |
| 374 | "_This issue was auto-opened by Gluecron's AI push scanner. Close it if the finding is a false positive._", | |
| 375 | ]; | |
| 376 | ||
| 377 | return lines.join("\n"); | |
| 378 | } | |
| 379 | ||
| 380 | function findingDescription(type: FindingType): string { | |
| 381 | switch (type) { | |
| 382 | case "todo": | |
| 383 | return "TODO/FIXME/HACK comments indicate incomplete or workaround code that should be tracked as proper issues rather than buried in source files."; | |
| 384 | case "secret": | |
| 385 | return "Hardcoded credentials or API keys in source code can be extracted from git history even after deletion. Rotate the exposed credential immediately and use environment variables or a secrets manager instead."; | |
| 386 | case "sql-injection": | |
| 387 | return "Template literals interpolated directly into SQL statements may allow SQL injection if user-controlled data reaches this code path. Use parameterised queries or a query builder instead."; | |
| 388 | case "console-log": | |
| 389 | return "Debug `console.log` calls left in production code can expose sensitive data in logs and add unnecessary noise. Remove or replace with a proper logging library with log-level controls."; | |
| 390 | } | |
| 391 | } | |
| 392 | ||
| 393 | function renderSummaryIssueBody( | |
| 394 | totalFindings: number, | |
| 395 | groups: GroupedFinding[], | |
| 396 | owner: string, | |
| 397 | repo: string, | |
| 398 | commitSha: string, | |
| 399 | pusherUsername: string | |
| 400 | ): string { | |
| 401 | const shortSha = commitSha.slice(0, 7); | |
| 402 | const lines = [ | |
| 403 | `**Automated AI scan** detected **${totalFindings} findings** in commit \`${shortSha}\` pushed by @${pusherUsername}.`, | |
| 404 | "", | |
| 405 | "The push scanner limit was reached — here is a summary of all findings:", | |
| 406 | "", | |
| 407 | "| Type | File | Lines |", | |
| 408 | "| ---- | ---- | ----- |", | |
| 409 | ...groups.map((g) => { | |
| 410 | const label = FINDING_LABELS[g.type]; | |
| 411 | const fileLink = `[${g.filePath}](/${owner}/${repo}/blob/${commitSha}/${g.filePath})`; | |
| 412 | const lineStr = g.lineNumbers.slice(0, 5).join(", ") + | |
| 413 | (g.lineNumbers.length > 5 ? ` (+${g.lineNumbers.length - 5})` : ""); | |
| 414 | return `| ${label} | ${fileLink} | ${lineStr} |`; | |
| 415 | }), | |
| 416 | "", | |
| 417 | "Address the individual findings and re-push to trigger a fresh scan.", | |
| 418 | "", | |
| 419 | "---", | |
| 420 | "_This issue was auto-opened by Gluecron's AI push scanner._", | |
| 421 | ]; | |
| 422 | return lines.join("\n"); | |
| 423 | } | |
| 424 | ||
| 425 | // --------------------------------------------------------------------------- | |
| 426 | // DB helpers | |
| 427 | // --------------------------------------------------------------------------- | |
| 428 | ||
| 429 | /** | |
| 430 | * Resolve or create the `ai-detected` label for a repository. | |
| 431 | * Returns the label id, or null on any failure. | |
| 432 | */ | |
| 433 | async function ensureAiDetectedLabel(repositoryId: string): Promise<string | null> { | |
| 434 | try { | |
| 435 | // Try to find existing label | |
| 436 | const [existing] = await db | |
| 437 | .select({ id: labels.id }) | |
| 438 | .from(labels) | |
| 439 | .where(and(eq(labels.repositoryId, repositoryId), eq(labels.name, LABEL_NAME))) | |
| 440 | .limit(1); | |
| 441 | if (existing) return existing.id; | |
| 442 | ||
| 443 | // Create it | |
| 444 | const [created] = await db | |
| 445 | .insert(labels) | |
| 446 | .values({ | |
| 447 | repositoryId, | |
| 448 | name: LABEL_NAME, | |
| 449 | color: LABEL_COLOR, | |
| 450 | description: "Automatically detected by Gluecron AI push scanner", | |
| 451 | }) | |
| 452 | .onConflictDoNothing() | |
| 453 | .returning({ id: labels.id }); | |
| 454 | return created?.id ?? null; | |
| 455 | } catch { | |
| 456 | return null; | |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | /** | |
| 461 | * Insert one issue and optionally attach the ai-detected label. | |
| 462 | * Bumps `repositories.issueCount` by 1. | |
| 463 | * Returns the new issue number, or null on failure. | |
| 464 | */ | |
| 465 | async function insertIssue(opts: { | |
| 466 | repositoryId: string; | |
| 467 | authorId: string; | |
| 468 | title: string; | |
| 469 | body: string; | |
| 470 | labelId: string | null; | |
| 471 | currentIssueCount: number; | |
| 472 | }): Promise<number | null> { | |
| 473 | try { | |
| 474 | const [inserted] = await db | |
| 475 | .insert(issues) | |
| 476 | .values({ | |
| 477 | repositoryId: opts.repositoryId, | |
| 478 | authorId: opts.authorId, | |
| 479 | title: opts.title.slice(0, 255), | |
| 480 | body: opts.body, | |
| 481 | state: "open", | |
| 482 | }) | |
| 483 | .returning({ id: issues.id, number: issues.number }); | |
| 484 | ||
| 485 | if (!inserted) return null; | |
| 486 | ||
| 487 | // Attach label — best-effort | |
| 488 | if (opts.labelId) { | |
| 489 | await db | |
| 490 | .insert(issueLabels) | |
| 491 | .values({ issueId: inserted.id, labelId: opts.labelId }) | |
| 492 | .catch(() => {/* ignore */}); | |
| 493 | } | |
| 494 | ||
| 495 | // Bump issue count — best-effort | |
| 496 | await db | |
| 497 | .update(repositories) | |
| 498 | .set({ issueCount: opts.currentIssueCount + 1 }) | |
| 499 | .where(eq(repositories.id, opts.repositoryId)) | |
| 500 | .catch(() => {/* ignore */}); | |
| 501 | ||
| 502 | return inserted.number; | |
| 503 | } catch { | |
| 504 | return null; | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | // --------------------------------------------------------------------------- | |
| 509 | // Entry point | |
| 510 | // --------------------------------------------------------------------------- | |
| 511 | ||
| 512 | export interface ScanResult { | |
| 513 | findingsCount: number; | |
| 514 | issuesOpened: number; | |
| 515 | skipped: boolean; | |
| 516 | } | |
| 517 | ||
| 518 | /** | |
| 519 | * Scan the diff between `oldSha` and `newSha` for code quality / security | |
| 520 | * signals and open issues in the repository for any findings. | |
| 521 | * | |
| 522 | * Gated on `AI_AUTO_ISSUES=1`. Never throws. | |
| 523 | */ | |
| 524 | export async function scanDiffForIssues( | |
| 525 | owner: string, | |
| 526 | repo: string, | |
| 527 | oldSha: string, | |
| 528 | newSha: string, | |
| 529 | pusherUserId: string | |
| 530 | ): Promise<ScanResult> { | |
| 531 | // Feature flag gate | |
| 532 | if (process.env.AI_AUTO_ISSUES !== "1") { | |
| 533 | return { findingsCount: 0, issuesOpened: 0, skipped: true }; | |
| 534 | } | |
| 535 | ||
| 536 | try { | |
| 537 | // 1. Resolve repo row | |
| 538 | const [repoRow] = await db | |
| 539 | .select({ | |
| 540 | id: repositories.id, | |
| 541 | ownerId: repositories.ownerId, | |
| 542 | issueCount: repositories.issueCount, | |
| 543 | }) | |
| 544 | .from(repositories) | |
| 545 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 546 | .where(and(eq(users.username, owner), eq(repositories.name, repo))) | |
| 547 | .limit(1); | |
| 548 | ||
| 549 | if (!repoRow) { | |
| 550 | return { findingsCount: 0, issuesOpened: 0, skipped: true }; | |
| 551 | } | |
| 552 | ||
| 553 | // 2. Resolve pusher username for issue titles | |
| 554 | let pusherUsername = "unknown"; | |
| 555 | try { | |
| 556 | const [pusherRow] = await db | |
| 557 | .select({ username: users.username }) | |
| 558 | .from(users) | |
| 559 | .where(eq(users.id, pusherUserId)) | |
| 560 | .limit(1); | |
| 561 | if (pusherRow) pusherUsername = pusherRow.username; | |
| 562 | } catch { | |
| 563 | /* fall back to "unknown" */ | |
| 564 | } | |
| 565 | ||
| 566 | // 3. Fetch diff | |
| 567 | const diff = await getDiff(owner, repo, oldSha, newSha); | |
| 568 | if (!diff.trim()) { | |
| 569 | return { findingsCount: 0, issuesOpened: 0, skipped: false }; | |
| 570 | } | |
| 571 | ||
| 572 | // 4. Parse and group findings | |
| 573 | const rawFindings = scanDiffLines(diff); | |
| 574 | const groups = groupFindings(rawFindings); | |
| 575 | ||
| 576 | if (groups.length === 0) { | |
| 577 | return { findingsCount: 0, issuesOpened: 0, skipped: false }; | |
| 578 | } | |
| 579 | ||
| 580 | // 5. Ensure ai-detected label exists | |
| 581 | const labelId = await ensureAiDetectedLabel(repoRow.id); | |
| 582 | ||
| 583 | // 6. Open issues — up to MAX_ISSUES_PER_PUSH, then a summary | |
| 584 | let issuesOpened = 0; | |
| 585 | let currentIssueCount = repoRow.issueCount ?? 0; | |
| 586 | ||
| 587 | if (groups.length <= MAX_ISSUES_PER_PUSH) { | |
| 588 | for (const group of groups) { | |
| 589 | const title = renderIssueTitle(group, pusherUsername); | |
| 590 | const body = renderIssueBody(group, owner, repo, newSha, pusherUsername); | |
| 591 | const num = await insertIssue({ | |
| 592 | repositoryId: repoRow.id, | |
| 593 | authorId: repoRow.ownerId, | |
| 594 | title, | |
| 595 | body, | |
| 596 | labelId, | |
| 597 | currentIssueCount, | |
| 598 | }); | |
| 599 | if (num !== null) { | |
| 600 | issuesOpened++; | |
| 601 | currentIssueCount++; | |
| 602 | } | |
| 603 | } | |
| 604 | } else { | |
| 605 | // Too many findings — open one summary issue | |
| 606 | const title = `[AI] Multiple issues found in this push (${rawFindings.length} findings) — see details`; | |
| 607 | const body = renderSummaryIssueBody( | |
| 608 | rawFindings.length, | |
| 609 | groups, | |
| 610 | owner, | |
| 611 | repo, | |
| 612 | newSha, | |
| 613 | pusherUsername | |
| 614 | ); | |
| 615 | const num = await insertIssue({ | |
| 616 | repositoryId: repoRow.id, | |
| 617 | authorId: repoRow.ownerId, | |
| 618 | title, | |
| 619 | body, | |
| 620 | labelId, | |
| 621 | currentIssueCount, | |
| 622 | }); | |
| 623 | if (num !== null) { | |
| 624 | issuesOpened++; | |
| 625 | } | |
| 626 | } | |
| 627 | ||
| 628 | console.log( | |
| 629 | `[ai-auto-issues] ${owner}/${repo}@${newSha.slice(0, 7)}: ${rawFindings.length} finding(s), ${issuesOpened} issue(s) opened` | |
| 630 | ); | |
| 631 | ||
| 632 | return { | |
| 633 | findingsCount: rawFindings.length, | |
| 634 | issuesOpened, | |
| 635 | skipped: false, | |
| 636 | }; | |
| 637 | } catch (err) { | |
| 638 | console.warn( | |
| 639 | `[ai-auto-issues] error for ${owner}/${repo}@${newSha.slice(0, 7)}:`, | |
| 640 | err instanceof Error ? err.message : err | |
| 641 | ); | |
| 642 | return { findingsCount: 0, issuesOpened: 0, skipped: false }; | |
| 643 | } | |
| 644 | } | |
| 645 | ||
| 646 | /** Test-only exports — do not import in production code paths. */ | |
| 647 | export const __test = { | |
| 648 | scanDiffLines, | |
| 649 | groupFindings, | |
| 650 | parseDiffForFindings, | |
| 651 | renderIssueTitle, | |
| 652 | renderIssueBody, | |
| 653 | renderSummaryIssueBody, | |
| 654 | maskSecretValue, | |
| 655 | }; |