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.tsxBlame520 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
11 */
12
13import { Hono } from "hono";
14import { and, eq } from "drizzle-orm";
15import { db } from "../db";
16import { repositories, users } from "../db/schema";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { requireAuth, softAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
febd4f0Claude21import { requireRepoAccess } from "../middleware/repo-access";
9ff7128Claude22import { audit } from "../lib/notify";
23import {
24 RULE_TYPES,
25 addRule,
26 createRuleset,
27 deleteRule,
28 deleteRuleset,
29 getRuleset,
30 listRulesetsForRepo,
31 parseParams,
32 updateRulesetEnforcement,
33} from "../lib/rulesets";
34
35const rulesets = new Hono<AuthEnv>();
36rulesets.use("*", softAuth);
37
38async function gate(c: any) {
39 const user = c.get("user");
40 if (!user) return c.redirect("/login");
41 const { owner: ownerName, repo: repoName } = c.req.param();
42 const [owner] = await db
43 .select()
44 .from(users)
45 .where(eq(users.username, ownerName))
46 .limit(1);
47 if (!owner) return c.notFound();
48 const [repo] = await db
49 .select()
50 .from(repositories)
51 .where(
52 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
53 )
54 .limit(1);
55 if (!repo) return c.notFound();
56 if (user.id !== repo.ownerId) {
57 return c.redirect(`/${ownerName}/${repoName}`);
58 }
59 return { user, owner, repo, ownerName, repoName };
60}
61
62function ruleDescription(type: string, params: Record<string, unknown>): string {
63 switch (type) {
64 case "commit_message_pattern":
65 return `commit message ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
66 case "branch_name_pattern":
67 return `branch name ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
68 case "tag_name_pattern":
69 return `tag name ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
70 case "blocked_file_paths":
71 return `blocks changes to: ${(params.paths as string[] | undefined)?.join(", ") || "(none)"}`;
72 case "max_file_size":
73 return `max blob size ${params.bytes || 0}B`;
74 case "forbid_force_push":
75 return "force push forbidden";
76 default:
77 return type;
78 }
79}
80
81// ---------- List + create ----------
82
febd4f0Claude83rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => {
9ff7128Claude84 const ctx = await gate(c);
85 if (ctx instanceof Response) return ctx;
86 const { ownerName, repoName, repo, user } = ctx;
87 const all = await listRulesetsForRepo(repo.id);
88 const message = c.req.query("message");
89 const error = c.req.query("error");
90 return c.html(
91 <Layout title={`Rulesets — ${ownerName}/${repoName}`} user={user}>
92 <RepoHeader owner={ownerName} repo={repoName} />
93 <RepoNav owner={ownerName} repo={repoName} active="settings" />
94 <div class="settings-container">
95 <h2>Rulesets</h2>
96 <p style="color:var(--text-muted)">
97 Policy engine that extends branch protection. Each ruleset contains
98 multiple rules (commit messages, branch/tag names, blocked paths,
99 max file size, force-push bans). Enforcement modes:
100 <strong> active</strong> blocks, <strong>evaluate</strong> only logs,
101 <strong> disabled</strong> is inert.
102 </p>
103 {message && (
104 <div class="auth-success" style="margin-top:12px">
105 {decodeURIComponent(message)}
106 </div>
107 )}
108 {error && (
109 <div class="auth-error" style="margin-top:12px">
110 {decodeURIComponent(error)}
111 </div>
112 )}
113
114 <h3 style="margin-top:24px">Existing rulesets</h3>
115 {all.length === 0 ? (
116 <div class="panel-empty" style="padding:16px">
117 No rulesets yet.
118 </div>
119 ) : (
120 <div class="panel">
121 {all.map((rs) => (
122 <div
123 class="panel-item"
124 style="flex-direction:column;align-items:stretch;gap:4px"
125 >
126 <div style="display:flex;justify-content:space-between;gap:12px">
127 <div>
128 <a
129 href={`/${ownerName}/${repoName}/settings/rulesets/${rs.id}`}
130 style="font-weight:600"
131 >
132 {rs.name}
133 </a>
134 <span
135 style={`margin-left:8px;font-size:10px;padding:2px 6px;border-radius:3px;background:${
136 rs.enforcement === "active"
137 ? "var(--red)"
138 : rs.enforcement === "evaluate"
139 ? "var(--yellow)"
140 : "var(--bg-subtle)"
141 };color:#fff;text-transform:uppercase`}
142 >
143 {rs.enforcement}
144 </span>
145 <span
146 style="margin-left:8px;color:var(--text-muted);font-size:12px"
147 >
148 {rs.rules.length} rule
149 {rs.rules.length === 1 ? "" : "s"}
150 </span>
151 </div>
152 </div>
153 </div>
154 ))}
155 </div>
156 )}
157
158 <h3 style="margin-top:24px">New ruleset</h3>
159 <form
001af43Claude160 method="post"
9ff7128Claude161 action={`/${ownerName}/${repoName}/settings/rulesets`}
162 class="auth-form"
163 style="max-width:520px"
164 >
165 <div class="form-group">
166 <label for="rs-name">Name</label>
167 <input
168 type="text"
169 id="rs-name"
170 name="name"
171 placeholder="e.g. release-branches"
172 required
173 maxLength={120}
174 />
175 </div>
176 <div class="form-group">
177 <label for="rs-enf">Enforcement</label>
178 <select id="rs-enf" name="enforcement" required>
179 <option value="active">active — block on violation</option>
180 <option value="evaluate">evaluate — log only</option>
181 <option value="disabled">disabled</option>
182 </select>
183 </div>
184 <button type="submit" class="btn btn-primary">
185 Create ruleset
186 </button>
187 </form>
188 </div>
189 </Layout>
190 );
191});
192
febd4f0Claude193rulesets.post("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => {
9ff7128Claude194 const ctx = await gate(c);
195 if (ctx instanceof Response) return ctx;
196 const { ownerName, repoName, repo, user } = ctx;
197 const body = await c.req.parseBody();
198 const name = String(body.name || "");
199 const enforcement = String(body.enforcement || "active") as
200 | "active"
201 | "evaluate"
202 | "disabled";
203 const result = await createRuleset({
204 repositoryId: repo.id,
205 name,
206 enforcement,
207 createdBy: user.id,
208 });
209 const base = `/${ownerName}/${repoName}/settings/rulesets`;
210 if (!result.ok) {
211 return c.redirect(`${base}?error=${encodeURIComponent(result.error)}`);
212 }
213 await audit({
214 userId: user.id,
215 repositoryId: repo.id,
216 action: "ruleset.create",
217 targetId: result.id,
218 metadata: { name, enforcement },
219 });
220 return c.redirect(`${base}/${result.id}`);
221});
222
223// ---------- Detail ----------
224
225rulesets.get(
226 "/:owner/:repo/settings/rulesets/:id",
227 requireAuth,
febd4f0Claude228 requireRepoAccess("admin"),
9ff7128Claude229 async (c) => {
230 const ctx = await gate(c);
231 if (ctx instanceof Response) return ctx;
232 const { ownerName, repoName, repo, user } = ctx;
233 const id = c.req.param("id");
234 const rs = await getRuleset(id, repo.id);
235 if (!rs) return c.notFound();
236 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
237 const message = c.req.query("message");
238 const error = c.req.query("error");
239 return c.html(
240 <Layout
241 title={`Ruleset ${rs.name} — ${ownerName}/${repoName}`}
242 user={user}
243 >
244 <RepoHeader owner={ownerName} repo={repoName} />
245 <RepoNav owner={ownerName} repo={repoName} active="settings" />
246 <div class="settings-container">
247 <div style="display:flex;justify-content:space-between;gap:12px;align-items:center">
248 <div>
249 <h2 style="margin:0">{rs.name}</h2>
250 <span
251 style={`font-size:10px;padding:2px 6px;border-radius:3px;background:${
252 rs.enforcement === "active"
253 ? "var(--red)"
254 : rs.enforcement === "evaluate"
255 ? "var(--yellow)"
256 : "var(--bg-subtle)"
257 };color:#fff;text-transform:uppercase`}
258 >
259 {rs.enforcement}
260 </span>
261 </div>
262 <a
263 href={`/${ownerName}/${repoName}/settings/rulesets`}
264 class="btn btn-sm"
265 >
266 ← Back
267 </a>
268 </div>
269
270 {message && (
271 <div class="auth-success" style="margin-top:12px">
272 {decodeURIComponent(message)}
273 </div>
274 )}
275 {error && (
276 <div class="auth-error" style="margin-top:12px">
277 {decodeURIComponent(error)}
278 </div>
279 )}
280
281 <h3 style="margin-top:24px">Enforcement</h3>
282 <form
001af43Claude283 method="post"
9ff7128Claude284 action={base}
285 style="display:flex;gap:8px;align-items:center"
286 >
287 <select name="enforcement">
288 <option
289 value="active"
290 selected={rs.enforcement === "active" as any}
291 >
292 active
293 </option>
294 <option
295 value="evaluate"
296 selected={rs.enforcement === "evaluate" as any}
297 >
298 evaluate
299 </option>
300 <option
301 value="disabled"
302 selected={rs.enforcement === "disabled" as any}
303 >
304 disabled
305 </option>
306 </select>
307 <button type="submit" class="btn btn-primary btn-sm">
308 Update
309 </button>
310 </form>
311
312 <h3 style="margin-top:24px">Rules</h3>
313 {rs.rules.length === 0 ? (
314 <div class="panel-empty" style="padding:16px">
315 No rules in this ruleset.
316 </div>
317 ) : (
318 <div class="panel">
319 {rs.rules.map((r) => {
320 const params = parseParams(r.params);
321 return (
322 <div
323 class="panel-item"
324 style="flex-direction:column;align-items:stretch;gap:4px"
325 >
326 <div style="display:flex;justify-content:space-between;gap:8px">
327 <div>
328 <span
329 style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase;margin-right:6px"
330 >
331 {r.ruleType}
332 </span>
333 <span>{ruleDescription(r.ruleType, params)}</span>
334 </div>
335 <form
001af43Claude336 method="post"
9ff7128Claude337 action={`${base}/rules/${r.id}/delete`}
338 >
339 <button
340 type="submit"
341 class="btn btn-sm"
342 style="font-size:11px"
343 >
344 Delete
345 </button>
346 </form>
347 </div>
348 </div>
349 );
350 })}
351 </div>
352 )}
353
354 <h3 style="margin-top:24px">Add rule</h3>
355 <form
001af43Claude356 method="post"
9ff7128Claude357 action={`${base}/rules`}
358 class="auth-form"
359 style="max-width:640px"
360 >
361 <div class="form-group">
362 <label for="rt">Rule type</label>
363 <select id="rt" name="rule_type" required>
364 {RULE_TYPES.map((t) => (
365 <option value={t}>{t}</option>
366 ))}
367 </select>
368 </div>
369 <div class="form-group">
370 <label for="rp">
371 Params (JSON) — e.g.{" "}
372 <code>{`{"pattern":"^(feat|fix|chore):"}`}</code>
373 </label>
374 <textarea
375 id="rp"
376 name="params"
377 rows={4}
378 placeholder="{}"
379 style="font-family:var(--font-mono);font-size:12px"
380 ></textarea>
381 </div>
382 <button type="submit" class="btn btn-primary">
383 Add rule
384 </button>
385 </form>
386
387 <h3 style="margin-top:24px;color:var(--red)">Danger zone</h3>
001af43Claude388 <form method="post" action={`${base}/delete`}>
9ff7128Claude389 <button type="submit" class="btn" style="color:var(--red)">
390 Delete ruleset
391 </button>
392 </form>
393 </div>
394 </Layout>
395 );
396 }
397);
398
399rulesets.post(
400 "/:owner/:repo/settings/rulesets/:id",
401 requireAuth,
febd4f0Claude402 requireRepoAccess("admin"),
9ff7128Claude403 async (c) => {
404 const ctx = await gate(c);
405 if (ctx instanceof Response) return ctx;
406 const { ownerName, repoName, repo, user } = ctx;
407 const id = c.req.param("id");
408 const body = await c.req.parseBody();
409 const enforcement = String(body.enforcement || "active") as
410 | "active"
411 | "evaluate"
412 | "disabled";
413 const ok = await updateRulesetEnforcement(id, repo.id, enforcement);
414 if (ok) {
415 await audit({
416 userId: user.id,
417 repositoryId: repo.id,
418 action: "ruleset.update",
419 targetId: id,
420 metadata: { enforcement },
421 });
422 }
423 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
424 return c.redirect(
425 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
426 ok ? "Updated." : "Update failed"
427 )}`
428 );
429 }
430);
431
432rulesets.post(
433 "/:owner/:repo/settings/rulesets/:id/delete",
434 requireAuth,
febd4f0Claude435 requireRepoAccess("admin"),
9ff7128Claude436 async (c) => {
437 const ctx = await gate(c);
438 if (ctx instanceof Response) return ctx;
439 const { ownerName, repoName, repo, user } = ctx;
440 const id = c.req.param("id");
441 const ok = await deleteRuleset(id, repo.id);
442 if (ok) {
443 await audit({
444 userId: user.id,
445 repositoryId: repo.id,
446 action: "ruleset.delete",
447 targetId: id,
448 });
449 }
450 const base = `/${ownerName}/${repoName}/settings/rulesets`;
451 return c.redirect(
452 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
453 ok ? "Ruleset deleted." : "Delete failed"
454 )}`
455 );
456 }
457);
458
459rulesets.post(
460 "/:owner/:repo/settings/rulesets/:id/rules",
461 requireAuth,
febd4f0Claude462 requireRepoAccess("admin"),
9ff7128Claude463 async (c) => {
464 const ctx = await gate(c);
465 if (ctx instanceof Response) return ctx;
466 const { ownerName, repoName, repo, user } = ctx;
467 const id = c.req.param("id");
468 const body = await c.req.parseBody();
469 const ruleType = String(body.rule_type || "") as any;
470 const params = parseParams(String(body.params || "{}"));
471 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
472 const result = await addRule({
473 rulesetId: id,
474 repositoryId: repo.id,
475 ruleType,
476 params,
477 });
478 if (!result.ok) {
479 return c.redirect(`${base}?error=${encodeURIComponent(result.error)}`);
480 }
481 await audit({
482 userId: user.id,
483 repositoryId: repo.id,
484 action: "ruleset.rule.add",
485 targetId: result.id,
486 metadata: { ruleType, params },
487 });
488 return c.redirect(`${base}?message=${encodeURIComponent("Rule added.")}`);
489 }
490);
491
492rulesets.post(
493 "/:owner/:repo/settings/rulesets/:id/rules/:rid/delete",
494 requireAuth,
febd4f0Claude495 requireRepoAccess("admin"),
9ff7128Claude496 async (c) => {
497 const ctx = await gate(c);
498 if (ctx instanceof Response) return ctx;
499 const { ownerName, repoName, repo, user } = ctx;
500 const id = c.req.param("id");
501 const rid = c.req.param("rid");
502 const ok = await deleteRule(rid, id, repo.id);
503 if (ok) {
504 await audit({
505 userId: user.id,
506 repositoryId: repo.id,
507 action: "ruleset.rule.delete",
508 targetId: rid,
509 });
510 }
511 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
512 return c.redirect(
513 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
514 ok ? "Rule removed." : "Delete failed"
515 )}`
516 );
517 }
518);
519
520export default rulesets;