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