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

ai-auto-issues.tsBlame655 lines · 1 contributor
6efae38Claude1/**
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
19import { and, eq } from "drizzle-orm";
20import { db } from "../db";
21import {
22 issues,
23 issueLabels,
24 labels,
25 repositories,
26 users,
27} from "../db/schema";
28import { getRepoPath } from "../git/repository";
29
30// ---------------------------------------------------------------------------
31// Diff scanning patterns
32// ---------------------------------------------------------------------------
33
34const TODO_PATTERN = /^\+.*\b(TODO|FIXME|HACK|XXX|BUG|OPTIMIZE)\b.*$/gm;
35const SECRET_PATTERN =
36 /^\+.*(password|secret|api_key|apikey|token|private_key|privatekey)\s*=\s*["'][^"']{8,}/gim;
37const SQL_INJECTION_PATTERN =
38 /^\+.*\$\{.*\}.*(?:SELECT|INSERT|UPDATE|DELETE|DROP)/gim;
39const CONSOLE_LOG_PATTERN = /^\+.*console\.(log|debug|info)\(/gm;
40
41type FindingType = "todo" | "secret" | "sql-injection" | "console-log";
42
43interface 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. */
55const MAX_ISSUES_PER_PUSH = 5;
56
57/** Hard cap on diff size consumed (500 KB). */
58const MAX_DIFF_BYTES = 500 * 1024;
59
60/** Label applied to every auto-opened issue. */
61const LABEL_NAME = "ai-detected";
62const 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 */
73async 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 */
126export 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 */
184function 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. */
278function 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
295interface 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
304function 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
327const 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
334function 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
343function 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
380function 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
393function 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 */
433async 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 */
465async 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
512export 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 */
524export 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. */
647export const __test = {
648 scanDiffLines,
649 groupFindings,
650 parseDiffForFindings,
651 renderIssueTitle,
652 renderIssueBody,
653 renderSummaryIssueBody,
654 maskSecretValue,
655};