Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

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

gate.tsBlame457 lines · 1 contributor
e883329Claude1/**
3ef4c9dClaude2 * Green gate enforcement — the heart of the "nothing broken ships" guarantee.
e883329Claude3 *
3ef4c9dClaude4 * Runs every configured gate for a repo on push / on PR / on merge:
5 * 1. GateTest scan (external test/lint runner)
6 * 2. Secret scan (regex secrets + AI security review)
7 * 3. AI code review (for PRs)
8 * 4. Merge check (for PRs)
9 * 5. Dependency/vuln scan (best-effort, skipped if not configured)
e883329Claude10 *
3ef4c9dClaude11 * Each result is persisted to `gate_runs`. If auto-repair is enabled
12 * and a gate fails, the engine attempts a fix before reporting a hard fail.
e883329Claude13 */
14
3ef4c9dClaude15import { eq } from "drizzle-orm";
e883329Claude16import { config } from "./config";
3ef4c9dClaude17import { db } from "../db";
18import { gateRuns, repoSettings, repositories, users } from "../db/schema";
19import { getOrCreateSettings } from "./repo-bootstrap";
20import { scanForSecrets, aiSecurityScan } from "./security-scan";
21import type { SecretFinding, SecurityFinding } from "./security-scan";
22import { repairSecrets, repairSecurityIssues } from "./auto-repair";
23import { readFile } from "fs/promises";
24import { join } from "path";
b2ff5c7Claude25import { updateGateMetrics, extractPatterns } from "./flywheel";
6522084Claude26import { broadcast } from "./sse";
e883329Claude27
28export interface GateCheckResult {
29 name: string;
30 passed: boolean;
31 details: string;
3ef4c9dClaude32 skipped?: boolean;
33 repaired?: boolean;
34 repairCommitSha?: string;
e883329Claude35}
36
37export interface GateResult {
38 allPassed: boolean;
39 checks: GateCheckResult[];
40}
41
3ef4c9dClaude42/**
43 * Record a gate run in the DB. Fire-and-forget; swallows DB errors.
44 */
45async function recordGateRun(opts: {
46 repositoryId: string;
47 pullRequestId?: string;
48 commitSha: string;
49 ref: string;
50 gateName: string;
51 status: "passed" | "failed" | "skipped" | "repaired";
52 summary: string;
53 details?: unknown;
54 repairAttempted?: boolean;
55 repairSucceeded?: boolean;
56 repairCommitSha?: string;
57 durationMs?: number;
58}): Promise<void> {
59 try {
60 await db.insert(gateRuns).values({
61 repositoryId: opts.repositoryId,
62 pullRequestId: opts.pullRequestId,
63 commitSha: opts.commitSha,
64 ref: opts.ref,
65 gateName: opts.gateName,
66 status: opts.status,
67 summary: opts.summary,
68 details: opts.details ? JSON.stringify(opts.details) : null,
69 repairAttempted: opts.repairAttempted ?? false,
70 repairSucceeded: opts.repairSucceeded ?? false,
71 repairCommitSha: opts.repairCommitSha,
72 durationMs: opts.durationMs,
73 completedAt: new Date(),
74 });
75 } catch (err) {
76 console.error("[gate] recordGateRun failed:", err);
77 }
78}
79
80/**
81 * Look up the repository row by owner/name.
82 */
83async function lookupRepo(
84 owner: string,
85 repo: string
86): Promise<{ id: string } | null> {
87 try {
88 const [u] = await db.select().from(users).where(eq(users.username, owner)).limit(1);
89 if (!u) return null;
90 const { and } = await import("drizzle-orm");
91 const [r] = await db
92 .select()
93 .from(repositories)
94 .where(and(eq(repositories.ownerId, u.id), eq(repositories.name, repo)))
95 .limit(1);
96 return r ? { id: r.id } : null;
97 } catch {
98 return null;
99 }
100}
101
e883329Claude102/**
103 * Run GateTest scan on a repository at a specific ref.
104 */
105export async function runGateTestScan(
106 owner: string,
107 repo: string,
108 ref: string,
109 headSha: string
110): Promise<GateCheckResult> {
111 if (!config.gatetestUrl) {
3ef4c9dClaude112 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped", skipped: true };
e883329Claude113 }
114
115 try {
3ef4c9dClaude116 const headers: Record<string, string> = { "Content-Type": "application/json" };
e883329Claude117 if (config.gatetestApiKey) {
118 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
119 }
120
121 const response = await fetch(config.gatetestUrl, {
122 method: "POST",
123 headers,
124 body: JSON.stringify({
125 repository: `${owner}/${repo}`,
126 ref,
127 sha: headSha,
128 source: "gluecron",
3ef4c9dClaude129 mode: "blocking",
e883329Claude130 }),
3ef4c9dClaude131 signal: AbortSignal.timeout(60_000),
e883329Claude132 });
133
134 if (!response.ok) {
135 const body = await response.text().catch(() => "");
136 return {
137 name: "GateTest",
138 passed: false,
139 details: `GateTest returned ${response.status}: ${body.slice(0, 200)}`,
140 };
141 }
142
3ef4c9dClaude143 const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
144 const passed =
145 result.passed === true || result.status === "passed" || result.status === "success";
146 const summary =
147 (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
148 return { name: "GateTest", passed, details: summary };
e883329Claude149 } catch (err) {
150 console.error("[gate] GateTest scan error:", err);
151 return {
152 name: "GateTest",
153 passed: false,
154 details: `GateTest scan failed: ${err instanceof Error ? err.message : "Unknown error"}`,
155 };
156 }
157}
158
159/**
160 * Check for merge conflicts between branches.
161 */
162export async function checkMergeability(
163 owner: string,
164 repo: string,
165 baseBranch: string,
166 headBranch: string
167): Promise<GateCheckResult> {
168 const { getRepoPath } = await import("../git/repository");
169 const repoDir = getRepoPath(owner, repo);
170
171 const ffCheck = Bun.spawn(
172 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
173 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
174 );
175 const ffExit = await ffCheck.exited;
176 if (ffExit === 0) {
177 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
178 }
179
180 const mergeBase = Bun.spawn(
181 ["git", "merge-base", baseBranch, headBranch],
182 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
183 );
184 const baseOut = await new Response(mergeBase.stdout).text();
185 const baseExit = await mergeBase.exited;
186
187 if (baseExit !== 0) {
188 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
189 }
190
191 const mergeTree = Bun.spawn(
192 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
193 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
194 );
195 const treeOut = await new Response(mergeTree.stdout).text();
196 await mergeTree.exited;
197
198 const hasConflicts = treeOut.includes("<<<<<<<");
199 return {
200 name: "Merge check",
201 passed: !hasConflicts,
202 details: hasConflicts
203 ? "Merge conflicts detected — auto-resolution will be attempted"
204 : "Clean merge possible",
205 };
206}
207
208/**
3ef4c9dClaude209 * Secret + security scan. Runs the regex scanner on files at the given ref,
210 * then optionally runs the AI semantic scan on the diff.
211 */
212export async function runSecretAndSecurityScan(
213 owner: string,
214 repo: string,
215 ref: string,
216 headSha: string,
217 opts: { scanSecrets: boolean; scanSecurity: boolean; diffText?: string }
218): Promise<{
219 secretResult: GateCheckResult;
220 securityResult: GateCheckResult;
221 secrets: SecretFinding[];
222 securityIssues: SecurityFinding[];
223}> {
224 const { getRepoPath, getTree, getBlob, listBranches } = await import("../git/repository");
225 const repoDir = getRepoPath(owner, repo);
226
227 // Snapshot top-level + one level deep files at the ref
228 const files: Array<{ path: string; content: string }> = [];
229 const branches = await listBranches(owner, repo);
230 const effectiveRef = branches.includes(ref.replace(/^refs\/heads\//, ""))
231 ? ref.replace(/^refs\/heads\//, "")
232 : headSha;
233
234 async function walk(dir: string, depth: number): Promise<void> {
235 if (depth > 4) return;
236 const tree = await getTree(owner, repo, effectiveRef, dir);
237 for (const entry of tree) {
238 const full = dir ? `${dir}/${entry.name}` : entry.name;
239 if (entry.type === "tree") {
240 await walk(full, depth + 1);
241 } else if (entry.type === "blob" && (entry.size ?? 0) < 200_000) {
242 try {
243 const blob = await getBlob(owner, repo, effectiveRef, full);
244 if (blob && !blob.isBinary) {
245 files.push({ path: full, content: blob.content });
246 }
247 } catch {
248 // skip
249 }
250 }
251 if (files.length >= 500) return;
252 }
253 }
254
255 try {
256 await walk("", 0);
257 } catch {
258 // Unable to walk — the ref may not exist yet. Bail gracefully.
259 }
260
261 const secrets = opts.scanSecrets ? scanForSecrets(files) : [];
262 const securityIssues =
263 opts.scanSecurity && opts.diffText ? await aiSecurityScan(`${owner}/${repo}`, opts.diffText) : [];
264
265 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
266 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
267
268 return {
269 secretResult: {
270 name: "Secret scan",
271 passed: criticalSecrets === 0,
272 details:
273 secrets.length === 0
274 ? "No secrets detected"
275 : `Found ${secrets.length} secret${secrets.length === 1 ? "" : "s"} (${criticalSecrets} critical)`,
276 },
277 securityResult: {
278 name: "Security scan",
279 passed: criticalSec === 0,
280 skipped: !opts.scanSecurity || !opts.diffText,
281 details:
282 securityIssues.length === 0
283 ? opts.scanSecurity && opts.diffText
284 ? "No security issues found"
285 : "Skipped — no diff provided"
286 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`,
287 },
288 secrets,
289 securityIssues,
290 };
291}
292
293/**
294 * Run every configured gate for a PR merge.
295 * Records gate_runs entries. Optionally invokes auto-repair.
e883329Claude296 */
297export async function runAllGateChecks(
298 owner: string,
299 repo: string,
300 baseBranch: string,
301 headBranch: string,
302 headSha: string,
3ef4c9dClaude303 aiReviewApproved: boolean,
304 opts: {
305 pullRequestId?: string;
306 enableAutoRepair?: boolean;
307 diffText?: string;
308 } = {}
e883329Claude309): Promise<GateResult> {
3ef4c9dClaude310 const started = Date.now();
311 const repoRow = await lookupRepo(owner, repo);
312 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
313
314 // Decide which gates to run
315 const runGateTest = settings?.gateTestEnabled !== false && !!config.gatetestUrl;
316 const runSecretScan = settings?.secretScanEnabled !== false;
317 const runSecurityScan = settings?.securityScanEnabled !== false;
318 const runAiReview = settings?.aiReviewEnabled !== false;
319 const enableRepair = opts.enableAutoRepair !== false && settings?.autoFixEnabled !== false;
e883329Claude320
6522084Claude321 // SSE: broadcast that gate checks are starting
322 const sseChannel = repoRow ? `gate:${repoRow.id}` : null;
323 if (sseChannel) {
324 broadcast(sseChannel, "gate:started", {
325 repoId: repoRow!.id,
326 prId: opts.pullRequestId,
327 sha: headSha,
328 gates: ["GateTest", "Secret scan", "Security scan", "Merge check", "AI Review"],
329 });
330 }
331
3ef4c9dClaude332 const [gateTestResult, mergeResult, scanResults] = await Promise.all([
333 runGateTest
334 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
335 : Promise.resolve<GateCheckResult>({
336 name: "GateTest",
337 passed: true,
338 skipped: true,
339 details: "Disabled in settings",
340 }),
e883329Claude341 checkMergeability(owner, repo, baseBranch, headBranch),
3ef4c9dClaude342 runSecretAndSecurityScan(owner, repo, `refs/heads/${headBranch}`, headSha, {
343 scanSecrets: runSecretScan,
344 scanSecurity: runSecurityScan,
345 diffText: opts.diffText,
346 }),
e883329Claude347 ]);
348
3ef4c9dClaude349 const checks: GateCheckResult[] = [gateTestResult];
350 checks.push(scanResults.secretResult);
351 if (runSecurityScan) checks.push(scanResults.securityResult);
e883329Claude352 checks.push(mergeResult);
353 checks.push({
354 name: "AI Review",
3ef4c9dClaude355 passed: !runAiReview || aiReviewApproved,
356 skipped: !runAiReview,
357 details: !runAiReview
358 ? "Disabled in settings"
359 : aiReviewApproved
360 ? "AI review approved"
361 : "AI review found blocking issues — resolve before merging",
e883329Claude362 });
363
3ef4c9dClaude364 // ---- Auto-repair on failures ----
365 if (enableRepair) {
366 // Secrets
367 if (!scanResults.secretResult.passed) {
368 const repair = await repairSecrets(owner, repo, headBranch, scanResults.secrets);
369 if (repair.success) {
370 scanResults.secretResult.passed = true;
371 scanResults.secretResult.repaired = true;
372 scanResults.secretResult.repairCommitSha = repair.commitSha;
373 scanResults.secretResult.details = `Auto-redacted ${scanResults.secrets.length} secret${scanResults.secrets.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
374 }
375 }
376 // Security
377 if (!scanResults.securityResult.passed && scanResults.securityIssues.length > 0) {
378 const repair = await repairSecurityIssues(
379 owner,
380 repo,
381 headBranch,
382 scanResults.securityIssues
383 );
384 if (repair.success) {
385 scanResults.securityResult.passed = true;
386 scanResults.securityResult.repaired = true;
387 scanResults.securityResult.repairCommitSha = repair.commitSha;
388 scanResults.securityResult.details = `Auto-repaired ${repair.filesChanged.length} file${repair.filesChanged.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
389 }
390 }
391 }
392
b2ff5c7Claude393 // Persist gate_runs + feed flywheel metrics
3ef4c9dClaude394 if (repoRow) {
395 const duration = Date.now() - started;
396 await Promise.all(
b2ff5c7Claude397 checks.map((check) => {
398 const status = check.skipped
399 ? ("skipped" as const)
400 : check.repaired
401 ? ("repaired" as const)
402 : check.passed
403 ? ("passed" as const)
404 : ("failed" as const);
405 return Promise.all([
406 recordGateRun({
407 repositoryId: repoRow.id,
408 pullRequestId: opts.pullRequestId,
409 commitSha: headSha,
410 ref: `refs/heads/${headBranch}`,
411 gateName: check.name,
412 status,
413 summary: check.details,
414 repairAttempted: !!check.repaired,
415 repairSucceeded: !!check.repaired,
416 repairCommitSha: check.repairCommitSha,
417 durationMs: duration,
418 }),
419 updateGateMetrics(repoRow.id, check.name, status, duration),
420 ]);
421 })
3ef4c9dClaude422 );
b2ff5c7Claude423
424 // Trigger pattern extraction periodically (every ~20 gate runs)
425 const runCount = checks.length;
426 if (runCount > 0 && Math.random() < 0.05) {
427 extractPatterns(repoRow.id).catch((err) =>
428 console.error("[flywheel] background pattern extraction failed:", err)
429 );
430 }
3ef4c9dClaude431 }
432
6522084Claude433 const result = {
3ef4c9dClaude434 allPassed: checks.every((c) => c.passed || c.skipped),
e883329Claude435 checks,
436 };
6522084Claude437
438 // SSE: broadcast gate completion with full results
439 if (sseChannel) {
440 broadcast(sseChannel, "gate:completed", {
441 repoId: repoRow!.id,
442 prId: opts.pullRequestId,
443 sha: headSha,
444 allPassed: result.allPassed,
445 checks: result.checks.map((c) => ({
446 name: c.name,
447 passed: c.passed,
448 skipped: c.skipped,
449 repaired: c.repaired,
450 details: c.details,
451 })),
452 durationMs: Date.now() - started,
453 });
454 }
455
456 return result;
e883329Claude457}