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.
| 9ff7128 | 1 | /** |
| 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 | ||
| 13 | import { Hono } from "hono"; | |
| 14 | import { and, eq } from "drizzle-orm"; | |
| 15 | import { db } from "../db"; | |
| 16 | import { repositories, users } from "../db/schema"; | |
| 17 | import { Layout } from "../views/layout"; | |
| 18 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 19 | import { requireAuth, softAuth } from "../middleware/auth"; | |
| 20 | import type { AuthEnv } from "../middleware/auth"; | |
| febd4f0 | 21 | import { requireRepoAccess } from "../middleware/repo-access"; |
| 9ff7128 | 22 | import { audit } from "../lib/notify"; |
| 23 | import { | |
| 24 | RULE_TYPES, | |
| 25 | addRule, | |
| 26 | createRuleset, | |
| 27 | deleteRule, | |
| 28 | deleteRuleset, | |
| 29 | getRuleset, | |
| 30 | listRulesetsForRepo, | |
| 31 | parseParams, | |
| 32 | updateRulesetEnforcement, | |
| 33 | } from "../lib/rulesets"; | |
| 34 | ||
| 35 | const rulesets = new Hono<AuthEnv>(); | |
| 36 | rulesets.use("*", softAuth); | |
| 37 | ||
| 38 | async 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 | ||
| 62 | function 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 | ||
| febd4f0 | 83 | rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => { |
| 9ff7128 | 84 | 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 | |
| 001af43 | 160 | method="post" |
| 9ff7128 | 161 | 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 | ||
| febd4f0 | 193 | rulesets.post("/:owner/:repo/settings/rulesets", requireAuth, requireRepoAccess("admin"), async (c) => { |
| 9ff7128 | 194 | 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 | ||
| 225 | rulesets.get( | |
| 226 | "/:owner/:repo/settings/rulesets/:id", | |
| 227 | requireAuth, | |
| febd4f0 | 228 | requireRepoAccess("admin"), |
| 9ff7128 | 229 | 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 | |
| 001af43 | 283 | method="post" |
| 9ff7128 | 284 | 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 | |
| 001af43 | 336 | method="post" |
| 9ff7128 | 337 | 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 | |
| 001af43 | 356 | method="post" |
| 9ff7128 | 357 | 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> | |
| 001af43 | 388 | <form method="post" action={`${base}/delete`}> |
| 9ff7128 | 389 | <button type="submit" class="btn" style="color:var(--red)"> |
| 390 | Delete ruleset | |
| 391 | </button> | |
| 392 | </form> | |
| 393 | </div> | |
| 394 | </Layout> | |
| 395 | ); | |
| 396 | } | |
| 397 | ); | |
| 398 | ||
| 399 | rulesets.post( | |
| 400 | "/:owner/:repo/settings/rulesets/:id", | |
| 401 | requireAuth, | |
| febd4f0 | 402 | requireRepoAccess("admin"), |
| 9ff7128 | 403 | 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 | ||
| 432 | rulesets.post( | |
| 433 | "/:owner/:repo/settings/rulesets/:id/delete", | |
| 434 | requireAuth, | |
| febd4f0 | 435 | requireRepoAccess("admin"), |
| 9ff7128 | 436 | 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 | ||
| 459 | rulesets.post( | |
| 460 | "/:owner/:repo/settings/rulesets/:id/rules", | |
| 461 | requireAuth, | |
| febd4f0 | 462 | requireRepoAccess("admin"), |
| 9ff7128 | 463 | 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 | ||
| 492 | rulesets.post( | |
| 493 | "/:owner/:repo/settings/rulesets/:id/rules/:rid/delete", | |
| 494 | requireAuth, | |
| febd4f0 | 495 | requireRepoAccess("admin"), |
| 9ff7128 | 496 | 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 | ||
| 520 | export default rulesets; |