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.tsBlame513 lines · 3 contributors
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";
e2206a6Test User20import { scanForSecrets, aiSecurityScanSafe } from "./security-scan";
3ef4c9dClaude21import type { SecretFinding, SecurityFinding } from "./security-scan";
22import { repairSecrets, repairSecurityIssues } from "./auto-repair";
23import { readFile } from "fs/promises";
24import { join } from "path";
e883329Claude25
26export interface GateCheckResult {
27 name: string;
28 passed: boolean;
29 details: string;
3ef4c9dClaude30 skipped?: boolean;
31 repaired?: boolean;
32 repairCommitSha?: string;
e883329Claude33}
34
35export interface GateResult {
36 allPassed: boolean;
37 checks: GateCheckResult[];
38}
39
3ef4c9dClaude40/**
41 * Record a gate run in the DB. Fire-and-forget; swallows DB errors.
42 */
43async function recordGateRun(opts: {
44 repositoryId: string;
45 pullRequestId?: string;
46 commitSha: string;
47 ref: string;
48 gateName: string;
49 status: "passed" | "failed" | "skipped" | "repaired";
50 summary: string;
51 details?: unknown;
52 repairAttempted?: boolean;
53 repairSucceeded?: boolean;
54 repairCommitSha?: string;
55 durationMs?: number;
56}): Promise<void> {
57 try {
58 await db.insert(gateRuns).values({
59 repositoryId: opts.repositoryId,
60 pullRequestId: opts.pullRequestId,
61 commitSha: opts.commitSha,
62 ref: opts.ref,
63 gateName: opts.gateName,
64 status: opts.status,
65 summary: opts.summary,
66 details: opts.details ? JSON.stringify(opts.details) : null,
67 repairAttempted: opts.repairAttempted ?? false,
68 repairSucceeded: opts.repairSucceeded ?? false,
69 repairCommitSha: opts.repairCommitSha,
70 durationMs: opts.durationMs,
71 completedAt: new Date(),
72 });
73 } catch (err) {
74 console.error("[gate] recordGateRun failed:", err);
75 }
76}
77
78/**
79 * Look up the repository row by owner/name.
80 */
81async function lookupRepo(
82 owner: string,
83 repo: string
84): Promise<{ id: string } | null> {
85 try {
86 const [u] = await db.select().from(users).where(eq(users.username, owner)).limit(1);
87 if (!u) return null;
88 const { and } = await import("drizzle-orm");
89 const [r] = await db
90 .select()
91 .from(repositories)
92 .where(and(eq(repositories.ownerId, u.id), eq(repositories.name, repo)))
93 .limit(1);
94 return r ? { id: r.id } : null;
95 } catch {
96 return null;
97 }
98}
99
170ddb2Claude100/**
101 * Fire-and-forget post-receive notification to GateTest.
102 *
103 * Called from `src/hooks/post-receive.ts` for every push when
104 * `GATETEST_URL` is set. Unlike `runGateTestScan` (which is used by the
105 * PR merge gate and AWAITS a blocking result), this helper just notifies
106 * GateTest that a push happened and lets GateTest POST results back via
107 * the existing inbound webhook at `/api/hooks/gatetest`. Never throws,
108 * never blocks the push. 10-second timeout so a slow GateTest endpoint
109 * can't hang the post-receive hook chain.
110 *
111 * Was a stub before 2026-05-16; `src/hooks/post-receive.ts:80` had the
112 * comment "triggerGateTest helper is slated for the intelligence
113 * rework" but nothing called it. CLAUDE.md claims "git push POSTs to
114 * it" — this restores that contract.
115 */
976d7f7Claude116// One-shot warning latch — operators only need to be told once that
117// GateTest is misconfigured. Reset between tests via the export below.
118let _gatetestAuthWarned = false;
119export function _resetGateTestAuthWarning(): void {
120 _gatetestAuthWarned = false;
121}
122
170ddb2Claude123export async function notifyGateTestOfPush(
124 owner: string,
125 repo: string,
126 ref: string,
127 headSha: string
128): Promise<void> {
129 if (!process.env.GATETEST_URL) return;
130 try {
131 const headers: Record<string, string> = {
132 "Content-Type": "application/json",
133 };
134 if (config.gatetestApiKey) {
135 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
976d7f7Claude136 } else if (!_gatetestAuthWarned) {
137 // GATETEST_URL is set but GATETEST_API_KEY is missing. GateTest
138 // rejects unauthenticated requests with 401 — the scan never
139 // actually runs but the push looks like it fired. Warn once so
140 // operators notice (silent-fail audit, item §4 in AUDIT-v2.md).
141 _gatetestAuthWarned = true;
142 console.warn(
143 "[gatetest] GATETEST_URL is set but GATETEST_API_KEY is empty — push notifications will be rejected by GateTest with 401. Set GATETEST_API_KEY in the deploy environment to enable scans."
144 );
170ddb2Claude145 }
146 const res = await fetch(config.gatetestUrl, {
147 method: "POST",
148 headers,
149 body: JSON.stringify({
150 repository: `${owner}/${repo}`,
151 ref,
152 sha: headSha,
153 source: "gluecron",
154 mode: "async",
155 }),
156 signal: AbortSignal.timeout(10_000),
157 });
158 if (!res.ok) {
159 const body = await res.text().catch(() => "");
160 console.warn(
161 `[gatetest] push notify returned ${res.status} for ${owner}/${repo}@${headSha.slice(0, 7)}: ${body.slice(0, 200)}`
162 );
163 }
164 } catch (err) {
165 console.warn(
166 `[gatetest] push notify failed for ${owner}/${repo}@${headSha.slice(0, 7)}:`,
167 err instanceof Error ? err.message : err
168 );
169 }
170}
171
e883329Claude172/**
173 * Run GateTest scan on a repository at a specific ref.
174 */
175export async function runGateTestScan(
176 owner: string,
177 repo: string,
178 ref: string,
179 headSha: string
180): Promise<GateCheckResult> {
181 if (!config.gatetestUrl) {
3ef4c9dClaude182 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped", skipped: true };
e883329Claude183 }
184
185 try {
3ef4c9dClaude186 const headers: Record<string, string> = { "Content-Type": "application/json" };
e883329Claude187 if (config.gatetestApiKey) {
188 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
189 }
190
191 const response = await fetch(config.gatetestUrl, {
192 method: "POST",
193 headers,
194 body: JSON.stringify({
195 repository: `${owner}/${repo}`,
196 ref,
197 sha: headSha,
198 source: "gluecron",
3ef4c9dClaude199 mode: "blocking",
e883329Claude200 }),
3ef4c9dClaude201 signal: AbortSignal.timeout(60_000),
e883329Claude202 });
203
204 if (!response.ok) {
6930df0ccanty labs205 // A non-2xx here means GateTest itself is unreachable/broken (5xx,
206 // auth misconfig, etc) — that is an availability failure of a
207 // third-party dependency, not a scan verdict on the code. Treat it
208 // like "not configured": skip (don't block merges platform-wide on
209 // an external outage) but keep the detail visible so it's not silent.
e883329Claude210 const body = await response.text().catch(() => "");
211 return {
212 name: "GateTest",
6930df0ccanty labs213 passed: true,
214 skipped: true,
215 details: `GateTest unavailable (HTTP ${response.status}) — scan skipped, not blocking: ${body.slice(0, 200)}`,
e883329Claude216 };
217 }
218
3ef4c9dClaude219 const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
220 const passed =
221 result.passed === true || result.status === "passed" || result.status === "success";
222 const summary =
223 (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
224 return { name: "GateTest", passed, details: summary };
e883329Claude225 } catch (err) {
6930df0ccanty labs226 // Network error / timeout — same reasoning as the non-2xx branch above.
e883329Claude227 console.error("[gate] GateTest scan error:", err);
228 return {
229 name: "GateTest",
6930df0ccanty labs230 passed: true,
231 skipped: true,
232 details: `GateTest unreachable — scan skipped, not blocking: ${err instanceof Error ? err.message : "Unknown error"}`,
e883329Claude233 };
234 }
235}
236
237/**
238 * Check for merge conflicts between branches.
239 */
240export async function checkMergeability(
241 owner: string,
242 repo: string,
243 baseBranch: string,
244 headBranch: string
245): Promise<GateCheckResult> {
246 const { getRepoPath } = await import("../git/repository");
247 const repoDir = getRepoPath(owner, repo);
248
6f1fd83Claude249 function spawnGit(args: string[]) {
250 try {
251 return Bun.spawn(["git", ...args], { cwd: repoDir, stdout: "pipe", stderr: "pipe" });
252 } catch {
253 return null;
254 }
255 }
256
257 const ffCheck = spawnGit(["merge-base", "--is-ancestor", baseBranch, headBranch]);
258 if (!ffCheck) {
259 return { name: "Merge check", passed: false, skipped: true, details: "Repository not accessible" };
260 }
e883329Claude261 const ffExit = await ffCheck.exited;
262 if (ffExit === 0) {
263 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
264 }
265
6f1fd83Claude266 const mergeBase = spawnGit(["merge-base", baseBranch, headBranch]);
267 if (!mergeBase) {
268 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
269 }
e883329Claude270 const baseOut = await new Response(mergeBase.stdout).text();
271 const baseExit = await mergeBase.exited;
272
273 if (baseExit !== 0) {
274 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
275 }
276
6f1fd83Claude277 const mergeTree = spawnGit(["merge-tree", baseOut.trim(), baseBranch, headBranch]);
278 if (!mergeTree) {
279 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
280 }
e883329Claude281 const treeOut = await new Response(mergeTree.stdout).text();
282 await mergeTree.exited;
283
284 const hasConflicts = treeOut.includes("<<<<<<<");
285 return {
286 name: "Merge check",
287 passed: !hasConflicts,
288 details: hasConflicts
289 ? "Merge conflicts detected — auto-resolution will be attempted"
290 : "Clean merge possible",
291 };
292}
293
294/**
3ef4c9dClaude295 * Secret + security scan. Runs the regex scanner on files at the given ref,
296 * then optionally runs the AI semantic scan on the diff.
297 */
298export async function runSecretAndSecurityScan(
299 owner: string,
300 repo: string,
301 ref: string,
302 headSha: string,
303 opts: { scanSecrets: boolean; scanSecurity: boolean; diffText?: string }
304): Promise<{
305 secretResult: GateCheckResult;
306 securityResult: GateCheckResult;
307 secrets: SecretFinding[];
308 securityIssues: SecurityFinding[];
309}> {
310 const { getRepoPath, getTree, getBlob, listBranches } = await import("../git/repository");
311 const repoDir = getRepoPath(owner, repo);
312
313 // Snapshot top-level + one level deep files at the ref
314 const files: Array<{ path: string; content: string }> = [];
315 const branches = await listBranches(owner, repo);
316 const effectiveRef = branches.includes(ref.replace(/^refs\/heads\//, ""))
317 ? ref.replace(/^refs\/heads\//, "")
318 : headSha;
319
320 async function walk(dir: string, depth: number): Promise<void> {
321 if (depth > 4) return;
322 const tree = await getTree(owner, repo, effectiveRef, dir);
323 for (const entry of tree) {
324 const full = dir ? `${dir}/${entry.name}` : entry.name;
325 if (entry.type === "tree") {
326 await walk(full, depth + 1);
327 } else if (entry.type === "blob" && (entry.size ?? 0) < 200_000) {
328 try {
329 const blob = await getBlob(owner, repo, effectiveRef, full);
330 if (blob && !blob.isBinary) {
331 files.push({ path: full, content: blob.content });
332 }
333 } catch {
334 // skip
335 }
336 }
337 if (files.length >= 500) return;
338 }
339 }
340
341 try {
342 await walk("", 0);
343 } catch {
344 // Unable to walk — the ref may not exist yet. Bail gracefully.
345 }
346
347 const secrets = opts.scanSecrets ? scanForSecrets(files) : [];
e2206a6Test User348 const aiOutcome =
349 opts.scanSecurity && opts.diffText
350 ? await aiSecurityScanSafe(`${owner}/${repo}`, opts.diffText)
351 : { findings: [], skipped: true as const };
352 const securityIssues = aiOutcome.findings;
3ef4c9dClaude353
354 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
355 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
356
e2206a6Test User357 // Three distinguishable states, not two: "not configured" (no scanSecurity/
358 // diffText), "configured but the AI provider errored/timed out" (aiOutcome.
359 // skipped, with an error we don't silently swallow), and "ran and returned
360 // a real verdict" (skipped: false — the only case allowed to say "No
361 // security issues found", since that string previously also covered a
362 // provider outage indistinguishably).
363 const notConfigured = !opts.scanSecurity || !opts.diffText;
364 const securityDetails = notConfigured
365 ? "Skipped — no diff provided"
366 : aiOutcome.skipped
367 ? `AI security scan unavailable — scan skipped, not blocking: ${aiOutcome.error ?? "unknown error"}`
368 : securityIssues.length === 0
369 ? "No security issues found"
370 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`;
371
3ef4c9dClaude372 return {
373 secretResult: {
374 name: "Secret scan",
375 passed: criticalSecrets === 0,
376 details:
377 secrets.length === 0
378 ? "No secrets detected"
379 : `Found ${secrets.length} secret${secrets.length === 1 ? "" : "s"} (${criticalSecrets} critical)`,
380 },
381 securityResult: {
382 name: "Security scan",
383 passed: criticalSec === 0,
e2206a6Test User384 skipped: notConfigured || aiOutcome.skipped,
385 details: securityDetails,
3ef4c9dClaude386 },
387 secrets,
388 securityIssues,
389 };
390}
391
392/**
393 * Run every configured gate for a PR merge.
394 * Records gate_runs entries. Optionally invokes auto-repair.
e883329Claude395 */
396export async function runAllGateChecks(
397 owner: string,
398 repo: string,
399 baseBranch: string,
400 headBranch: string,
401 headSha: string,
3ef4c9dClaude402 aiReviewApproved: boolean,
403 opts: {
404 pullRequestId?: string;
405 enableAutoRepair?: boolean;
406 diffText?: string;
407 } = {}
e883329Claude408): Promise<GateResult> {
3ef4c9dClaude409 const started = Date.now();
410 const repoRow = await lookupRepo(owner, repo);
411 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
412
413 // Decide which gates to run
414 const runGateTest = settings?.gateTestEnabled !== false && !!config.gatetestUrl;
415 const runSecretScan = settings?.secretScanEnabled !== false;
416 const runSecurityScan = settings?.securityScanEnabled !== false;
417 const runAiReview = settings?.aiReviewEnabled !== false;
418 const enableRepair = opts.enableAutoRepair !== false && settings?.autoFixEnabled !== false;
e883329Claude419
3ef4c9dClaude420 const [gateTestResult, mergeResult, scanResults] = await Promise.all([
421 runGateTest
422 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
423 : Promise.resolve<GateCheckResult>({
424 name: "GateTest",
425 passed: true,
426 skipped: true,
427 details: "Disabled in settings",
428 }),
e883329Claude429 checkMergeability(owner, repo, baseBranch, headBranch),
3ef4c9dClaude430 runSecretAndSecurityScan(owner, repo, `refs/heads/${headBranch}`, headSha, {
431 scanSecrets: runSecretScan,
432 scanSecurity: runSecurityScan,
433 diffText: opts.diffText,
434 }),
e883329Claude435 ]);
436
3ef4c9dClaude437 const checks: GateCheckResult[] = [gateTestResult];
438 checks.push(scanResults.secretResult);
439 if (runSecurityScan) checks.push(scanResults.securityResult);
e883329Claude440 checks.push(mergeResult);
441 checks.push({
442 name: "AI Review",
3ef4c9dClaude443 passed: !runAiReview || aiReviewApproved,
444 skipped: !runAiReview,
445 details: !runAiReview
446 ? "Disabled in settings"
447 : aiReviewApproved
448 ? "AI review approved"
449 : "AI review found blocking issues — resolve before merging",
e883329Claude450 });
451
3ef4c9dClaude452 // ---- Auto-repair on failures ----
453 if (enableRepair) {
454 // Secrets
455 if (!scanResults.secretResult.passed) {
456 const repair = await repairSecrets(owner, repo, headBranch, scanResults.secrets);
457 if (repair.success) {
458 scanResults.secretResult.passed = true;
459 scanResults.secretResult.repaired = true;
460 scanResults.secretResult.repairCommitSha = repair.commitSha;
461 scanResults.secretResult.details = `Auto-redacted ${scanResults.secrets.length} secret${scanResults.secrets.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
462 }
463 }
464 // Security
465 if (!scanResults.securityResult.passed && scanResults.securityIssues.length > 0) {
466 const repair = await repairSecurityIssues(
467 owner,
468 repo,
469 headBranch,
470 scanResults.securityIssues
471 );
472 if (repair.success) {
473 scanResults.securityResult.passed = true;
474 scanResults.securityResult.repaired = true;
475 scanResults.securityResult.repairCommitSha = repair.commitSha;
476 scanResults.securityResult.details = `Auto-repaired ${repair.filesChanged.length} file${repair.filesChanged.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
477 }
478 }
479 }
480
481 // Persist gate_runs
482 if (repoRow) {
483 const duration = Date.now() - started;
484 await Promise.all(
485 checks.map((check) =>
486 recordGateRun({
487 repositoryId: repoRow.id,
488 pullRequestId: opts.pullRequestId,
489 commitSha: headSha,
490 ref: `refs/heads/${headBranch}`,
491 gateName: check.name,
492 status: check.skipped
493 ? "skipped"
494 : check.repaired
495 ? "repaired"
496 : check.passed
497 ? "passed"
498 : "failed",
499 summary: check.details,
500 repairAttempted: !!check.repaired,
501 repairSucceeded: !!check.repaired,
502 repairCommitSha: check.repairCommitSha,
503 durationMs: duration,
504 })
505 )
506 );
507 }
508
e883329Claude509 return {
3ef4c9dClaude510 allPassed: checks.every((c) => c.passed || c.skipped),
e883329Claude511 checks,
512 };
513}