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

rulesets.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.

rulesets.tsBlame402 lines · 1 contributor
9ff7128Claude1/**
2 * Block J6 — Repository rulesets.
3 *
4 * A ruleset groups N rules under a named policy at enforcement level active /
5 * evaluate / disabled. The evaluator is pure — callers (push hook, PR
6 * merger, web editor) pass a PushContext describing what they're about to
7 * do, and get back either an allow or the list of violations.
8 *
9 * Supported rule types in V1:
10 * - commit_message_pattern : { pattern: string, flags?: "i", require?: bool }
11 * - branch_name_pattern : { pattern: string, require?: bool }
12 * - tag_name_pattern : { pattern: string, require?: bool }
13 * - blocked_file_paths : { paths: string[] } (glob-lite: *, /)
14 * - max_file_size : { bytes: number }
15 * - forbid_force_push : {}
16 */
17
18import { and, eq } from "drizzle-orm";
19import { db } from "../db";
20import {
21 repoRulesets,
22 rulesetRules,
23 type RepoRuleset,
24 type RulesetRule,
25} from "../db/schema";
26
27export type RuleType =
28 | "commit_message_pattern"
29 | "branch_name_pattern"
30 | "tag_name_pattern"
31 | "blocked_file_paths"
32 | "max_file_size"
33 | "forbid_force_push";
34
35export const RULE_TYPES: RuleType[] = [
36 "commit_message_pattern",
37 "branch_name_pattern",
38 "tag_name_pattern",
39 "blocked_file_paths",
40 "max_file_size",
41 "forbid_force_push",
42];
43
44export interface CommitLike {
45 sha?: string;
46 message: string;
47 changedPaths?: string[];
48 maxBlobSize?: number;
49}
50
51export interface PushContext {
52 kind: "push";
53 refType: "branch" | "tag";
54 refName: string;
55 commits: CommitLike[];
56 forcePush?: boolean;
57}
58
59export interface Violation {
60 rulesetId: string;
61 rulesetName: string;
62 enforcement: "active" | "evaluate" | "disabled";
63 ruleType: RuleType;
64 message: string;
65}
66
67export interface EvalResult {
68 allowed: boolean;
69 violations: Violation[];
70}
71
72// ----------------------------------------------------------------------------
73// Pure rule helpers
74// ----------------------------------------------------------------------------
75
76/** glob-lite → RegExp. Supports `*` (non-slash) and `**` (anything). */
77export function globToRegex(glob: string): RegExp {
78 const parts: string[] = [];
79 let i = 0;
80 while (i < glob.length) {
81 const ch = glob[i];
82 if (ch === "*") {
83 if (glob[i + 1] === "*") {
84 parts.push(".*");
85 i += 2;
86 } else {
87 parts.push("[^/]*");
88 i += 1;
89 }
90 } else if (/[.+?^${}()|[\]\\]/.test(ch)) {
91 parts.push("\\" + ch);
92 i += 1;
93 } else {
94 parts.push(ch);
95 i += 1;
96 }
97 }
98 return new RegExp("^" + parts.join("") + "$");
99}
100
101export function parseParams(raw: string): Record<string, unknown> {
102 try {
103 return JSON.parse(raw || "{}");
104 } catch {
105 return {};
106 }
107}
108
109function evalRule(
110 rule: RulesetRule,
111 ctx: PushContext
112): string[] {
113 const params = parseParams(rule.params);
114 const out: string[] = [];
115 switch (rule.ruleType as RuleType) {
116 case "commit_message_pattern": {
117 const pattern = String(params.pattern || "");
118 const flags = String(params.flags || "") || undefined;
119 const require = params.require !== false;
120 if (!pattern) return out;
121 let re: RegExp;
122 try {
123 re = new RegExp(pattern, flags);
124 } catch {
125 return out;
126 }
127 for (const c of ctx.commits) {
128 const matches = re.test(c.message);
129 if (require && !matches) {
130 out.push(
131 `commit ${c.sha?.slice(0, 7) || "?"} message does not match /${pattern}/`
132 );
133 } else if (!require && matches) {
134 out.push(
135 `commit ${c.sha?.slice(0, 7) || "?"} message matches forbidden /${pattern}/`
136 );
137 }
138 }
139 return out;
140 }
141 case "branch_name_pattern": {
142 if (ctx.refType !== "branch") return out;
143 const pattern = String(params.pattern || "");
144 const require = params.require !== false;
145 if (!pattern) return out;
146 let re: RegExp;
147 try {
148 re = new RegExp(pattern);
149 } catch {
150 return out;
151 }
152 const ok = re.test(ctx.refName);
153 if (require && !ok) {
154 out.push(`branch "${ctx.refName}" does not match /${pattern}/`);
155 } else if (!require && ok) {
156 out.push(`branch "${ctx.refName}" matches forbidden /${pattern}/`);
157 }
158 return out;
159 }
160 case "tag_name_pattern": {
161 if (ctx.refType !== "tag") return out;
162 const pattern = String(params.pattern || "");
163 const require = params.require !== false;
164 if (!pattern) return out;
165 let re: RegExp;
166 try {
167 re = new RegExp(pattern);
168 } catch {
169 return out;
170 }
171 const ok = re.test(ctx.refName);
172 if (require && !ok) {
173 out.push(`tag "${ctx.refName}" does not match /${pattern}/`);
174 } else if (!require && ok) {
175 out.push(`tag "${ctx.refName}" matches forbidden /${pattern}/`);
176 }
177 return out;
178 }
179 case "blocked_file_paths": {
180 const globs = Array.isArray(params.paths)
181 ? (params.paths as string[])
182 : [];
183 if (!globs.length) return out;
184 const res = globs.map((g) => globToRegex(g));
185 for (const c of ctx.commits) {
186 for (const p of c.changedPaths || []) {
187 for (let i = 0; i < res.length; i++) {
188 if (res[i].test(p)) {
189 out.push(
190 `commit ${c.sha?.slice(0, 7) || "?"} modifies blocked path "${p}" (${globs[i]})`
191 );
192 }
193 }
194 }
195 }
196 return out;
197 }
198 case "max_file_size": {
199 const bytes = Number(params.bytes || 0);
200 if (!bytes) return out;
201 for (const c of ctx.commits) {
202 if (typeof c.maxBlobSize === "number" && c.maxBlobSize > bytes) {
203 out.push(
204 `commit ${c.sha?.slice(0, 7) || "?"} has a blob ${c.maxBlobSize}B > ${bytes}B`
205 );
206 }
207 }
208 return out;
209 }
210 case "forbid_force_push": {
211 if (ctx.forcePush) {
212 out.push("force push is forbidden by ruleset");
213 }
214 return out;
215 }
216 default:
217 return out;
218 }
219}
220
221/** Pure evaluator — takes rulesets + rules + context, returns verdict. */
222export function evaluatePush(
223 rulesets: Array<RepoRuleset & { rules: RulesetRule[] }>,
224 ctx: PushContext
225): EvalResult {
226 const violations: Violation[] = [];
227 let blocked = false;
228 for (const rs of rulesets) {
229 if (rs.enforcement === "disabled") continue;
230 for (const r of rs.rules) {
231 const msgs = evalRule(r, ctx);
232 for (const m of msgs) {
233 violations.push({
234 rulesetId: rs.id,
235 rulesetName: rs.name,
236 enforcement: rs.enforcement as "active" | "evaluate",
237 ruleType: r.ruleType as RuleType,
238 message: m,
239 });
240 if (rs.enforcement === "active") blocked = true;
241 }
242 }
243 }
244 return { allowed: !blocked, violations };
245}
246
247// ----------------------------------------------------------------------------
248// DB access
249// ----------------------------------------------------------------------------
250
251export async function listRulesetsForRepo(
252 repositoryId: string
253): Promise<Array<RepoRuleset & { rules: RulesetRule[] }>> {
254 const sets = await db
255 .select()
256 .from(repoRulesets)
257 .where(eq(repoRulesets.repositoryId, repositoryId));
258 if (sets.length === 0) return [];
259 const allRules = await db
260 .select()
261 .from(rulesetRules);
262 const byId = new Map<string, RulesetRule[]>();
263 for (const r of allRules) {
264 if (!byId.has(r.rulesetId)) byId.set(r.rulesetId, []);
265 byId.get(r.rulesetId)!.push(r);
266 }
267 return sets.map((s) => ({ ...s, rules: byId.get(s.id) || [] }));
268}
269
270export async function getRuleset(
271 rulesetId: string,
272 repositoryId: string
273): Promise<(RepoRuleset & { rules: RulesetRule[] }) | null> {
274 const [row] = await db
275 .select()
276 .from(repoRulesets)
277 .where(
278 and(
279 eq(repoRulesets.id, rulesetId),
280 eq(repoRulesets.repositoryId, repositoryId)
281 )
282 )
283 .limit(1);
284 if (!row) return null;
285 const rules = await db
286 .select()
287 .from(rulesetRules)
288 .where(eq(rulesetRules.rulesetId, rulesetId));
289 return { ...row, rules };
290}
291
292export async function createRuleset(params: {
293 repositoryId: string;
294 name: string;
295 enforcement: "active" | "evaluate" | "disabled";
296 createdBy: string;
297}): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
298 const name = params.name.trim();
299 if (!name) return { ok: false, error: "Name is required" };
300 if (!["active", "evaluate", "disabled"].includes(params.enforcement)) {
301 return { ok: false, error: "Invalid enforcement" };
302 }
303 try {
304 const [row] = await db
305 .insert(repoRulesets)
306 .values({
307 repositoryId: params.repositoryId,
308 name,
309 enforcement: params.enforcement,
310 createdBy: params.createdBy,
311 })
312 .returning();
313 return { ok: true, id: row.id };
314 } catch (err: any) {
315 const msg = String(err?.message || "");
316 if (msg.includes("duplicate") || msg.includes("unique")) {
317 return { ok: false, error: "A ruleset with that name already exists" };
318 }
319 return { ok: false, error: "Could not save ruleset" };
320 }
321}
322
323export async function updateRulesetEnforcement(
324 rulesetId: string,
325 repositoryId: string,
326 enforcement: "active" | "evaluate" | "disabled"
327): Promise<boolean> {
328 if (!["active", "evaluate", "disabled"].includes(enforcement)) return false;
329 const rows = await db
330 .update(repoRulesets)
331 .set({ enforcement, updatedAt: new Date() })
332 .where(
333 and(
334 eq(repoRulesets.id, rulesetId),
335 eq(repoRulesets.repositoryId, repositoryId)
336 )
337 )
338 .returning();
339 return rows.length > 0;
340}
341
342export async function deleteRuleset(
343 rulesetId: string,
344 repositoryId: string
345): Promise<boolean> {
346 const rows = await db
347 .delete(repoRulesets)
348 .where(
349 and(
350 eq(repoRulesets.id, rulesetId),
351 eq(repoRulesets.repositoryId, repositoryId)
352 )
353 )
354 .returning();
355 return rows.length > 0;
356}
357
358export async function addRule(params: {
359 rulesetId: string;
360 repositoryId: string;
361 ruleType: RuleType;
362 params: Record<string, unknown>;
363}): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
364 if (!RULE_TYPES.includes(params.ruleType)) {
365 return { ok: false, error: "Unknown rule type" };
366 }
367 // Ensure the ruleset belongs to this repo.
368 const parent = await getRuleset(params.rulesetId, params.repositoryId);
369 if (!parent) return { ok: false, error: "Ruleset not found" };
370 try {
371 const [row] = await db
372 .insert(rulesetRules)
373 .values({
374 rulesetId: params.rulesetId,
375 ruleType: params.ruleType,
376 params: JSON.stringify(params.params || {}),
377 })
378 .returning();
379 return { ok: true, id: row.id };
380 } catch {
381 return { ok: false, error: "Could not save rule" };
382 }
383}
384
385export async function deleteRule(
386 ruleId: string,
387 rulesetId: string,
388 repositoryId: string
389): Promise<boolean> {
390 const parent = await getRuleset(rulesetId, repositoryId);
391 if (!parent) return false;
392 const rows = await db
393 .delete(rulesetRules)
394 .where(
395 and(eq(rulesetRules.id, ruleId), eq(rulesetRules.rulesetId, rulesetId))
396 )
397 .returning();
398 return rows.length > 0;
399}
400
401// Test-only surface.
402export const __internal = { evalRule, globToRegex, parseParams };