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

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.tsxBlame1171 lines · 1 contributor
9ff7128Claude1/**
2 * Block J6 — Ruleset management UI.
3 *
4 * GET /:owner/:repo/settings/rulesets — list + create
5 * POST /:owner/:repo/settings/rulesets — create
6 * GET /:owner/:repo/settings/rulesets/:id — detail, add rules
7 * POST /:owner/:repo/settings/rulesets/:id — update enforcement
8 * POST /:owner/:repo/settings/rulesets/:id/delete
9 * POST /:owner/:repo/settings/rulesets/:id/rules — add rule
10 * POST /:owner/:repo/settings/rulesets/:id/rules/:rid/delete
7581253Claude11 *
12 * 2026 polish: scoped under `.rs-` selectors. Form actions, validation, and
13 * audit logging are preserved verbatim — this is a security-critical surface.
9ff7128Claude14 */
15
16import { Hono } from "hono";
17import { and, eq } from "drizzle-orm";
18import { db } from "../db";
19import { repositories, users } from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader, RepoNav } from "../views/components";
22import { requireAuth, softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
febd4f0Claude24import { requireRepoAccess } from "../middleware/repo-access";
9ff7128Claude25import { audit } from "../lib/notify";
26import {
27 RULE_TYPES,
28 addRule,
29 createRuleset,
30 deleteRule,
31 deleteRuleset,
32 getRuleset,
33 listRulesetsForRepo,
34 parseParams,
35 updateRulesetEnforcement,
36} from "../lib/rulesets";
37
38const rulesets = new Hono<AuthEnv>();
39rulesets.use("*", softAuth);
40
41async function gate(c: any) {
42 const user = c.get("user");
43 if (!user) return c.redirect("/login");
44 const { owner: ownerName, repo: repoName } = c.req.param();
45 const [owner] = await db
46 .select()
47 .from(users)
48 .where(eq(users.username, ownerName))
49 .limit(1);
50 if (!owner) return c.notFound();
51 const [repo] = await db
52 .select()
53 .from(repositories)
54 .where(
55 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
56 )
57 .limit(1);
58 if (!repo) return c.notFound();
59 if (user.id !== repo.ownerId) {
60 return c.redirect(`/${ownerName}/${repoName}`);
61 }
62 return { user, owner, repo, ownerName, repoName };
63}
64
65function ruleDescription(type: string, params: Record<string, unknown>): string {
66 switch (type) {
67 case "commit_message_pattern":
68 return `commit message ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
69 case "branch_name_pattern":
70 return `branch name ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
71 case "tag_name_pattern":
72 return `tag name ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
73 case "blocked_file_paths":
74 return `blocks changes to: ${(params.paths as string[] | undefined)?.join(", ") || "(none)"}`;
75 case "max_file_size":
76 return `max blob size ${params.bytes || 0}B`;
77 case "forbid_force_push":
78 return "force push forbidden";
79 default:
80 return type;
81 }
82}
83
7581253Claude84/* ─────────────────────────────────────────────────────────────────────────
85 * Scoped CSS — every selector under `.rs-` so this surface can't leak.
86 * ───────────────────────────────────────────────────────────────────── */
87const rsStyles = `
eed4684Claude88 .rs-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-4) 0; }
7581253Claude89
90 .rs-hero {
91 position: relative;
92 margin-bottom: var(--space-5);
93 padding: var(--space-5) var(--space-6);
94 background: var(--bg-elevated);
95 border: 1px solid var(--border);
96 border-radius: 16px;
97 overflow: hidden;
98 }
99 .rs-hero::before {
100 content: '';
101 position: absolute;
102 top: 0; left: 0; right: 0;
103 height: 2px;
6fd5915Claude104 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude105 opacity: 0.7;
106 pointer-events: none;
107 }
108 .rs-hero-orb {
109 position: absolute;
110 inset: -20% -10% auto auto;
111 width: 380px; height: 380px;
6fd5915Claude112 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
7581253Claude113 filter: blur(80px);
114 opacity: 0.7;
115 pointer-events: none;
116 z-index: 0;
117 }
118 .rs-hero-inner {
119 position: relative;
120 z-index: 1;
121 display: flex;
122 align-items: flex-end;
123 justify-content: space-between;
124 gap: var(--space-4);
125 flex-wrap: wrap;
126 }
127 .rs-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
128 .rs-eyebrow {
129 font-size: 12px;
130 color: var(--text-muted);
131 margin-bottom: var(--space-2);
132 letter-spacing: 0.02em;
133 display: inline-flex;
134 align-items: center;
135 gap: 8px;
136 text-transform: uppercase;
137 font-family: var(--font-mono);
138 font-weight: 600;
139 }
140 .rs-eyebrow-pill {
141 display: inline-flex;
142 align-items: center;
143 justify-content: center;
144 width: 18px; height: 18px;
145 border-radius: 6px;
6fd5915Claude146 background: rgba(91,110,232,0.14);
147 color: #5b6ee8;
148 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude149 }
150 .rs-title {
151 font-size: clamp(26px, 3.6vw, 36px);
152 font-family: var(--font-display);
153 font-weight: 800;
154 letter-spacing: -0.028em;
155 line-height: 1.05;
156 margin: 0 0 var(--space-2);
157 color: var(--text-strong);
158 }
159 .rs-title-grad {
6fd5915Claude160 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7581253Claude161 -webkit-background-clip: text;
162 background-clip: text;
163 -webkit-text-fill-color: transparent;
164 color: transparent;
165 }
166 .rs-sub {
167 font-size: 14.5px;
168 color: var(--text-muted);
169 margin: 0;
170 line-height: 1.55;
171 max-width: 620px;
172 }
173 .rs-hero-link {
174 display: inline-flex;
175 align-items: center;
176 gap: 6px;
177 padding: 7px 12px;
178 font-size: 12.5px;
179 color: var(--text-muted);
180 background: rgba(255,255,255,0.02);
181 border: 1px solid var(--border);
182 border-radius: 8px;
183 text-decoration: none;
184 font-weight: 500;
185 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
186 }
187 .rs-hero-link:hover {
188 border-color: var(--border-strong);
189 color: var(--text-strong);
190 background: rgba(255,255,255,0.04);
191 text-decoration: none;
192 }
193
194 .rs-banner {
195 margin-bottom: var(--space-4);
196 padding: 10px 14px;
197 border-radius: 10px;
198 font-size: 13.5px;
199 border: 1px solid var(--border);
200 background: rgba(255,255,255,0.025);
201 color: var(--text);
202 display: flex;
203 align-items: center;
204 gap: 10px;
205 }
206 .rs-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
207 .rs-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
208 .rs-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
209
210 /* ─── Status card ─── */
211 .rs-status {
212 position: relative;
213 margin-bottom: var(--space-5);
214 padding: var(--space-5);
215 background: var(--bg-elevated);
216 border: 1px solid var(--border);
217 border-radius: 14px;
218 overflow: hidden;
219 }
220 .rs-status.is-on {
221 border-color: rgba(52,211,153,0.32);
6fd5915Claude222 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
7581253Claude223 }
224 .rs-status.is-warn {
225 border-color: rgba(248,113,113,0.32);
6fd5915Claude226 background: linear-gradient(135deg, rgba(248,113,113,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
7581253Claude227 }
228 .rs-status.is-empty {
229 border-color: rgba(251,191,36,0.30);
6fd5915Claude230 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
7581253Claude231 }
232 .rs-status-row { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
233 .rs-status-mark {
234 flex-shrink: 0;
235 width: 52px; height: 52px;
236 border-radius: 14px;
237 display: flex;
238 align-items: center;
239 justify-content: center;
240 color: #fff;
6fd5915Claude241 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
242 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.18);
7581253Claude243 }
244 .rs-status.is-on .rs-status-mark {
245 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
246 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
247 }
248 .rs-status.is-warn .rs-status-mark {
249 background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
250 box-shadow: 0 8px 20px -8px rgba(239,68,68,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
251 }
252 .rs-status.is-empty .rs-status-mark {
253 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
254 color: #1a1206;
255 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
256 }
257 .rs-status-text { flex: 1; min-width: 220px; }
258 .rs-status-headline {
259 margin: 0 0 4px;
260 font-family: var(--font-display);
261 font-size: 18px;
262 font-weight: 700;
263 letter-spacing: -0.018em;
264 color: var(--text-strong);
265 }
266 .rs-status-desc { margin: 0; font-size: 13.5px; color: var(--text-muted); line-height: 1.5; }
267
268 /* ─── Section card ─── */
269 .rs-section {
270 margin-bottom: var(--space-5);
271 background: var(--bg-elevated);
272 border: 1px solid var(--border);
273 border-radius: 14px;
274 overflow: hidden;
275 }
276 .rs-section-head {
277 padding: var(--space-4) var(--space-5);
278 border-bottom: 1px solid var(--border);
279 }
280 .rs-section-title {
281 margin: 0;
282 font-family: var(--font-display);
283 font-size: 16px;
284 font-weight: 700;
285 letter-spacing: -0.018em;
286 color: var(--text-strong);
287 display: flex;
288 align-items: center;
289 gap: 10px;
290 }
291 .rs-section-icon {
292 display: inline-flex;
293 align-items: center;
294 justify-content: center;
295 width: 26px; height: 26px;
296 border-radius: 8px;
6fd5915Claude297 background: rgba(91,110,232,0.12);
298 color: #5b6ee8;
299 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
7581253Claude300 flex-shrink: 0;
301 }
302 .rs-section-sub {
303 margin: 6px 0 0 36px;
304 font-size: 12.5px;
305 color: var(--text-muted);
306 line-height: 1.5;
307 }
308 .rs-section-body { padding: var(--space-4) var(--space-5); }
309
310 /* ─── Ruleset cards ─── */
311 .rs-list { display: flex; flex-direction: column; gap: 10px; }
312 .rs-item {
313 display: flex;
314 align-items: center;
315 justify-content: space-between;
316 gap: var(--space-3);
317 padding: var(--space-4);
318 background: var(--bg-elevated);
319 border: 1px solid var(--border);
320 border-radius: 12px;
321 text-decoration: none;
322 color: inherit;
323 transition: border-color 140ms ease, transform 140ms ease;
324 }
6fd5915Claude325 .rs-item:hover { border-color: rgba(91,110,232,0.30); transform: translateY(-1px); text-decoration: none; }
7581253Claude326 .rs-item-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
327 .rs-item-name { font-family: var(--font-display); font-size: 15px; font-weight: 700; color: var(--text-strong); letter-spacing: -0.012em; }
328 .rs-item-meta { margin-top: 4px; font-size: 12px; color: var(--text-muted); }
329
330 /* ─── Enforcement pills ─── */
331 .rs-pill {
332 display: inline-flex;
333 align-items: center;
334 gap: 5px;
335 padding: 2px 9px;
336 font-size: 10.5px;
337 font-weight: 700;
338 text-transform: uppercase;
339 letter-spacing: 0.06em;
340 border-radius: 9999px;
341 }
342 .rs-pill.is-active { background: rgba(248,113,113,0.14); color: #fecaca; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
343 .rs-pill.is-evaluate { background: rgba(251,191,36,0.14); color: #fde68a; box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32); }
344 .rs-pill.is-disabled { background: rgba(255,255,255,0.04); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
345 .rs-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
346
347 .rs-chip {
348 display: inline-flex;
349 align-items: center;
350 gap: 5px;
351 padding: 2px 9px;
352 font-size: 11px;
353 font-weight: 600;
354 border-radius: 9999px;
355 background: rgba(255,255,255,0.04);
356 color: var(--text);
357 border: 1px solid var(--border);
358 }
359
360 /* ─── Rule rows ─── */
361 .rs-rule {
362 display: flex;
363 align-items: flex-start;
364 justify-content: space-between;
365 gap: var(--space-3);
366 padding: 12px 14px;
367 background: var(--bg-elevated);
368 border: 1px solid var(--border);
369 border-radius: 10px;
370 margin-bottom: 8px;
371 }
372 .rs-rule:last-child { margin-bottom: 0; }
373 .rs-rule-body { flex: 1; min-width: 0; }
374 .rs-rule-type {
375 display: inline-block;
376 font-family: var(--font-mono);
377 font-size: 10.5px;
378 padding: 2px 7px;
379 border-radius: 5px;
6fd5915Claude380 background: rgba(91,110,232,0.10);
7581253Claude381 color: #c4b5fd;
6fd5915Claude382 border: 1px solid rgba(91,110,232,0.25);
7581253Claude383 text-transform: uppercase;
384 letter-spacing: 0.04em;
385 font-weight: 700;
386 margin-right: 8px;
387 }
388 .rs-rule-desc { font-size: 13px; color: var(--text); }
389
390 /* ─── Empty state ─── */
391 .rs-empty {
392 padding: var(--space-6);
393 text-align: center;
394 background: var(--bg-elevated);
395 border: 1px dashed var(--border-strong);
396 border-radius: 14px;
397 position: relative;
398 overflow: hidden;
399 }
400 .rs-empty-orb {
401 position: absolute;
402 inset: -30% auto auto -10%;
403 width: 260px; height: 260px;
6fd5915Claude404 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
7581253Claude405 filter: blur(60px);
406 opacity: 0.8;
407 pointer-events: none;
408 }
409 .rs-empty-mark {
410 position: relative;
411 z-index: 1;
412 margin: 0 auto var(--space-3);
413 width: 52px; height: 52px;
414 border-radius: 14px;
6fd5915Claude415 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12));
416 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
7581253Claude417 display: flex;
418 align-items: center;
419 justify-content: center;
420 color: #c4b5fd;
421 }
422 .rs-empty-title { position: relative; z-index: 1; margin: 0 0 6px; font-family: var(--font-display); font-size: 17px; font-weight: 700; color: var(--text-strong); letter-spacing: -0.018em; }
423 .rs-empty-body { position: relative; z-index: 1; margin: 0 auto; max-width: 460px; font-size: 13.5px; color: var(--text-muted); line-height: 1.55; }
424
425 /* ─── Form card ─── */
426 .rs-form { padding: var(--space-5); }
427 .rs-form-group { margin-bottom: var(--space-4); }
428 .rs-form-group:last-of-type { margin-bottom: var(--space-4); }
429 .rs-form-label {
430 display: block;
431 font-family: var(--font-mono);
432 font-size: 11.5px;
433 font-weight: 700;
434 text-transform: uppercase;
435 letter-spacing: 0.12em;
436 color: var(--text-muted);
437 margin-bottom: 6px;
438 }
439 .rs-input,
440 .rs-select,
441 .rs-textarea {
442 width: 100%;
443 padding: 9px 12px;
444 font-size: 13.5px;
445 color: var(--text);
446 background: var(--bg);
447 border: 1px solid var(--border-strong);
448 border-radius: 8px;
449 outline: none;
450 font-family: var(--font-mono);
451 transition: border-color 120ms ease, box-shadow 120ms ease;
452 box-sizing: border-box;
453 }
454 .rs-input:focus,
455 .rs-select:focus,
456 .rs-textarea:focus {
6fd5915Claude457 border-color: var(--border-focus, rgba(91,110,232,0.55));
458 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
7581253Claude459 }
460 .rs-textarea { font-size: 12px; line-height: 1.5; }
461 .rs-inline-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
462 .rs-inline-form .rs-select { width: auto; min-width: 180px; }
463 .rs-form-hint {
464 margin-top: 6px;
465 font-size: 12px;
466 color: var(--text-muted);
467 }
468 .rs-form-hint code { font-family: var(--font-mono); font-size: 11.5px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); padding: 1px 6px; border-radius: 5px; color: var(--text); }
469
470 /* ─── Buttons ─── */
471 .rs-btn {
472 display: inline-flex;
473 align-items: center;
474 gap: 6px;
475 padding: 9px 16px;
476 border-radius: 10px;
477 font-size: 13px;
478 font-weight: 600;
479 text-decoration: none;
480 border: 1px solid transparent;
481 cursor: pointer;
482 font-family: inherit;
483 line-height: 1;
484 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
485 }
486 .rs-btn-primary {
6fd5915Claude487 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
7581253Claude488 color: #fff;
6fd5915Claude489 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
7581253Claude490 }
491 .rs-btn-primary:hover {
492 transform: translateY(-1px);
6fd5915Claude493 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
7581253Claude494 color: #fff;
495 text-decoration: none;
496 }
497 .rs-btn-ghost {
498 background: rgba(255,255,255,0.025);
499 color: var(--text);
500 border-color: var(--border-strong);
501 }
502 .rs-btn-ghost:hover {
6fd5915Claude503 background: rgba(91,110,232,0.06);
504 border-color: rgba(91,110,232,0.45);
7581253Claude505 color: var(--text-strong);
506 text-decoration: none;
507 }
508 .rs-btn-danger {
509 background: transparent;
510 color: #fecaca;
511 border-color: rgba(248,113,113,0.40);
512 }
513 .rs-btn-danger:hover {
514 background: rgba(248,113,113,0.10);
515 border-color: rgba(248,113,113,0.65);
516 color: #fee2e2;
517 text-decoration: none;
518 }
519 .rs-btn-sm { padding: 6px 11px; font-size: 12px; }
520
521 /* ─── Danger zone ─── */
522 .rs-danger {
523 margin-top: var(--space-5);
524 padding: var(--space-5);
525 background: rgba(248,113,113,0.04);
526 border: 1px solid rgba(248,113,113,0.30);
527 border-radius: 14px;
528 }
529 .rs-danger h3 {
530 margin: 0 0 6px;
531 font-family: var(--font-display);
532 font-size: 15px;
533 font-weight: 700;
534 color: #fecaca;
535 letter-spacing: -0.012em;
536 }
537 .rs-danger p { margin: 0 0 var(--space-3); font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
538`;
539
540/* Icons */
541const ListIcon = () => (
542 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
543 <line x1="8" y1="6" x2="21" y2="6" />
544 <line x1="8" y1="12" x2="21" y2="12" />
545 <line x1="8" y1="18" x2="21" y2="18" />
546 <line x1="3" y1="6" x2="3.01" y2="6" />
547 <line x1="3" y1="12" x2="3.01" y2="12" />
548 <line x1="3" y1="18" x2="3.01" y2="18" />
549 </svg>
550);
551const ShieldIcon = () => (
552 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
553 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
554 </svg>
555);
556const PlusIcon = () => (
557 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
558 <line x1="12" y1="5" x2="12" y2="19" />
559 <line x1="5" y1="12" x2="19" y2="12" />
560 </svg>
561);
562const ArrowLeft = () => (
563 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
564 <line x1="19" y1="12" x2="5" y2="12" />
565 <polyline points="12 19 5 12 12 5" />
566 </svg>
567);
568const SettingsIcon = () => (
569 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
570 <circle cx="12" cy="12" r="3" />
571 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9 1.65 1.65 0 0 0 4.27 7.18l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6h.09a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09c0 .61.36 1.17.92 1.42a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82c.25.56.81.92 1.42.92H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
572 </svg>
573);
574
575function enforcementPill(enforcement: string) {
576 const cls =
577 enforcement === "active"
578 ? "is-active"
579 : enforcement === "evaluate"
580 ? "is-evaluate"
581 : "is-disabled";
582 return (
583 <span class={`rs-pill ${cls}`}>
584 <span class="dot" aria-hidden="true" />
585 {enforcement}
586 </span>
587 );
588}
589
9ff7128Claude590// ---------- List + create ----------
591
febd4f0Claude592rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => {
9ff7128Claude593 const ctx = await gate(c);
594 if (ctx instanceof Response) return ctx;
595 const { ownerName, repoName, repo, user } = ctx;
596 const all = await listRulesetsForRepo(repo.id);
597 const message = c.req.query("message");
598 const error = c.req.query("error");
7581253Claude599
600 const activeCount = all.filter((rs) => rs.enforcement === "active").length;
601 const evaluateCount = all.filter((rs) => rs.enforcement === "evaluate").length;
602
603 let statusVariant: "is-on" | "is-warn" | "is-empty" = "is-empty";
604 let statusHead = "No rulesets configured";
605 let statusDesc =
606 "Pushes are accepted as-is. Add a ruleset below to start enforcing commit-message, branch-name, or file-size policies.";
607 let statusMark: any = <ShieldIcon />;
608 if (all.length > 0) {
609 statusVariant = activeCount > 0 ? "is-on" : "is-warn";
610 statusHead = `${all.length} ruleset${all.length === 1 ? "" : "s"} configured`;
611 const parts: string[] = [];
612 if (activeCount) parts.push(`${activeCount} active`);
613 if (evaluateCount) parts.push(`${evaluateCount} evaluate-only`);
614 const disabled = all.length - activeCount - evaluateCount;
615 if (disabled) parts.push(`${disabled} disabled`);
616 statusDesc = parts.join(" · ");
617 }
618
9ff7128Claude619 return c.html(
620 <Layout title={`Rulesets — ${ownerName}/${repoName}`} user={user}>
621 <RepoHeader owner={ownerName} repo={repoName} />
622 <RepoNav owner={ownerName} repo={repoName} active="settings" />
7581253Claude623
624 <div class="rs-wrap">
625 <section class="rs-hero">
626 <div class="rs-hero-orb" aria-hidden="true" />
627 <div class="rs-hero-inner">
628 <div class="rs-hero-text">
629 <div class="rs-eyebrow">
630 <span class="rs-eyebrow-pill" aria-hidden="true">
631 <ListIcon />
632 </span>
633 Rulesets · {ownerName}/{repoName}
634 </div>
635 <h1 class="rs-title">
636 <span class="rs-title-grad">Policy engine.</span>
637 </h1>
638 <p class="rs-sub">
639 Extend branch protection with commit-message, branch/tag-name,
640 blocked-path, and file-size policies. Enforcement modes:{" "}
641 <strong>active</strong> blocks, <strong>evaluate</strong> only
642 logs, <strong>disabled</strong> is inert.
643 </p>
644 </div>
645 <a href={`/${ownerName}/${repoName}/settings`} class="rs-hero-link">
646 <ArrowLeft /> Back to settings
647 </a>
648 </div>
649 </section>
650
9ff7128Claude651 {message && (
7581253Claude652 <div class="rs-banner is-ok" role="status">
653 <span class="rs-banner-dot" aria-hidden="true" />
9ff7128Claude654 {decodeURIComponent(message)}
655 </div>
656 )}
657 {error && (
7581253Claude658 <div class="rs-banner is-error" role="alert">
659 <span class="rs-banner-dot" aria-hidden="true" />
9ff7128Claude660 {decodeURIComponent(error)}
661 </div>
662 )}
663
7581253Claude664 <section class={`rs-status ${statusVariant}`}>
665 <div class="rs-status-row">
666 <span class="rs-status-mark" aria-hidden="true">
667 {statusMark}
668 </span>
669 <div class="rs-status-text">
670 <h2 class="rs-status-headline">{statusHead}</h2>
671 <p class="rs-status-desc">{statusDesc}</p>
672 </div>
9ff7128Claude673 </div>
7581253Claude674 </section>
675
676 <section class="rs-section">
677 <header class="rs-section-head">
678 <h3 class="rs-section-title">
679 <span class="rs-section-icon" aria-hidden="true">
680 <ListIcon />
681 </span>
682 Existing rulesets
683 </h3>
684 <p class="rs-section-sub">
685 Click into a ruleset to manage rules and change enforcement.
686 </p>
687 </header>
688 <div class="rs-section-body">
689 {all.length === 0 ? (
690 <div class="rs-empty">
691 <div class="rs-empty-orb" aria-hidden="true" />
692 <div class="rs-empty-mark" aria-hidden="true">
693 <ListIcon />
9ff7128Claude694 </div>
7581253Claude695 <h4 class="rs-empty-title">No rulesets yet</h4>
696 <p class="rs-empty-body">
697 Rulesets sit alongside branch protection — they let you
698 enforce per-commit policy (message format, blocked paths, max
699 file size, no force push) across branches and tags.
700 </p>
701 </div>
702 ) : (
703 <div class="rs-list">
704 {all.map((rs) => (
705 <a
706 href={`/${ownerName}/${repoName}/settings/rulesets/${rs.id}`}
707 class="rs-item"
708 >
709 <div>
710 <div class="rs-item-head">
711 <span class="rs-item-name">{rs.name}</span>
712 {enforcementPill(rs.enforcement)}
713 <span class="rs-chip">
714 {rs.rules.length} rule
715 {rs.rules.length === 1 ? "" : "s"}
716 </span>
717 </div>
718 </div>
719 <span style="color:var(--text-muted);font-size:18px" aria-hidden="true">→</span>
720 </a>
721 ))}
9ff7128Claude722 </div>
7581253Claude723 )}
9ff7128Claude724 </div>
7581253Claude725 </section>
9ff7128Claude726
7581253Claude727 <section class="rs-section">
728 <header class="rs-section-head">
729 <h3 class="rs-section-title">
730 <span class="rs-section-icon" aria-hidden="true">
731 <PlusIcon />
732 </span>
733 New ruleset
734 </h3>
735 <p class="rs-section-sub">
736 Give it a name, pick an enforcement mode, then add rules on the
737 detail page.
738 </p>
739 </header>
740 <form
741 method="post"
742 action={`/${ownerName}/${repoName}/settings/rulesets`}
743 class="rs-form"
744 >
745 <div class="rs-form-group">
746 <label class="rs-form-label" for="rs-name">Name</label>
747 <input
748 type="text"
749 id="rs-name"
750 name="name"
751 placeholder="e.g. release-branches"
752 required
753 maxLength={120}
754 class="rs-input"
755 />
756 </div>
757 <div class="rs-form-group">
758 <label class="rs-form-label" for="rs-enf">Enforcement</label>
759 <select id="rs-enf" name="enforcement" required class="rs-select">
760 <option value="active">active — block on violation</option>
761 <option value="evaluate">evaluate — log only</option>
762 <option value="disabled">disabled</option>
763 </select>
764 </div>
765 <button type="submit" class="rs-btn rs-btn-primary">
766 <PlusIcon /> Create ruleset
767 </button>
768 </form>
769 </section>
9ff7128Claude770 </div>
7581253Claude771 <style dangerouslySetInnerHTML={{ __html: rsStyles }} />
9ff7128Claude772 </Layout>
773 );
774});
775
febd4f0Claude776rulesets.post("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => {
9ff7128Claude777 const ctx = await gate(c);
778 if (ctx instanceof Response) return ctx;
779 const { ownerName, repoName, repo, user } = ctx;
780 const body = await c.req.parseBody();
781 const name = String(body.name || "");
782 const enforcement = String(body.enforcement || "active") as
783 | "active"
784 | "evaluate"
785 | "disabled";
786 const result = await createRuleset({
787 repositoryId: repo.id,
788 name,
789 enforcement,
790 createdBy: user.id,
791 });
792 const base = `/${ownerName}/${repoName}/settings/rulesets`;
793 if (!result.ok) {
794 return c.redirect(`${base}?error=${encodeURIComponent(result.error)}`);
795 }
796 await audit({
797 userId: user.id,
798 repositoryId: repo.id,
799 action: "ruleset.create",
800 targetId: result.id,
801 metadata: { name, enforcement },
802 });
803 return c.redirect(`${base}/${result.id}`);
804});
805
806// ---------- Detail ----------
807
808rulesets.get(
809 "/:owner/:repo/settings/rulesets/:id",
810 requireAuth,
febd4f0Claude811 requireRepoAccess("admin"),
9ff7128Claude812 async (c) => {
813 const ctx = await gate(c);
814 if (ctx instanceof Response) return ctx;
815 const { ownerName, repoName, repo, user } = ctx;
816 const id = c.req.param("id");
817 const rs = await getRuleset(id, repo.id);
818 if (!rs) return c.notFound();
819 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
820 const message = c.req.query("message");
821 const error = c.req.query("error");
7581253Claude822
823 const statusVariant: "is-on" | "is-warn" | "is-empty" =
824 rs.enforcement === "active"
825 ? "is-on"
826 : rs.enforcement === "evaluate"
827 ? "is-warn"
828 : "is-empty";
829 const statusHead =
830 rs.enforcement === "active"
831 ? `Active · ${rs.rules.length} rule${rs.rules.length === 1 ? "" : "s"} enforced`
832 : rs.enforcement === "evaluate"
833 ? `Evaluate-only · ${rs.rules.length} rule${rs.rules.length === 1 ? "" : "s"} logged`
834 : `Disabled · ${rs.rules.length} rule${rs.rules.length === 1 ? "" : "s"} inert`;
835 const statusDesc =
836 rs.enforcement === "active"
837 ? "Pushes that violate any rule below are rejected."
838 : rs.enforcement === "evaluate"
839 ? "Violations are recorded but pushes still go through."
840 : "This ruleset does nothing right now. Switch to active or evaluate to use it.";
841
9ff7128Claude842 return c.html(
843 <Layout
844 title={`Ruleset ${rs.name} — ${ownerName}/${repoName}`}
845 user={user}
846 >
847 <RepoHeader owner={ownerName} repo={repoName} />
848 <RepoNav owner={ownerName} repo={repoName} active="settings" />
7581253Claude849
850 <div class="rs-wrap">
851 <section class="rs-hero">
852 <div class="rs-hero-orb" aria-hidden="true" />
853 <div class="rs-hero-inner">
854 <div class="rs-hero-text">
855 <div class="rs-eyebrow">
856 <span class="rs-eyebrow-pill" aria-hidden="true">
857 <ShieldIcon />
858 </span>
859 Ruleset · {ownerName}/{repoName}
860 </div>
861 <h1 class="rs-title">
862 <span class="rs-title-grad">{rs.name}</span>
863 </h1>
864 <p class="rs-sub">
865 Manage rules and enforcement for this ruleset. Add rules
866 below — each rule type uses a JSON params blob.
867 </p>
868 </div>
869 <a
870 href={`/${ownerName}/${repoName}/settings/rulesets`}
871 class="rs-hero-link"
9ff7128Claude872 >
7581253Claude873 <ArrowLeft /> All rulesets
874 </a>
9ff7128Claude875 </div>
7581253Claude876 </section>
9ff7128Claude877
878 {message && (
7581253Claude879 <div class="rs-banner is-ok" role="status">
880 <span class="rs-banner-dot" aria-hidden="true" />
9ff7128Claude881 {decodeURIComponent(message)}
882 </div>
883 )}
884 {error && (
7581253Claude885 <div class="rs-banner is-error" role="alert">
886 <span class="rs-banner-dot" aria-hidden="true" />
9ff7128Claude887 {decodeURIComponent(error)}
888 </div>
889 )}
890
7581253Claude891 <section class={`rs-status ${statusVariant}`}>
892 <div class="rs-status-row">
893 <span class="rs-status-mark" aria-hidden="true">
894 <ShieldIcon />
895 </span>
896 <div class="rs-status-text">
897 <h2 class="rs-status-headline">{statusHead}</h2>
898 <p class="rs-status-desc">{statusDesc}</p>
899 </div>
900 {enforcementPill(rs.enforcement)}
901 </div>
902 </section>
9ff7128Claude903
7581253Claude904 <section class="rs-section">
905 <header class="rs-section-head">
906 <h3 class="rs-section-title">
907 <span class="rs-section-icon" aria-hidden="true">
908 <SettingsIcon />
909 </span>
910 Enforcement mode
911 </h3>
912 <p class="rs-section-sub">
913 Change how this ruleset behaves on push.
914 </p>
915 </header>
916 <div class="rs-section-body">
917 <form method="post" action={base} class="rs-inline-form">
918 <select name="enforcement" class="rs-select">
919 <option value="active" selected={rs.enforcement === "active" as any}>
920 active — block on violation
921 </option>
922 <option value="evaluate" selected={rs.enforcement === "evaluate" as any}>
923 evaluate — log only
924 </option>
925 <option value="disabled" selected={rs.enforcement === "disabled" as any}>
926 disabled
927 </option>
928 </select>
929 <button type="submit" class="rs-btn rs-btn-primary rs-btn-sm">
930 Update
931 </button>
932 </form>
9ff7128Claude933 </div>
7581253Claude934 </section>
935
936 <section class="rs-section">
937 <header class="rs-section-head">
938 <h3 class="rs-section-title">
939 <span class="rs-section-icon" aria-hidden="true">
940 <ListIcon />
941 </span>
942 Rules
943 </h3>
944 <p class="rs-section-sub">
945 Each rule defines one constraint. All rules in the ruleset must
946 pass for the push to be allowed.
947 </p>
948 </header>
949 <div class="rs-section-body">
950 {rs.rules.length === 0 ? (
951 <div class="rs-empty">
952 <div class="rs-empty-orb" aria-hidden="true" />
953 <div class="rs-empty-mark" aria-hidden="true">
954 <ListIcon />
955 </div>
956 <h4 class="rs-empty-title">No rules in this ruleset</h4>
957 <p class="rs-empty-body">
958 Add a rule below to make this ruleset do something. Until
959 then, no constraints are enforced.
960 </p>
961 </div>
962 ) : (
963 rs.rules.map((r) => {
964 const params = parseParams(r.params);
965 return (
966 <div class="rs-rule">
967 <div class="rs-rule-body">
968 <span class="rs-rule-type">{r.ruleType}</span>
969 <span class="rs-rule-desc">
970 {ruleDescription(r.ruleType, params)}
9ff7128Claude971 </span>
972 </div>
7581253Claude973 <form method="post" action={`${base}/rules/${r.id}/delete`}>
9ff7128Claude974 <button
975 type="submit"
7581253Claude976 class="rs-btn rs-btn-danger rs-btn-sm"
9ff7128Claude977 >
978 Delete
979 </button>
980 </form>
981 </div>
7581253Claude982 );
983 })
984 )}
9ff7128Claude985 </div>
7581253Claude986 </section>
9ff7128Claude987
7581253Claude988 <section class="rs-section">
989 <header class="rs-section-head">
990 <h3 class="rs-section-title">
991 <span class="rs-section-icon" aria-hidden="true">
992 <PlusIcon />
993 </span>
994 Add rule
995 </h3>
996 <p class="rs-section-sub">
997 Pick a type and provide its JSON params.
998 </p>
999 </header>
1000 <form method="post" action={`${base}/rules`} class="rs-form">
1001 <div class="rs-form-group">
1002 <label class="rs-form-label" for="rt">Rule type</label>
1003 <select id="rt" name="rule_type" required class="rs-select">
1004 {RULE_TYPES.map((t) => (
1005 <option value={t}>{t}</option>
1006 ))}
1007 </select>
1008 </div>
1009 <div class="rs-form-group">
1010 <label class="rs-form-label" for="rp">Params (JSON)</label>
1011 <textarea
1012 id="rp"
1013 name="params"
1014 rows={4}
1015 placeholder="{}"
1016 class="rs-textarea"
1017 ></textarea>
1018 <div class="rs-form-hint">
1019 Example: <code>{`{"pattern":"^(feat|fix|chore):"}`}</code>
1020 </div>
1021 </div>
1022 <button type="submit" class="rs-btn rs-btn-primary">
1023 <PlusIcon /> Add rule
1024 </button>
1025 </form>
1026 </section>
9ff7128Claude1027
7581253Claude1028 <section class="rs-danger">
1029 <h3>Danger zone</h3>
1030 <p>
1031 Deleting a ruleset removes it and all of its rules permanently.
1032 </p>
1033 <form
1034 method="post"
1035 action={`${base}/delete`}
1036 onsubmit="return confirm('Delete this ruleset and all of its rules?')"
1037 >
1038 <button type="submit" class="rs-btn rs-btn-danger">
1039 Delete ruleset
1040 </button>
1041 </form>
1042 </section>
9ff7128Claude1043 </div>
7581253Claude1044 <style dangerouslySetInnerHTML={{ __html: rsStyles }} />
9ff7128Claude1045 </Layout>
1046 );
1047 }
1048);
1049
1050rulesets.post(
1051 "/:owner/:repo/settings/rulesets/:id",
1052 requireAuth,
febd4f0Claude1053 requireRepoAccess("admin"),
9ff7128Claude1054 async (c) => {
1055 const ctx = await gate(c);
1056 if (ctx instanceof Response) return ctx;
1057 const { ownerName, repoName, repo, user } = ctx;
1058 const id = c.req.param("id");
1059 const body = await c.req.parseBody();
1060 const enforcement = String(body.enforcement || "active") as
1061 | "active"
1062 | "evaluate"
1063 | "disabled";
1064 const ok = await updateRulesetEnforcement(id, repo.id, enforcement);
1065 if (ok) {
1066 await audit({
1067 userId: user.id,
1068 repositoryId: repo.id,
1069 action: "ruleset.update",
1070 targetId: id,
1071 metadata: { enforcement },
1072 });
1073 }
1074 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
1075 return c.redirect(
1076 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
1077 ok ? "Updated." : "Update failed"
1078 )}`
1079 );
1080 }
1081);
1082
1083rulesets.post(
1084 "/:owner/:repo/settings/rulesets/:id/delete",
1085 requireAuth,
febd4f0Claude1086 requireRepoAccess("admin"),
9ff7128Claude1087 async (c) => {
1088 const ctx = await gate(c);
1089 if (ctx instanceof Response) return ctx;
1090 const { ownerName, repoName, repo, user } = ctx;
1091 const id = c.req.param("id");
1092 const ok = await deleteRuleset(id, repo.id);
1093 if (ok) {
1094 await audit({
1095 userId: user.id,
1096 repositoryId: repo.id,
1097 action: "ruleset.delete",
1098 targetId: id,
1099 });
1100 }
1101 const base = `/${ownerName}/${repoName}/settings/rulesets`;
1102 return c.redirect(
1103 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
1104 ok ? "Ruleset deleted." : "Delete failed"
1105 )}`
1106 );
1107 }
1108);
1109
1110rulesets.post(
1111 "/:owner/:repo/settings/rulesets/:id/rules",
1112 requireAuth,
febd4f0Claude1113 requireRepoAccess("admin"),
9ff7128Claude1114 async (c) => {
1115 const ctx = await gate(c);
1116 if (ctx instanceof Response) return ctx;
1117 const { ownerName, repoName, repo, user } = ctx;
1118 const id = c.req.param("id");
1119 const body = await c.req.parseBody();
1120 const ruleType = String(body.rule_type || "") as any;
1121 const params = parseParams(String(body.params || "{}"));
1122 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
1123 const result = await addRule({
1124 rulesetId: id,
1125 repositoryId: repo.id,
1126 ruleType,
1127 params,
1128 });
1129 if (!result.ok) {
1130 return c.redirect(`${base}?error=${encodeURIComponent(result.error)}`);
1131 }
1132 await audit({
1133 userId: user.id,
1134 repositoryId: repo.id,
1135 action: "ruleset.rule.add",
1136 targetId: result.id,
1137 metadata: { ruleType, params },
1138 });
1139 return c.redirect(`${base}?message=${encodeURIComponent("Rule added.")}`);
1140 }
1141);
1142
1143rulesets.post(
1144 "/:owner/:repo/settings/rulesets/:id/rules/:rid/delete",
1145 requireAuth,
febd4f0Claude1146 requireRepoAccess("admin"),
9ff7128Claude1147 async (c) => {
1148 const ctx = await gate(c);
1149 if (ctx instanceof Response) return ctx;
1150 const { ownerName, repoName, repo, user } = ctx;
1151 const id = c.req.param("id");
1152 const rid = c.req.param("rid");
1153 const ok = await deleteRule(rid, id, repo.id);
1154 if (ok) {
1155 await audit({
1156 userId: user.id,
1157 repositoryId: repo.id,
1158 action: "ruleset.rule.delete",
1159 targetId: rid,
1160 });
1161 }
1162 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
1163 return c.redirect(
1164 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
1165 ok ? "Rule removed." : "Delete failed"
1166 )}`
1167 );
1168 }
1169);
1170
1171export default rulesets;