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

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.tsBlame425 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";
e883329Claude26
27export interface GateCheckResult {
28 name: string;
29 passed: boolean;
30 details: string;
3ef4c9dClaude31 skipped?: boolean;
32 repaired?: boolean;
33 repairCommitSha?: string;
e883329Claude34}
35
36export interface GateResult {
37 allPassed: boolean;
38 checks: GateCheckResult[];
39}
40
3ef4c9dClaude41/**
42 * Record a gate run in the DB. Fire-and-forget; swallows DB errors.
43 */
44async function recordGateRun(opts: {
45 repositoryId: string;
46 pullRequestId?: string;
47 commitSha: string;
48 ref: string;
49 gateName: string;
50 status: "passed" | "failed" | "skipped" | "repaired";
51 summary: string;
52 details?: unknown;
53 repairAttempted?: boolean;
54 repairSucceeded?: boolean;
55 repairCommitSha?: string;
56 durationMs?: number;
57}): Promise<void> {
58 try {
59 await db.insert(gateRuns).values({
60 repositoryId: opts.repositoryId,
61 pullRequestId: opts.pullRequestId,
62 commitSha: opts.commitSha,
63 ref: opts.ref,
64 gateName: opts.gateName,
65 status: opts.status,
66 summary: opts.summary,
67 details: opts.details ? JSON.stringify(opts.details) : null,
68 repairAttempted: opts.repairAttempted ?? false,
69 repairSucceeded: opts.repairSucceeded ?? false,
70 repairCommitSha: opts.repairCommitSha,
71 durationMs: opts.durationMs,
72 completedAt: new Date(),
73 });
74 } catch (err) {
75 console.error("[gate] recordGateRun failed:", err);
76 }
77}
78
79/**
80 * Look up the repository row by owner/name.
81 */
82async function lookupRepo(
83 owner: string,
84 repo: string
85): Promise<{ id: string } | null> {
86 try {
87 const [u] = await db.select().from(users).where(eq(users.username, owner)).limit(1);
88 if (!u) return null;
89 const { and } = await import("drizzle-orm");
90 const [r] = await db
91 .select()
92 .from(repositories)
93 .where(and(eq(repositories.ownerId, u.id), eq(repositories.name, repo)))
94 .limit(1);
95 return r ? { id: r.id } : null;
96 } catch {
97 return null;
98 }
99}
100
e883329Claude101/**
102 * Run GateTest scan on a repository at a specific ref.
103 */
104export async function runGateTestScan(
105 owner: string,
106 repo: string,
107 ref: string,
108 headSha: string
109): Promise<GateCheckResult> {
110 if (!config.gatetestUrl) {
3ef4c9dClaude111 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped", skipped: true };
e883329Claude112 }
113
114 try {
3ef4c9dClaude115 const headers: Record<string, string> = { "Content-Type": "application/json" };
e883329Claude116 if (config.gatetestApiKey) {
117 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
118 }
119
120 const response = await fetch(config.gatetestUrl, {
121 method: "POST",
122 headers,
123 body: JSON.stringify({
124 repository: `${owner}/${repo}`,
125 ref,
126 sha: headSha,
127 source: "gluecron",
3ef4c9dClaude128 mode: "blocking",
e883329Claude129 }),
3ef4c9dClaude130 signal: AbortSignal.timeout(60_000),
e883329Claude131 });
132
133 if (!response.ok) {
134 const body = await response.text().catch(() => "");
135 return {
136 name: "GateTest",
137 passed: false,
138 details: `GateTest returned ${response.status}: ${body.slice(0, 200)}`,
139 };
140 }
141
3ef4c9dClaude142 const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
143 const passed =
144 result.passed === true || result.status === "passed" || result.status === "success";
145 const summary =
146 (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
147 return { name: "GateTest", passed, details: summary };
e883329Claude148 } catch (err) {
149 console.error("[gate] GateTest scan error:", err);
150 return {
151 name: "GateTest",
152 passed: false,
153 details: `GateTest scan failed: ${err instanceof Error ? err.message : "Unknown error"}`,
154 };
155 }
156}
157
158/**
159 * Check for merge conflicts between branches.
160 */
161export async function checkMergeability(
162 owner: string,
163 repo: string,
164 baseBranch: string,
165 headBranch: string
166): Promise<GateCheckResult> {
167 const { getRepoPath } = await import("../git/repository");
168 const repoDir = getRepoPath(owner, repo);
169
170 const ffCheck = Bun.spawn(
171 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
172 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
173 );
174 const ffExit = await ffCheck.exited;
175 if (ffExit === 0) {
176 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
177 }
178
179 const mergeBase = Bun.spawn(
180 ["git", "merge-base", baseBranch, headBranch],
181 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
182 );
183 const baseOut = await new Response(mergeBase.stdout).text();
184 const baseExit = await mergeBase.exited;
185
186 if (baseExit !== 0) {
187 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
188 }
189
190 const mergeTree = Bun.spawn(
191 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
192 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
193 );
194 const treeOut = await new Response(mergeTree.stdout).text();
195 await mergeTree.exited;
196
197 const hasConflicts = treeOut.includes("<<<<<<<");
198 return {
199 name: "Merge check",
200 passed: !hasConflicts,
201 details: hasConflicts
202 ? "Merge conflicts detected — auto-resolution will be attempted"
203 : "Clean merge possible",
204 };
205}
206
207/**
3ef4c9dClaude208 * Secret + security scan. Runs the regex scanner on files at the given ref,
209 * then optionally runs the AI semantic scan on the diff.
210 */
211export async function runSecretAndSecurityScan(
212 owner: string,
213 repo: string,
214 ref: string,
215 headSha: string,
216 opts: { scanSecrets: boolean; scanSecurity: boolean; diffText?: string }
217): Promise<{
218 secretResult: GateCheckResult;
219 securityResult: GateCheckResult;
220 secrets: SecretFinding[];
221 securityIssues: SecurityFinding[];
222}> {
223 const { getRepoPath, getTree, getBlob, listBranches } = await import("../git/repository");
224 const repoDir = getRepoPath(owner, repo);
225
226 // Snapshot top-level + one level deep files at the ref
227 const files: Array<{ path: string; content: string }> = [];
228 const branches = await listBranches(owner, repo);
229 const effectiveRef = branches.includes(ref.replace(/^refs\/heads\//, ""))
230 ? ref.replace(/^refs\/heads\//, "")
231 : headSha;
232
233 async function walk(dir: string, depth: number): Promise<void> {
234 if (depth > 4) return;
235 const tree = await getTree(owner, repo, effectiveRef, dir);
236 for (const entry of tree) {
237 const full = dir ? `${dir}/${entry.name}` : entry.name;
238 if (entry.type === "tree") {
239 await walk(full, depth + 1);
240 } else if (entry.type === "blob" && (entry.size ?? 0) < 200_000) {
241 try {
242 const blob = await getBlob(owner, repo, effectiveRef, full);
243 if (blob && !blob.isBinary) {
244 files.push({ path: full, content: blob.content });
245 }
246 } catch {
247 // skip
248 }
249 }
250 if (files.length >= 500) return;
251 }
252 }
253
254 try {
255 await walk("", 0);
256 } catch {
257 // Unable to walk — the ref may not exist yet. Bail gracefully.
258 }
259
260 const secrets = opts.scanSecrets ? scanForSecrets(files) : [];
261 const securityIssues =
262 opts.scanSecurity && opts.diffText ? await aiSecurityScan(`${owner}/${repo}`, opts.diffText) : [];
263
264 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
265 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
266
267 return {
268 secretResult: {
269 name: "Secret scan",
270 passed: criticalSecrets === 0,
271 details:
272 secrets.length === 0
273 ? "No secrets detected"
274 : `Found ${secrets.length} secret${secrets.length === 1 ? "" : "s"} (${criticalSecrets} critical)`,
275 },
276 securityResult: {
277 name: "Security scan",
278 passed: criticalSec === 0,
279 skipped: !opts.scanSecurity || !opts.diffText,
280 details:
281 securityIssues.length === 0
282 ? opts.scanSecurity && opts.diffText
283 ? "No security issues found"
284 : "Skipped — no diff provided"
285 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`,
286 },
287 secrets,
288 securityIssues,
289 };
290}
291
292/**
293 * Run every configured gate for a PR merge.
294 * Records gate_runs entries. Optionally invokes auto-repair.
e883329Claude295 */
296export async function runAllGateChecks(
297 owner: string,
298 repo: string,
299 baseBranch: string,
300 headBranch: string,
301 headSha: string,
3ef4c9dClaude302 aiReviewApproved: boolean,
303 opts: {
304 pullRequestId?: string;
305 enableAutoRepair?: boolean;
306 diffText?: string;
307 } = {}
e883329Claude308): Promise<GateResult> {
3ef4c9dClaude309 const started = Date.now();
310 const repoRow = await lookupRepo(owner, repo);
311 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
312
313 // Decide which gates to run
314 const runGateTest = settings?.gateTestEnabled !== false && !!config.gatetestUrl;
315 const runSecretScan = settings?.secretScanEnabled !== false;
316 const runSecurityScan = settings?.securityScanEnabled !== false;
317 const runAiReview = settings?.aiReviewEnabled !== false;
318 const enableRepair = opts.enableAutoRepair !== false && settings?.autoFixEnabled !== false;
e883329Claude319
3ef4c9dClaude320 const [gateTestResult, mergeResult, scanResults] = await Promise.all([
321 runGateTest
322 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
323 : Promise.resolve<GateCheckResult>({
324 name: "GateTest",
325 passed: true,
326 skipped: true,
327 details: "Disabled in settings",
328 }),
e883329Claude329 checkMergeability(owner, repo, baseBranch, headBranch),
3ef4c9dClaude330 runSecretAndSecurityScan(owner, repo, `refs/heads/${headBranch}`, headSha, {
331 scanSecrets: runSecretScan,
332 scanSecurity: runSecurityScan,
333 diffText: opts.diffText,
334 }),
e883329Claude335 ]);
336
3ef4c9dClaude337 const checks: GateCheckResult[] = [gateTestResult];
338 checks.push(scanResults.secretResult);
339 if (runSecurityScan) checks.push(scanResults.securityResult);
e883329Claude340 checks.push(mergeResult);
341 checks.push({
342 name: "AI Review",
3ef4c9dClaude343 passed: !runAiReview || aiReviewApproved,
344 skipped: !runAiReview,
345 details: !runAiReview
346 ? "Disabled in settings"
347 : aiReviewApproved
348 ? "AI review approved"
349 : "AI review found blocking issues — resolve before merging",
e883329Claude350 });
351
3ef4c9dClaude352 // ---- Auto-repair on failures ----
353 if (enableRepair) {
354 // Secrets
355 if (!scanResults.secretResult.passed) {
356 const repair = await repairSecrets(owner, repo, headBranch, scanResults.secrets);
357 if (repair.success) {
358 scanResults.secretResult.passed = true;
359 scanResults.secretResult.repaired = true;
360 scanResults.secretResult.repairCommitSha = repair.commitSha;
361 scanResults.secretResult.details = `Auto-redacted ${scanResults.secrets.length} secret${scanResults.secrets.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
362 }
363 }
364 // Security
365 if (!scanResults.securityResult.passed && scanResults.securityIssues.length > 0) {
366 const repair = await repairSecurityIssues(
367 owner,
368 repo,
369 headBranch,
370 scanResults.securityIssues
371 );
372 if (repair.success) {
373 scanResults.securityResult.passed = true;
374 scanResults.securityResult.repaired = true;
375 scanResults.securityResult.repairCommitSha = repair.commitSha;
376 scanResults.securityResult.details = `Auto-repaired ${repair.filesChanged.length} file${repair.filesChanged.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
377 }
378 }
379 }
380
b2ff5c7Claude381 // Persist gate_runs + feed flywheel metrics
3ef4c9dClaude382 if (repoRow) {
383 const duration = Date.now() - started;
384 await Promise.all(
b2ff5c7Claude385 checks.map((check) => {
386 const status = check.skipped
387 ? ("skipped" as const)
388 : check.repaired
389 ? ("repaired" as const)
390 : check.passed
391 ? ("passed" as const)
392 : ("failed" as const);
393 return Promise.all([
394 recordGateRun({
395 repositoryId: repoRow.id,
396 pullRequestId: opts.pullRequestId,
397 commitSha: headSha,
398 ref: `refs/heads/${headBranch}`,
399 gateName: check.name,
400 status,
401 summary: check.details,
402 repairAttempted: !!check.repaired,
403 repairSucceeded: !!check.repaired,
404 repairCommitSha: check.repairCommitSha,
405 durationMs: duration,
406 }),
407 updateGateMetrics(repoRow.id, check.name, status, duration),
408 ]);
409 })
3ef4c9dClaude410 );
b2ff5c7Claude411
412 // Trigger pattern extraction periodically (every ~20 gate runs)
413 const runCount = checks.length;
414 if (runCount > 0 && Math.random() < 0.05) {
415 extractPatterns(repoRow.id).catch((err) =>
416 console.error("[flywheel] background pattern extraction failed:", err)
417 );
418 }
3ef4c9dClaude419 }
420
e883329Claude421 return {
3ef4c9dClaude422 allPassed: checks.every((c) => c.passed || c.skipped),
e883329Claude423 checks,
424 };
425}