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