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

branch-protection.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.

branch-protection.tsBlame312 lines · 2 contributors
1e162a8Claude1/**
2 * Block D5 — Branch-protection enforcement helpers.
3 *
4 * The `branch_protection` table lets owners configure per-pattern rules. Until
5 * now those rules were mostly advisory — `runAllGateChecks` read the repo-
6 * global `repoSettings` for enable flags, and the merge handler only rejected
7 * on gate-level hard failures. This module:
8 *
9 * 1. Matches a branch name against the list of protection rules for a repo
10 * (supports `*` / `**` globs via shared matcher).
11 * 2. Evaluates the matched rule against merge-time context (AI approval,
12 * human approvals, gate result) and returns a pass/fail decision with
13 * human-readable reasons.
14 *
15 * Kept minimal: no throwing, no side effects.
16 */
17
a79a9edClaude18import { and, desc, eq } from "drizzle-orm";
1e162a8Claude19import { db } from "../db";
a79a9edClaude20import {
21 branchProtection,
22 branchRequiredChecks,
23 gateRuns,
24 prComments,
0a67773Claude25 prReviews,
a79a9edClaude26 workflowRuns,
27 workflows,
28} from "../db/schema";
29import type { BranchProtection, BranchRequiredCheck } from "../db/schema";
1e162a8Claude30import { matchGlob } from "./environments";
91b054eccanty labs31import { listStatuses } from "./commit-statuses";
1e162a8Claude32
33export interface ProtectionEvalContext {
34 aiApproved: boolean;
35 humanApprovalCount: number;
36 gateResultGreen: boolean;
37 hasFailedGates: boolean;
a79a9edClaude38 /** Names of checks whose latest run passed. Used by E6 required-checks. */
39 passingCheckNames?: string[];
1e162a8Claude40}
41
42export interface ProtectionDecision {
43 allowed: boolean;
44 rule: BranchProtection | null;
45 reasons: string[];
a79a9edClaude46 missingChecks?: string[];
1e162a8Claude47}
48
49/**
50 * Find the most specific branch-protection rule that matches `branch`.
51 * Rules with exact string matches win over glob rules; among globs the first
52 * alphabetical pattern wins (deterministic). Returns null if nothing matches.
53 */
54export async function matchProtection(
55 repositoryId: string,
56 branch: string
57): Promise<BranchProtection | null> {
58 let rules: BranchProtection[];
59 try {
60 rules = await db
61 .select()
62 .from(branchProtection)
63 .where(eq(branchProtection.repositoryId, repositoryId));
64 } catch {
65 return null;
66 }
67 if (!rules || rules.length === 0) return null;
68
69 // Exact match wins.
70 const exact = rules.find((r) => r.pattern === branch);
71 if (exact) return exact;
72
73 // Otherwise first glob match (deterministic order).
74 const globs = rules
75 .filter((r) => r.pattern.includes("*"))
76 .sort((a, b) => a.pattern.localeCompare(b.pattern));
77 for (const rule of globs) {
78 if (matchGlob(branch, rule.pattern)) return rule;
79 }
80 return null;
81}
82
83/**
84 * Evaluate a protection rule against merge-time context. Does not block on
85 * a missing rule — callers can treat that as "no protection configured".
86 */
87export function evaluateProtection(
88 rule: BranchProtection | null,
a79a9edClaude89 ctx: ProtectionEvalContext,
90 requiredChecks: string[] = []
1e162a8Claude91): ProtectionDecision {
92 if (!rule) {
93 return { allowed: true, rule: null, reasons: [] };
94 }
95 const reasons: string[] = [];
96
97 if (rule.requireAiApproval && !ctx.aiApproved) {
98 reasons.push(
99 `Branch protection '${rule.pattern}' requires AI approval, but no AI review comment is approving this PR.`
100 );
101 }
102 if (rule.requireGreenGates && ctx.hasFailedGates) {
103 reasons.push(
104 `Branch protection '${rule.pattern}' requires green gates, but at least one gate is failing.`
105 );
106 }
107 if (rule.requireHumanReview && ctx.humanApprovalCount < 1) {
108 reasons.push(
109 `Branch protection '${rule.pattern}' requires at least one human review approval.`
110 );
111 }
112 if (
113 rule.requiredApprovals > 0 &&
114 ctx.humanApprovalCount < rule.requiredApprovals
115 ) {
116 reasons.push(
117 `Branch protection '${rule.pattern}' requires ${rule.requiredApprovals} approvals (have ${ctx.humanApprovalCount}).`
118 );
119 }
120
a79a9edClaude121 // E6 — required status checks matrix
122 let missingChecks: string[] | undefined;
123 if (requiredChecks.length > 0) {
124 const passing = new Set(ctx.passingCheckNames || []);
125 const missing = requiredChecks.filter((n) => !passing.has(n));
126 if (missing.length > 0) {
127 missingChecks = missing;
128 reasons.push(
129 `Branch protection '${rule.pattern}' requires these checks to pass: ${missing.join(", ")}.`
130 );
131 }
132 }
133
134 return {
135 allowed: reasons.length === 0,
136 rule,
137 reasons,
138 ...(missingChecks ? { missingChecks } : {}),
139 };
1e162a8Claude140}
141
142/**
143 * Count human (non-AI) approving PR comments. "Approval" is defined as a
144 * comment containing LGTM / ":+1:" / "approved" tokens. Best-effort; callers
145 * should treat a zero here as "unknown", not "rejected".
146 */
147export async function countHumanApprovals(pullRequestId: string): Promise<number> {
148 try {
0a67773Claude149 // Primary: count distinct reviewers with state='approved' in pr_reviews,
150 // using the most recent non-commented review per reviewer.
151 const rows = await db
152 .select({ reviewerId: prReviews.reviewerId, state: prReviews.state })
153 .from(prReviews)
154 .where(
155 and(
156 eq(prReviews.pullRequestId, pullRequestId),
157 eq(prReviews.isAi, false)
158 )
159 )
160 .orderBy(desc(prReviews.createdAt));
161 const latestByReviewer = new Map<string, string>();
162 for (const r of rows) {
163 if (r.state !== "commented" && !latestByReviewer.has(r.reviewerId)) {
164 latestByReviewer.set(r.reviewerId, r.state);
165 }
166 }
167 const formalApprovals = [...latestByReviewer.values()].filter(s => s === "approved").length;
168 if (formalApprovals > 0) return formalApprovals;
169
170 // Fallback: legacy LGTM-in-comment heuristic (for repos not yet using reviews)
1e162a8Claude171 const comments = await db
172 .select({ body: prComments.body, isAi: prComments.isAiReview })
173 .from(prComments)
174 .where(
175 and(
176 eq(prComments.pullRequestId, pullRequestId),
177 eq(prComments.isAiReview, false)
178 )
179 );
180 return comments.filter((c) => {
181 const b = (c.body || "").toLowerCase();
182 return (
183 b.includes("lgtm") ||
184 b.includes(":+1:") ||
185 b.includes("approved") ||
186 b.includes("👍")
187 );
188 }).length;
189 } catch {
190 return 0;
191 }
192}
a79a9edClaude193
194// ---------------------------------------------------------------------------
195// E6 — Required status checks matrix
196// ---------------------------------------------------------------------------
197
198/**
199 * List required check names for a branch protection rule. Empty array when
200 * nothing is required (the default; same semantics as "no matrix configured").
201 */
202export async function listRequiredChecks(
203 branchProtectionId: string
204): Promise<BranchRequiredCheck[]> {
205 try {
206 return await db
207 .select()
208 .from(branchRequiredChecks)
209 .where(eq(branchRequiredChecks.branchProtectionId, branchProtectionId));
210 } catch {
211 return [];
212 }
213}
214
215/**
216 * Compute the set of check names that have a passing latest result for this
217 * repo + commit. A "check" is either:
218 * - a `gate_runs` row where `status IN ('passed','repaired')` (matched by
219 * gateName), or
220 * - a `workflow_runs` row where `status = 'success'` (matched by workflow
91b054eccanty labs221 * name, joined through the workflows table), or
222 * - a `commit_statuses` row (external CI signal, see commit-statuses.ts)
223 * whose LATEST row (by createdAt) for a given context has
224 * `state = 'success'` (matched by context).
a79a9edClaude225 *
91b054eccanty labs226 * Passing names for gate_runs/workflow_runs are aggregated across the last N
227 * rows to survive re-runs (any passing row in the window counts). Commit
228 * statuses are different: only the single most-recent row per context counts
229 * — a stale "success" followed by a newer "failure" must NOT count as
230 * passing, so we explicitly resolve "latest per context" rather than OR-ing
231 * across the window.
a79a9edClaude232 */
233export async function passingCheckNames(
234 repositoryId: string,
235 commitSha: string | null
236): Promise<string[]> {
237 const names = new Set<string>();
238
239 try {
240 const whereClause = commitSha
241 ? and(
242 eq(gateRuns.repositoryId, repositoryId),
243 eq(gateRuns.commitSha, commitSha)
244 )
245 : eq(gateRuns.repositoryId, repositoryId);
246 const gRows = await db
247 .select({ name: gateRuns.gateName, status: gateRuns.status })
248 .from(gateRuns)
249 .where(whereClause)
250 .orderBy(desc(gateRuns.createdAt))
251 .limit(200);
252 for (const r of gRows) {
253 if (r.status === "passed" || r.status === "repaired") {
254 names.add(r.name);
255 }
256 }
257 } catch {
258 // ignore
259 }
260
261 try {
262 const whereWf = commitSha
263 ? and(
264 eq(workflowRuns.repositoryId, repositoryId),
265 eq(workflowRuns.commitSha, commitSha)
266 )
267 : eq(workflowRuns.repositoryId, repositoryId);
268 const wRows = await db
269 .select({
270 name: workflows.name,
271 status: workflowRuns.status,
272 })
273 .from(workflowRuns)
274 .innerJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
275 .where(whereWf)
276 .orderBy(desc(workflowRuns.createdAt))
277 .limit(200);
278 for (const r of wRows) {
279 if (r.status === "success") {
280 names.add(r.name);
281 }
282 }
283 } catch {
284 // ignore
285 }
286
91b054eccanty labs287 try {
288 if (commitSha) {
289 const sRows = await listStatuses(repositoryId, commitSha);
290 // Resolve "latest per context" — a re-post to the same context must
291 // supersede an older one, in either direction (success->failure or
292 // failure->success), so we can't just OR across the window like the
293 // gate_runs/workflow_runs blocks above.
294 const latestByContext = new Map<string, (typeof sRows)[number]>();
295 for (const r of sRows) {
296 const prev = latestByContext.get(r.context);
297 if (!prev || prev.createdAt < r.createdAt) {
298 latestByContext.set(r.context, r);
299 }
300 }
301 for (const r of latestByContext.values()) {
302 if (r.state === "success") {
303 names.add(r.context);
304 }
305 }
306 }
307 } catch {
308 // ignore
309 }
310
a79a9edClaude311 return Array.from(names);
312}