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.
| e883329 | 1 | /** |
| 2 | * Green gate enforcement. | |
| 3 | * | |
| 4 | * Checks that all quality gates pass before a merge is allowed: | |
| 5 | * 1. GateTest scan — runs automated tests/checks via the GateTest API | |
| 6 | * 2. AI code review — must be approved (no blocking issues) | |
| 7 | * | |
| 8 | * Nothing ships unless everything is green. | |
| 9 | */ | |
| 10 | ||
| 11 | import { config } from "./config"; | |
| 12 | ||
| 13 | export interface GateCheckResult { | |
| 14 | name: string; | |
| 15 | passed: boolean; | |
| 16 | details: string; | |
| 17 | } | |
| 18 | ||
| 19 | export interface GateResult { | |
| 20 | allPassed: boolean; | |
| 21 | checks: GateCheckResult[]; | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * Run GateTest scan on a repository at a specific ref. | |
| 26 | * Returns pass/fail with details. | |
| 27 | */ | |
| 28 | export async function runGateTestScan( | |
| 29 | owner: string, | |
| 30 | repo: string, | |
| 31 | ref: string, | |
| 32 | headSha: string | |
| 33 | ): Promise<GateCheckResult> { | |
| 34 | if (!config.gatetestUrl) { | |
| 35 | return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped" }; | |
| 36 | } | |
| 37 | ||
| 38 | try { | |
| 39 | const headers: Record<string, string> = { | |
| 40 | "Content-Type": "application/json", | |
| 41 | }; | |
| 42 | if (config.gatetestApiKey) { | |
| 43 | headers["Authorization"] = `Bearer ${config.gatetestApiKey}`; | |
| 44 | } | |
| 45 | ||
| 46 | const response = await fetch(config.gatetestUrl, { | |
| 47 | method: "POST", | |
| 48 | headers, | |
| 49 | body: JSON.stringify({ | |
| 50 | repository: `${owner}/${repo}`, | |
| 51 | ref, | |
| 52 | sha: headSha, | |
| 53 | source: "gluecron", | |
| 54 | mode: "blocking", // Wait for results instead of fire-and-forget | |
| 55 | }), | |
| 56 | }); | |
| 57 | ||
| 58 | if (!response.ok) { | |
| 59 | const body = await response.text().catch(() => ""); | |
| 60 | return { | |
| 61 | name: "GateTest", | |
| 62 | passed: false, | |
| 63 | details: `GateTest returned ${response.status}: ${body.slice(0, 200)}`, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | const result = await response.json().catch(() => ({})) as Record<string, unknown>; | |
| 68 | ||
| 69 | // GateTest API returns { passed: boolean, summary: string, issues: [...] } | |
| 70 | const passed = result.passed === true || result.status === "passed" || result.status === "success"; | |
| 71 | const summary = (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed"); | |
| 72 | ||
| 73 | return { | |
| 74 | name: "GateTest", | |
| 75 | passed, | |
| 76 | details: summary, | |
| 77 | }; | |
| 78 | } catch (err) { | |
| 79 | console.error("[gate] GateTest scan error:", err); | |
| 80 | return { | |
| 81 | name: "GateTest", | |
| 82 | passed: false, | |
| 83 | details: `GateTest scan failed: ${err instanceof Error ? err.message : "Unknown error"}`, | |
| 84 | }; | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Check for merge conflicts between branches. | |
| 90 | */ | |
| 91 | export async function checkMergeability( | |
| 92 | owner: string, | |
| 93 | repo: string, | |
| 94 | baseBranch: string, | |
| 95 | headBranch: string | |
| 96 | ): Promise<GateCheckResult> { | |
| 97 | const { getRepoPath } = await import("../git/repository"); | |
| 98 | const repoDir = getRepoPath(owner, repo); | |
| 99 | ||
| 100 | const proc = Bun.spawn( | |
| 101 | ["git", "merge-tree", `$(git merge-base ${baseBranch} ${headBranch})`, baseBranch, headBranch], | |
| 102 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 103 | ); | |
| 104 | // merge-tree isn't ideal — use merge --no-commit in a worktree style check | |
| 105 | await proc.exited; | |
| 106 | ||
| 107 | // Simpler: check if merge-base --is-ancestor works (fast-forward possible) | |
| 108 | const ffCheck = Bun.spawn( | |
| 109 | ["git", "merge-base", "--is-ancestor", baseBranch, headBranch], | |
| 110 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 111 | ); | |
| 112 | const ffExit = await ffCheck.exited; | |
| 113 | ||
| 114 | if (ffExit === 0) { | |
| 115 | return { name: "Merge check", passed: true, details: "Fast-forward merge possible" }; | |
| 116 | } | |
| 117 | ||
| 118 | // Check if there would be conflicts | |
| 119 | const mergeBase = Bun.spawn( | |
| 120 | ["git", "merge-base", baseBranch, headBranch], | |
| 121 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 122 | ); | |
| 123 | const baseOut = await new Response(mergeBase.stdout).text(); | |
| 124 | const baseExit = await mergeBase.exited; | |
| 125 | ||
| 126 | if (baseExit !== 0) { | |
| 127 | return { name: "Merge check", passed: false, details: "Branches have no common ancestor" }; | |
| 128 | } | |
| 129 | ||
| 130 | // Use merge-tree (three-way) to detect conflicts without touching working tree | |
| 131 | const mergeTree = Bun.spawn( | |
| 132 | ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch], | |
| 133 | { cwd: repoDir, stdout: "pipe", stderr: "pipe" } | |
| 134 | ); | |
| 135 | const treeOut = await new Response(mergeTree.stdout).text(); | |
| 136 | await mergeTree.exited; | |
| 137 | ||
| 138 | const hasConflicts = treeOut.includes("<<<<<<<"); | |
| 139 | ||
| 140 | return { | |
| 141 | name: "Merge check", | |
| 142 | passed: !hasConflicts, | |
| 143 | details: hasConflicts | |
| 144 | ? "Merge conflicts detected — auto-resolution will be attempted" | |
| 145 | : "Clean merge possible", | |
| 146 | }; | |
| 147 | } | |
| 148 | ||
| 149 | /** | |
| 150 | * Run all gate checks for a PR merge. | |
| 151 | */ | |
| 152 | export async function runAllGateChecks( | |
| 153 | owner: string, | |
| 154 | repo: string, | |
| 155 | baseBranch: string, | |
| 156 | headBranch: string, | |
| 157 | headSha: string, | |
| 158 | aiReviewApproved: boolean | |
| 159 | ): Promise<GateResult> { | |
| 160 | const checks: GateCheckResult[] = []; | |
| 161 | ||
| 162 | // Run GateTest and mergeability check in parallel | |
| 163 | const [gateTestResult, mergeResult] = await Promise.all([ | |
| 164 | runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha), | |
| 165 | checkMergeability(owner, repo, baseBranch, headBranch), | |
| 166 | ]); | |
| 167 | ||
| 168 | checks.push(gateTestResult); | |
| 169 | checks.push(mergeResult); | |
| 170 | ||
| 171 | // AI review check | |
| 172 | checks.push({ | |
| 173 | name: "AI Review", | |
| 174 | passed: aiReviewApproved, | |
| 175 | details: aiReviewApproved | |
| 176 | ? "AI review approved" | |
| 177 | : "AI review found blocking issues — resolve before merging", | |
| 178 | }); | |
| 179 | ||
| 180 | return { | |
| 181 | allPassed: checks.every((c) => c.passed), | |
| 182 | checks, | |
| 183 | }; | |
| 184 | } |