Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit6efae38unknown_key

feat: AI auto-issue opener — scans every push for TODOs, secrets, and SQL injection

feat: AI auto-issue opener — scans every push for TODOs, secrets, and SQL injection

When AI_AUTO_ISSUES=1, every git push is diffed and scanned for:
- TODO/FIXME/HACK/XXX/BUG/OPTIMIZE comments
- Hardcoded credentials (password=, api_key=, token=, etc.)
- SQL injection patterns (template literals inside SQL keywords)
- Debug console.log/debug/info calls

Findings are grouped by (file, type) and up to 5 issues are auto-opened
per push; if more findings exist, one summary issue is opened instead.
Issues carry the 'ai-detected' label (created on first use) and link
back to the exact file/line in the commit blob view.

- src/lib/ai-auto-issues.ts — scanner + issue renderer + DB insertion
- src/lib/config.ts — aiAutoIssues getter for AI_AUTO_ISSUES env var
- src/hooks/post-receive.ts — fire-and-forget per-ref scan; new optional
  pusherUserId param threaded through for attribution
- src/routes/git.ts — hoist resolvePusher call so pusherUserId is
  available to both the policy gate and the post-receive hook

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 6f2809f
4 files changed+69366efae3878276adac651ed88b13ed40c8d624f1da
4 changed files+693−6
Modifiedsrc/hooks/post-receive.ts+15−1View fileUnifiedSplit
2525 getRepoPath,
2626} from "../git/repository";
2727import { indexChangedFiles } from "../lib/semantic-index";
28import { scanDiffForIssues } from "../lib/ai-auto-issues";
2829import { enqueuePreviewBuild } from "../lib/branch-previews";
2930import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
3031import {
4445export async function onPostReceive(
4546 owner: string,
4647 repo: string,
47 refs: PushRef[]
48 refs: PushRef[],
49 pusherUserId: string = ""
4850): Promise<void> {
4951 for (const ref of refs) {
5052 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
124126 console.warn("[branch-previews] dispatch error:", err)
125127 );
126128
129 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
130 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
131 // and debug console.log calls. Opens one issue per finding type per
132 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1.
133 // Fire-and-forget; never blocks the push path.
134 for (const ref of refs) {
135 if (ref.newSha.startsWith("0000")) continue;
136 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
137 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
138 );
139 }
140
127141 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
128142 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
129143 // regions, hashes the referenced source, and opens a PR labelled
Addedsrc/lib/ai-auto-issues.ts+655−0View fileUnifiedSplit
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
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};
Modifiedsrc/lib/config.ts+9−0View fileUnifiedSplit
121121 get redisUrl() {
122122 return process.env.REDIS_URL || process.env.VALKEY_URL || "";
123123 },
124 /**
125 * AI Auto-Issue Opener (src/lib/ai-auto-issues.ts). When set to "1",
126 * every git push is scanned for TODOs, hardcoded secrets, SQL injection
127 * patterns, and debug console.log calls; matching findings automatically
128 * open issues in the repository. Off by default.
129 */
130 get aiAutoIssues() {
131 return process.env.AI_AUTO_ISSUES === "1";
132 },
124133};
Modifiedsrc/routes/git.ts+14−5View fileUnifiedSplit
9393 const bodyBuffer = await c.req.arrayBuffer();
9494 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
9595
96 // Resolve the pusher early so we can pass it to both the policy gate and
97 // the post-receive hook (e.g. for AI auto-issue attribution).
98 let pusherUserId = "";
99 try {
100 const pusher = await resolvePusher(c.req.header("authorization"));
101 pusherUserId = pusher?.userId || "";
102 } catch {
103 // Fail open — policy gate and post-receive handle missing pushers fine.
104 }
105
96106 // Pre-receive policy: protected tags + ruleset name patterns. Fail-open
97107 // on any DB hiccup (the helper returns {allowed:true} in that case).
98108 // We also retain the repoRow so the pack-inspection hook below can reuse it.
102112 const repoRow = await loadRepoRow(owner, repo);
103113 if (repoRow) {
104114 cachedRepoId = repoRow.id;
105 const pusher = await resolvePusher(c.req.header("authorization"));
106115 const decision = await evaluatePushPolicy({
107116 repositoryId: repoRow.id,
108117 refs,
109 pusherUserId: pusher?.userId || null,
118 pusherUserId: pusherUserId || null,
110119 });
111120 if (!decision.allowed) {
112121 // Audit the rejection so owners can see blocked-push attempts
113122 // even though the request never reached the post-receive hook.
114123 // Fire-and-forget — never block the 403.
115124 audit({
116 userId: pusher?.userId || null,
125 userId: pusherUserId || null,
117126 repositoryId: repoRow.id,
118127 action: "push.rejected",
119128 targetType: "repository",
126135 metadata: {
127136 violations: decision.violations,
128137 refs: refs.map((r) => r.refName),
129 pusherSource: pusher?.source || "anonymous",
138 pusherSource: pusherUserId ? "user" : "anonymous",
130139 },
131140 }).catch((err) => {
132141 console.warn(
182191
183192 // Fire post-receive hooks asynchronously (don't block response).
184193 if (refs.length > 0) {
185 onPostReceive(owner, repo, refs).catch((err) =>
194 onPostReceive(owner, repo, refs, pusherUserId).catch((err) =>
186195 console.error("[post-receive] hook error:", err)
187196 );
188197 }
189198