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.tsBlame256 lines · 1 contributor
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,
25 workflowRuns,
26 workflows,
27} from "../db/schema";
28import type { BranchProtection, BranchRequiredCheck } from "../db/schema";
1e162a8Claude29import { matchGlob } from "./environments";
30
31export interface ProtectionEvalContext {
32 aiApproved: boolean;
33 humanApprovalCount: number;
34 gateResultGreen: boolean;
35 hasFailedGates: boolean;
a79a9edClaude36 /** Names of checks whose latest run passed. Used by E6 required-checks. */
37 passingCheckNames?: string[];
1e162a8Claude38}
39
40export interface ProtectionDecision {
41 allowed: boolean;
42 rule: BranchProtection | null;
43 reasons: string[];
a79a9edClaude44 missingChecks?: string[];
1e162a8Claude45}
46
47/**
48 * Find the most specific branch-protection rule that matches `branch`.
49 * Rules with exact string matches win over glob rules; among globs the first
50 * alphabetical pattern wins (deterministic). Returns null if nothing matches.
51 */
52export async function matchProtection(
53 repositoryId: string,
54 branch: string
55): Promise<BranchProtection | null> {
56 let rules: BranchProtection[];
57 try {
58 rules = await db
59 .select()
60 .from(branchProtection)
61 .where(eq(branchProtection.repositoryId, repositoryId));
62 } catch {
63 return null;
64 }
65 if (!rules || rules.length === 0) return null;
66
67 // Exact match wins.
68 const exact = rules.find((r) => r.pattern === branch);
69 if (exact) return exact;
70
71 // Otherwise first glob match (deterministic order).
72 const globs = rules
73 .filter((r) => r.pattern.includes("*"))
74 .sort((a, b) => a.pattern.localeCompare(b.pattern));
75 for (const rule of globs) {
76 if (matchGlob(branch, rule.pattern)) return rule;
77 }
78 return null;
79}
80
81/**
82 * Evaluate a protection rule against merge-time context. Does not block on
83 * a missing rule — callers can treat that as "no protection configured".
84 */
85export function evaluateProtection(
86 rule: BranchProtection | null,
a79a9edClaude87 ctx: ProtectionEvalContext,
88 requiredChecks: string[] = []
1e162a8Claude89): ProtectionDecision {
90 if (!rule) {
91 return { allowed: true, rule: null, reasons: [] };
92 }
93 const reasons: string[] = [];
94
95 if (rule.requireAiApproval && !ctx.aiApproved) {
96 reasons.push(
97 `Branch protection '${rule.pattern}' requires AI approval, but no AI review comment is approving this PR.`
98 );
99 }
100 if (rule.requireGreenGates && ctx.hasFailedGates) {
101 reasons.push(
102 `Branch protection '${rule.pattern}' requires green gates, but at least one gate is failing.`
103 );
104 }
105 if (rule.requireHumanReview && ctx.humanApprovalCount < 1) {
106 reasons.push(
107 `Branch protection '${rule.pattern}' requires at least one human review approval.`
108 );
109 }
110 if (
111 rule.requiredApprovals > 0 &&
112 ctx.humanApprovalCount < rule.requiredApprovals
113 ) {
114 reasons.push(
115 `Branch protection '${rule.pattern}' requires ${rule.requiredApprovals} approvals (have ${ctx.humanApprovalCount}).`
116 );
117 }
118
a79a9edClaude119 // E6 — required status checks matrix
120 let missingChecks: string[] | undefined;
121 if (requiredChecks.length > 0) {
122 const passing = new Set(ctx.passingCheckNames || []);
123 const missing = requiredChecks.filter((n) => !passing.has(n));
124 if (missing.length > 0) {
125 missingChecks = missing;
126 reasons.push(
127 `Branch protection '${rule.pattern}' requires these checks to pass: ${missing.join(", ")}.`
128 );
129 }
130 }
131
132 return {
133 allowed: reasons.length === 0,
134 rule,
135 reasons,
136 ...(missingChecks ? { missingChecks } : {}),
137 };
1e162a8Claude138}
139
140/**
141 * Count human (non-AI) approving PR comments. "Approval" is defined as a
142 * comment containing LGTM / ":+1:" / "approved" tokens. Best-effort; callers
143 * should treat a zero here as "unknown", not "rejected".
144 */
145export async function countHumanApprovals(pullRequestId: string): Promise<number> {
146 try {
147 const comments = await db
148 .select({ body: prComments.body, isAi: prComments.isAiReview })
149 .from(prComments)
150 .where(
151 and(
152 eq(prComments.pullRequestId, pullRequestId),
153 eq(prComments.isAiReview, false)
154 )
155 );
156 return comments.filter((c) => {
157 const b = (c.body || "").toLowerCase();
158 return (
159 b.includes("lgtm") ||
160 b.includes(":+1:") ||
161 b.includes("approved") ||
162 b.includes("👍")
163 );
164 }).length;
165 } catch {
166 return 0;
167 }
168}
a79a9edClaude169
170// ---------------------------------------------------------------------------
171// E6 — Required status checks matrix
172// ---------------------------------------------------------------------------
173
174/**
175 * List required check names for a branch protection rule. Empty array when
176 * nothing is required (the default; same semantics as "no matrix configured").
177 */
178export async function listRequiredChecks(
179 branchProtectionId: string
180): Promise<BranchRequiredCheck[]> {
181 try {
182 return await db
183 .select()
184 .from(branchRequiredChecks)
185 .where(eq(branchRequiredChecks.branchProtectionId, branchProtectionId));
186 } catch {
187 return [];
188 }
189}
190
191/**
192 * Compute the set of check names that have a passing latest result for this
193 * repo + commit. A "check" is either:
194 * - a `gate_runs` row where `status IN ('passed','repaired')` (matched by
195 * gateName), or
196 * - a `workflow_runs` row where `status = 'success'` (matched by workflow
197 * name, joined through the workflows table).
198 *
199 * Passing names are aggregated across the last N rows to survive re-runs.
200 */
201export async function passingCheckNames(
202 repositoryId: string,
203 commitSha: string | null
204): Promise<string[]> {
205 const names = new Set<string>();
206
207 try {
208 const whereClause = commitSha
209 ? and(
210 eq(gateRuns.repositoryId, repositoryId),
211 eq(gateRuns.commitSha, commitSha)
212 )
213 : eq(gateRuns.repositoryId, repositoryId);
214 const gRows = await db
215 .select({ name: gateRuns.gateName, status: gateRuns.status })
216 .from(gateRuns)
217 .where(whereClause)
218 .orderBy(desc(gateRuns.createdAt))
219 .limit(200);
220 for (const r of gRows) {
221 if (r.status === "passed" || r.status === "repaired") {
222 names.add(r.name);
223 }
224 }
225 } catch {
226 // ignore
227 }
228
229 try {
230 const whereWf = commitSha
231 ? and(
232 eq(workflowRuns.repositoryId, repositoryId),
233 eq(workflowRuns.commitSha, commitSha)
234 )
235 : eq(workflowRuns.repositoryId, repositoryId);
236 const wRows = await db
237 .select({
238 name: workflows.name,
239 status: workflowRuns.status,
240 })
241 .from(workflowRuns)
242 .innerJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
243 .where(whereWf)
244 .orderBy(desc(workflowRuns.createdAt))
245 .limit(200);
246 for (const r of wRows) {
247 if (r.status === "success") {
248 names.add(r.name);
249 }
250 }
251 } catch {
252 // ignore
253 }
254
255 return Array.from(names);
256}