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.tsBlame412 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";
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
e883329Claude100/**
101 * Run GateTest scan on a repository at a specific ref.
102 */
103export async function runGateTestScan(
104 owner: string,
105 repo: string,
106 ref: string,
107 headSha: string
108): Promise<GateCheckResult> {
109 if (!config.gatetestUrl) {
3ef4c9dClaude110 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped", skipped: true };
e883329Claude111 }
112
113 try {
3ef4c9dClaude114 const headers: Record<string, string> = { "Content-Type": "application/json" };
e883329Claude115 if (config.gatetestApiKey) {
116 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
117 }
118
119 const response = await fetch(config.gatetestUrl, {
120 method: "POST",
121 headers,
122 body: JSON.stringify({
123 repository: `${owner}/${repo}`,
124 ref,
125 sha: headSha,
126 source: "gluecron",
3ef4c9dClaude127 mode: "blocking",
e883329Claude128 }),
3ef4c9dClaude129 signal: AbortSignal.timeout(60_000),
e883329Claude130 });
131
132 if (!response.ok) {
133 const body = await response.text().catch(() => "");
134 return {
135 name: "GateTest",
136 passed: false,
137 details: `GateTest returned ${response.status}: ${body.slice(0, 200)}`,
138 };
139 }
140
3ef4c9dClaude141 const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
142 const passed =
143 result.passed === true || result.status === "passed" || result.status === "success";
144 const summary =
145 (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
146 return { name: "GateTest", passed, details: summary };
e883329Claude147 } catch (err) {
148 console.error("[gate] GateTest scan error:", err);
149 return {
150 name: "GateTest",
151 passed: false,
152 details: `GateTest scan failed: ${err instanceof Error ? err.message : "Unknown error"}`,
153 };
154 }
155}
156
157/**
158 * Check for merge conflicts between branches.
159 */
160export async function checkMergeability(
161 owner: string,
162 repo: string,
163 baseBranch: string,
164 headBranch: string
165): Promise<GateCheckResult> {
166 const { getRepoPath } = await import("../git/repository");
167 const repoDir = getRepoPath(owner, repo);
168
169 const ffCheck = Bun.spawn(
170 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
171 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
172 );
173 const ffExit = await ffCheck.exited;
174 if (ffExit === 0) {
175 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
176 }
177
178 const mergeBase = Bun.spawn(
179 ["git", "merge-base", baseBranch, headBranch],
180 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
181 );
182 const baseOut = await new Response(mergeBase.stdout).text();
183 const baseExit = await mergeBase.exited;
184
185 if (baseExit !== 0) {
186 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
187 }
188
189 const mergeTree = Bun.spawn(
190 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
191 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
192 );
193 const treeOut = await new Response(mergeTree.stdout).text();
194 await mergeTree.exited;
195
196 const hasConflicts = treeOut.includes("<<<<<<<");
197 return {
198 name: "Merge check",
199 passed: !hasConflicts,
200 details: hasConflicts
201 ? "Merge conflicts detected — auto-resolution will be attempted"
202 : "Clean merge possible",
203 };
204}
205
206/**
3ef4c9dClaude207 * Secret + security scan. Runs the regex scanner on files at the given ref,
208 * then optionally runs the AI semantic scan on the diff.
209 */
210export async function runSecretAndSecurityScan(
211 owner: string,
212 repo: string,
213 ref: string,
214 headSha: string,
215 opts: { scanSecrets: boolean; scanSecurity: boolean; diffText?: string }
216): Promise<{
217 secretResult: GateCheckResult;
218 securityResult: GateCheckResult;
219 secrets: SecretFinding[];
220 securityIssues: SecurityFinding[];
221}> {
222 const { getRepoPath, getTree, getBlob, listBranches } = await import("../git/repository");
223 const repoDir = getRepoPath(owner, repo);
224
225 // Snapshot top-level + one level deep files at the ref
226 const files: Array<{ path: string; content: string }> = [];
227 const branches = await listBranches(owner, repo);
228 const effectiveRef = branches.includes(ref.replace(/^refs\/heads\//, ""))
229 ? ref.replace(/^refs\/heads\//, "")
230 : headSha;
231
232 async function walk(dir: string, depth: number): Promise<void> {
233 if (depth > 4) return;
234 const tree = await getTree(owner, repo, effectiveRef, dir);
235 for (const entry of tree) {
236 const full = dir ? `${dir}/${entry.name}` : entry.name;
237 if (entry.type === "tree") {
238 await walk(full, depth + 1);
239 } else if (entry.type === "blob" && (entry.size ?? 0) < 200_000) {
240 try {
241 const blob = await getBlob(owner, repo, effectiveRef, full);
242 if (blob && !blob.isBinary) {
243 files.push({ path: full, content: blob.content });
244 }
245 } catch {
246 // skip
247 }
248 }
249 if (files.length >= 500) return;
250 }
251 }
252
253 try {
254 await walk("", 0);
255 } catch {
256 // Unable to walk — the ref may not exist yet. Bail gracefully.
257 }
258
259 const secrets = opts.scanSecrets ? scanForSecrets(files) : [];
260 const securityIssues =
261 opts.scanSecurity && opts.diffText ? await aiSecurityScan(`${owner}/${repo}`, opts.diffText) : [];
262
263 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
264 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
265
266 return {
267 secretResult: {
268 name: "Secret scan",
269 passed: criticalSecrets === 0,
270 details:
271 secrets.length === 0
272 ? "No secrets detected"
273 : `Found ${secrets.length} secret${secrets.length === 1 ? "" : "s"} (${criticalSecrets} critical)`,
274 },
275 securityResult: {
276 name: "Security scan",
277 passed: criticalSec === 0,
278 skipped: !opts.scanSecurity || !opts.diffText,
279 details:
280 securityIssues.length === 0
281 ? opts.scanSecurity && opts.diffText
282 ? "No security issues found"
283 : "Skipped — no diff provided"
284 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`,
285 },
286 secrets,
287 securityIssues,
288 };
289}
290
291/**
292 * Run every configured gate for a PR merge.
293 * Records gate_runs entries. Optionally invokes auto-repair.
e883329Claude294 */
295export async function runAllGateChecks(
296 owner: string,
297 repo: string,
298 baseBranch: string,
299 headBranch: string,
300 headSha: string,
3ef4c9dClaude301 aiReviewApproved: boolean,
302 opts: {
303 pullRequestId?: string;
304 enableAutoRepair?: boolean;
305 diffText?: string;
306 } = {}
e883329Claude307): Promise<GateResult> {
3ef4c9dClaude308 const started = Date.now();
309 const repoRow = await lookupRepo(owner, repo);
310 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
311
312 // Decide which gates to run
313 const runGateTest = settings?.gateTestEnabled !== false && !!config.gatetestUrl;
314 const runSecretScan = settings?.secretScanEnabled !== false;
315 const runSecurityScan = settings?.securityScanEnabled !== false;
316 const runAiReview = settings?.aiReviewEnabled !== false;
317 const enableRepair = opts.enableAutoRepair !== false && settings?.autoFixEnabled !== false;
e883329Claude318
3ef4c9dClaude319 const [gateTestResult, mergeResult, scanResults] = await Promise.all([
320 runGateTest
321 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
322 : Promise.resolve<GateCheckResult>({
323 name: "GateTest",
324 passed: true,
325 skipped: true,
326 details: "Disabled in settings",
327 }),
e883329Claude328 checkMergeability(owner, repo, baseBranch, headBranch),
3ef4c9dClaude329 runSecretAndSecurityScan(owner, repo, `refs/heads/${headBranch}`, headSha, {
330 scanSecrets: runSecretScan,
331 scanSecurity: runSecurityScan,
332 diffText: opts.diffText,
333 }),
e883329Claude334 ]);
335
3ef4c9dClaude336 const checks: GateCheckResult[] = [gateTestResult];
337 checks.push(scanResults.secretResult);
338 if (runSecurityScan) checks.push(scanResults.securityResult);
e883329Claude339 checks.push(mergeResult);
340 checks.push({
341 name: "AI Review",
3ef4c9dClaude342 passed: !runAiReview || aiReviewApproved,
343 skipped: !runAiReview,
344 details: !runAiReview
345 ? "Disabled in settings"
346 : aiReviewApproved
347 ? "AI review approved"
348 : "AI review found blocking issues — resolve before merging",
e883329Claude349 });
350
3ef4c9dClaude351 // ---- Auto-repair on failures ----
352 if (enableRepair) {
353 // Secrets
354 if (!scanResults.secretResult.passed) {
355 const repair = await repairSecrets(owner, repo, headBranch, scanResults.secrets);
356 if (repair.success) {
357 scanResults.secretResult.passed = true;
358 scanResults.secretResult.repaired = true;
359 scanResults.secretResult.repairCommitSha = repair.commitSha;
360 scanResults.secretResult.details = `Auto-redacted ${scanResults.secrets.length} secret${scanResults.secrets.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
361 }
362 }
363 // Security
364 if (!scanResults.securityResult.passed && scanResults.securityIssues.length > 0) {
365 const repair = await repairSecurityIssues(
366 owner,
367 repo,
368 headBranch,
369 scanResults.securityIssues
370 );
371 if (repair.success) {
372 scanResults.securityResult.passed = true;
373 scanResults.securityResult.repaired = true;
374 scanResults.securityResult.repairCommitSha = repair.commitSha;
375 scanResults.securityResult.details = `Auto-repaired ${repair.filesChanged.length} file${repair.filesChanged.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
376 }
377 }
378 }
379
380 // Persist gate_runs
381 if (repoRow) {
382 const duration = Date.now() - started;
383 await Promise.all(
384 checks.map((check) =>
385 recordGateRun({
386 repositoryId: repoRow.id,
387 pullRequestId: opts.pullRequestId,
388 commitSha: headSha,
389 ref: `refs/heads/${headBranch}`,
390 gateName: check.name,
391 status: check.skipped
392 ? "skipped"
393 : check.repaired
394 ? "repaired"
395 : check.passed
396 ? "passed"
397 : "failed",
398 summary: check.details,
399 repairAttempted: !!check.repaired,
400 repairSucceeded: !!check.repaired,
401 repairCommitSha: check.repairCommitSha,
402 durationMs: duration,
403 })
404 )
405 );
406 }
407
e883329Claude408 return {
3ef4c9dClaude409 allPassed: checks.every((c) => c.passed || c.skipped),
e883329Claude410 checks,
411 };
412}