Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame139 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
18import { and, eq } from "drizzle-orm";
19import { db } from "../db";
20import { branchProtection, prComments } from "../db/schema";
21import type { BranchProtection } from "../db/schema";
22import { matchGlob } from "./environments";
23
24export interface ProtectionEvalContext {
25 aiApproved: boolean;
26 humanApprovalCount: number;
27 gateResultGreen: boolean;
28 hasFailedGates: boolean;
29}
30
31export interface ProtectionDecision {
32 allowed: boolean;
33 rule: BranchProtection | null;
34 reasons: string[];
35}
36
37/**
38 * Find the most specific branch-protection rule that matches `branch`.
39 * Rules with exact string matches win over glob rules; among globs the first
40 * alphabetical pattern wins (deterministic). Returns null if nothing matches.
41 */
42export async function matchProtection(
43 repositoryId: string,
44 branch: string
45): Promise<BranchProtection | null> {
46 let rules: BranchProtection[];
47 try {
48 rules = await db
49 .select()
50 .from(branchProtection)
51 .where(eq(branchProtection.repositoryId, repositoryId));
52 } catch {
53 return null;
54 }
55 if (!rules || rules.length === 0) return null;
56
57 // Exact match wins.
58 const exact = rules.find((r) => r.pattern === branch);
59 if (exact) return exact;
60
61 // Otherwise first glob match (deterministic order).
62 const globs = rules
63 .filter((r) => r.pattern.includes("*"))
64 .sort((a, b) => a.pattern.localeCompare(b.pattern));
65 for (const rule of globs) {
66 if (matchGlob(branch, rule.pattern)) return rule;
67 }
68 return null;
69}
70
71/**
72 * Evaluate a protection rule against merge-time context. Does not block on
73 * a missing rule — callers can treat that as "no protection configured".
74 */
75export function evaluateProtection(
76 rule: BranchProtection | null,
77 ctx: ProtectionEvalContext
78): ProtectionDecision {
79 if (!rule) {
80 return { allowed: true, rule: null, reasons: [] };
81 }
82 const reasons: string[] = [];
83
84 if (rule.requireAiApproval && !ctx.aiApproved) {
85 reasons.push(
86 `Branch protection '${rule.pattern}' requires AI approval, but no AI review comment is approving this PR.`
87 );
88 }
89 if (rule.requireGreenGates && ctx.hasFailedGates) {
90 reasons.push(
91 `Branch protection '${rule.pattern}' requires green gates, but at least one gate is failing.`
92 );
93 }
94 if (rule.requireHumanReview && ctx.humanApprovalCount < 1) {
95 reasons.push(
96 `Branch protection '${rule.pattern}' requires at least one human review approval.`
97 );
98 }
99 if (
100 rule.requiredApprovals > 0 &&
101 ctx.humanApprovalCount < rule.requiredApprovals
102 ) {
103 reasons.push(
104 `Branch protection '${rule.pattern}' requires ${rule.requiredApprovals} approvals (have ${ctx.humanApprovalCount}).`
105 );
106 }
107
108 return { allowed: reasons.length === 0, rule, reasons };
109}
110
111/**
112 * Count human (non-AI) approving PR comments. "Approval" is defined as a
113 * comment containing LGTM / ":+1:" / "approved" tokens. Best-effort; callers
114 * should treat a zero here as "unknown", not "rejected".
115 */
116export async function countHumanApprovals(pullRequestId: string): Promise<number> {
117 try {
118 const comments = await db
119 .select({ body: prComments.body, isAi: prComments.isAiReview })
120 .from(prComments)
121 .where(
122 and(
123 eq(prComments.pullRequestId, pullRequestId),
124 eq(prComments.isAiReview, false)
125 )
126 );
127 return comments.filter((c) => {
128 const b = (c.body || "").toLowerCase();
129 return (
130 b.includes("lgtm") ||
131 b.includes(":+1:") ||
132 b.includes("approved") ||
133 b.includes("👍")
134 );
135 }).length;
136 } catch {
137 return 0;
138 }
139}