Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit9ff7128unknown_key

feat(BLOCK-J): J6 repository rulesets (push policy engine)

feat(BLOCK-J): J6 repository rulesets (push policy engine)

Named policies group N rules at active/evaluate/disabled enforcement.
Pure evaluator evaluatePush(rulesets, ctx) → {allowed, violations}.
Six rule types: commit_message_pattern, branch_name_pattern,
tag_name_pattern, blocked_file_paths, max_file_size, forbid_force_push.
Owner-only CRUD UI at /:owner/:repo/settings/rulesets.
23 new tests (rule evaluation, enforcement modes, glob edge cases,
route auth). Total suite: 735/735 passing.

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 15, 2026Parent: d412586
7 files changed+137819ff71288570c6a84f73ff584ee1e5d10c4a466f8
7 changed files+1378−1
ModifiedBUILD_BIBLE.md+6−1View fileUnifiedSplit
9090| Dependency graph | ✅ | J1 — `src/lib/deps.ts` parses package.json / requirements.txt / pyproject.toml / go.mod / Cargo.toml / Gemfile / composer.json without a TOML lib. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` grouped by ecosystem with per-ecosystem counts; owner-only reindex walks the default-branch tree (max 200 manifests, 1MB each). `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies`. |
9191| Security advisories / Dependabot alerts | ✅ | J2 — curated 12-entry seed list + minimal semver range matcher cross-referenced against J1 dep rows. `src/lib/advisories.ts` + `src/routes/advisories.tsx` serve `/:owner/:repo/security/advisories` (open) + `/all`, owner-only `POST /scan`, and per-alert dismiss/reopen. `drizzle/0029_security_advisories.sql` adds `security_advisories` + `repo_advisory_alerts`. |
9292| Commit signature verification (Verified badge) | ✅ | J3 — GPG + SSH pubkey registration at `/settings/signing-keys`, `gpgsig` extraction from raw commit objects, OpenPGP packet walker for Issuer Fingerprint, SHA-256 fingerprints for SSHSIG pubkeys, memoised in `commit_verifications`. Green "Verified" badge rendered on commit list + detail when a registered key matches. `src/lib/signatures.ts` + `src/routes/signing-keys.tsx` + `drizzle/0030_signing_keys.sql`. |
93| Repository rulesets (push policy engine) | ✅ | J6 — named policies group N rules at active/evaluate/disabled enforcement. Pure evaluator `evaluatePush(rulesets, ctx)``{allowed, violations}`. Six rule types: commit_message_pattern, branch_name_pattern, tag_name_pattern, blocked_file_paths, max_file_size, forbid_force_push. Glob-lite matcher (`*` = non-slash, `**` = anything). Owner-only CRUD at `/:owner/:repo/settings/rulesets`. `src/lib/rulesets.ts` + `src/routes/rulesets.tsx` + `drizzle/0032_repo_rulesets.sql`. |
9394
9495### 2.3 Collaboration
9596| Feature | Status | Notes |
287288- **J3** — Commit signature verification (GPG + SSH "Verified" badge) → ✅ shipped. `drizzle/0030_signing_keys.sql` adds `signing_keys` (per-user GPG/SSH pubkeys, unique on `(key_type, fingerprint)`) + `commit_verifications` (memoised per-commit result, unique on `(repo, sha)`). `src/lib/signatures.ts` extracts `gpgsig` / `gpgsig-sha256` from raw commit objects (`getRawCommitObject` added to `src/git/repository.ts`), unarmors PGP + SSH signature blobs, walks OpenPGP packet streams for Issuer Fingerprint (subpacket 33) / Issuer Key ID (subpacket 16), parses the SSHSIG inner publickey field, and SHA-256 fingerprints SSH wire-format keys. Identity matching via fingerprint → optional email check → cached. `src/routes/signing-keys.tsx` serves `GET/POST /settings/signing-keys` + `POST /settings/signing-keys/:id/delete`, audit-logged. `CommitList` + single commit view render a green "Verified" badge when cached `verified=true`. 29 new tests (extraction, unarmor, packet walker, SSH fp, end-to-end fast paths, route auth).
288289- **J4** — User following + personalised feed → ✅ shipped. `drizzle/0031_user_follows.sql` adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK constraint rejecting self-follows, reverse-lookup index on `following_id`). `src/lib/follows.ts` exposes `followUser/unfollowUser/isFollowing/listFollowers/listFollowing/followCounts` + `feedForUser(userId, limit)` which joins `activity_feed` against the follow set (bounded to 200 edges) and filters out private repos the viewer doesn't own. `src/routes/follows.tsx` serves `POST /:user/follow` + `/:user/unfollow` (auth-gated, audit-logged), public `GET /:user/followers` + `/:user/following`, and `GET /feed` (personalised timeline). Follow button + follower/following counts added to the user profile page via `src/routes/web.tsx`. Reserved-name set protects fixed paths (`login`, `settings`, `feed`, etc.). 8 new tests (verb table + route auth).
289290- **J5** — Profile READMEs → ✅ shipped. User profile page at `/:owner` now attempts to render `<user>/<user>/README.md` (GitHub convention) or `<user>/.github/README.md` (org-style fallback) as the hero panel above the repo list. No schema changes — reuses `getReadme` / `renderMarkdown` + `repoExists` from the git layer. Failures are silent; missing repo just hides the panel. 2 smoke tests.
291- **J6** — Repository rulesets (push policy engine) → ✅ shipped. `drizzle/0032_repo_rulesets.sql` adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum active/evaluate/disabled) + `ruleset_rules` (JSON params). `src/lib/rulesets.ts` exposes six rule types (`commit_message_pattern`, `branch_name_pattern`, `tag_name_pattern`, `blocked_file_paths`, `max_file_size`, `forbid_force_push`) + the pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. Helpers: glob-lite matcher (`globToRegex`), defensive `parseParams`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `src/routes/rulesets.tsx` serves owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail, enforcement toggle, add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. 23 new tests covering each rule type, enforcement modes, glob edge cases, and route-auth redirects.
290292
291293### BLOCK H — Marketplace
292294- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
302304- `src/app.tsx` — route composition, middleware order, error handlers
303305- `src/index.ts` — Bun server entry
304306- `src/lib/config.ts` — env getters (late-binding)
305- `src/db/schema.ts` — 92 tables. New tables only via new migration.
307- `src/db/schema.ts` — 94 tables. New tables only via new migration.
306308- `src/db/index.ts` — lazy proxy DB connection
307309- `src/db/migrate.ts` — migration runner
308310- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
334336- `drizzle/0029_security_advisories.sql` (Block J2) — migration, never edited in place. Adds `security_advisories` (`ghsa_id` unique) + `repo_advisory_alerts` (unique on `(repository_id, advisory_id, manifest_path)`, status index).
335337- `drizzle/0030_signing_keys.sql` (Block J3) — migration, never edited in place. Adds `signing_keys` (unique on `(key_type, fingerprint)`) + `commit_verifications` (unique on `(repository_id, commit_sha)`).
336338- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
339- `drizzle/0032_repo_rulesets.sql` (Block J6) — migration, never edited in place. Adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum) + `ruleset_rules` (JSON params).
337340
338341### 4.2 Git layer (locked)
339342- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
461464- `src/routes/symbols.tsx` (Block I8) — `/:owner/:repo/symbols` overview (total + per-kind counts + A–Z list with blob deep-links), `/:owner/:repo/symbols/search?q=` prefix search (ilike `q%`), `/:owner/:repo/symbols/:name` detail (all definitions with signature preview + deep link). `POST /:owner/:repo/symbols/reindex` is requireAuth + owner-only.
462465- `src/lib/mirrors.ts` (Block I9) — upstream URL validator (accepts https/http/git schemes, rejects ssh/file/paths/shell metacharacters, 2048-char cap), `safeUrlForLog` (redacts embedded credentials), `upsertMirror` / `deleteMirror` / `getMirrorForRepo` / `listRecentRuns`, `runMirrorSync` (runs `git fetch --prune --tags --no-write-fetch-head` via `Bun.spawn` with 5-min timeout + `GIT_TERMINAL_PROMPT=0`; updates `last_synced_at` + `last_status` + `last_error`), `listDueMirrors` + `syncAllDue` for the admin trigger.
463466- `src/routes/mirrors.tsx` (Block I9) — owner-only config at `/:owner/:repo/settings/mirror` (GET form + recent-runs panel, POST save, POST delete, POST sync-now). Site-admin `POST /admin/mirrors/sync-all` iterates due mirrors. All mutations `audit()`-logged.
467- `src/lib/rulesets.ts` (Block J6) — exports `RULE_TYPES` (6 types), pure `globToRegex`, defensive `parseParams`, pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `__internal = { evalRule, globToRegex, parseParams }` for tests. Every rule variant no-ops on malformed params or bad regex rather than throwing.
468- `src/routes/rulesets.tsx` (Block J6) — owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail + enforcement toggle + add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. All mutations `audit()`-logged via the existing `gate(c)` owner pattern.
464469
465470### 4.7 Views (locked contracts)
466471- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
Addeddrizzle/0032_repo_rulesets.sql+32−0View fileUnifiedSplit
1-- Block J6 — Repository rulesets.
2--
3-- Extends the legacy branch_protection table with a policy engine that can
4-- scope rules at the repo (v1) or org level (future). Each ruleset groups
5-- N rules; an evaluator short-circuits on enforcement=active with block=true
6-- and merely logs when enforcement=evaluate.
7
8CREATE TABLE IF NOT EXISTS "repo_rulesets" (
9 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
11 "name" text NOT NULL,
12 "enforcement" text NOT NULL DEFAULT 'active', -- 'active' | 'evaluate' | 'disabled'
13 "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
14 "created_at" timestamp NOT NULL DEFAULT now(),
15 "updated_at" timestamp NOT NULL DEFAULT now()
16);
17
18CREATE INDEX IF NOT EXISTS "repo_rulesets_repo_idx"
19 ON "repo_rulesets" ("repository_id");
20CREATE UNIQUE INDEX IF NOT EXISTS "repo_rulesets_repo_name_unique"
21 ON "repo_rulesets" ("repository_id", "name");
22
23CREATE TABLE IF NOT EXISTS "ruleset_rules" (
24 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
25 "ruleset_id" uuid NOT NULL REFERENCES "repo_rulesets"("id") ON DELETE CASCADE,
26 "rule_type" text NOT NULL,
27 "params" text NOT NULL DEFAULT '{}', -- JSON
28 "created_at" timestamp NOT NULL DEFAULT now()
29);
30
31CREATE INDEX IF NOT EXISTS "ruleset_rules_set_idx"
32 ON "ruleset_rules" ("ruleset_id");
Addedsrc/__tests__/rulesets.test.ts+368−0View fileUnifiedSplit
1/**
2 * Block J6 — Ruleset evaluator + route-auth tests.
3 *
4 * Evaluator is pure, so most of the coverage is in this file. DB-backed CRUD
5 * relies on a live DB so it's covered in integration.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import {
11 RULE_TYPES,
12 __internal,
13 evaluatePush,
14 globToRegex,
15 parseParams,
16} from "../lib/rulesets";
17
18describe("rulesets — globToRegex", () => {
19 it("handles literal paths", () => {
20 expect(globToRegex("README.md").test("README.md")).toBe(true);
21 expect(globToRegex("README.md").test("other.md")).toBe(false);
22 });
23
24 it("* matches one segment", () => {
25 expect(globToRegex("src/*.ts").test("src/app.ts")).toBe(true);
26 expect(globToRegex("src/*.ts").test("src/a/b.ts")).toBe(false);
27 });
28
29 it("** matches anything including slashes", () => {
30 expect(globToRegex("docs/**").test("docs/a/b/c.md")).toBe(true);
31 expect(globToRegex("**/secret.txt").test("a/b/secret.txt")).toBe(true);
32 });
33
34 it("escapes regex specials", () => {
35 expect(globToRegex("a+b.txt").test("a+b.txt")).toBe(true);
36 expect(globToRegex("a+b.txt").test("axb.txt")).toBe(false);
37 });
38});
39
40describe("rulesets — parseParams", () => {
41 it("round-trips JSON", () => {
42 expect(parseParams('{"pattern":"^feat:"}')).toEqual({
43 pattern: "^feat:",
44 });
45 });
46
47 it("returns {} on garbage", () => {
48 expect(parseParams("not json")).toEqual({});
49 expect(parseParams("")).toEqual({});
50 });
51});
52
53function rs(
54 enforcement: "active" | "evaluate" | "disabled",
55 rules: Array<{ ruleType: string; params: any }>
56) {
57 return {
58 id: "rs-1",
59 repositoryId: "r",
60 name: "n",
61 enforcement,
62 createdBy: null,
63 createdAt: new Date(),
64 updatedAt: new Date(),
65 rules: rules.map((r, i) => ({
66 id: `rule-${i}`,
67 rulesetId: "rs-1",
68 ruleType: r.ruleType,
69 params: JSON.stringify(r.params),
70 createdAt: new Date(),
71 })),
72 } as any;
73}
74
75describe("rulesets — evaluatePush commit_message_pattern", () => {
76 it("require=true blocks non-matching messages under active", () => {
77 const result = evaluatePush(
78 [
79 rs("active", [
80 {
81 ruleType: "commit_message_pattern",
82 params: { pattern: "^(feat|fix|chore):" },
83 },
84 ]),
85 ],
86 {
87 kind: "push",
88 refType: "branch",
89 refName: "main",
90 commits: [{ sha: "abc123", message: "random junk" }],
91 }
92 );
93 expect(result.allowed).toBe(false);
94 expect(result.violations[0].ruleType).toBe("commit_message_pattern");
95 });
96
97 it("evaluate mode warns but allows", () => {
98 const result = evaluatePush(
99 [
100 rs("evaluate", [
101 {
102 ruleType: "commit_message_pattern",
103 params: { pattern: "^(feat|fix|chore):" },
104 },
105 ]),
106 ],
107 {
108 kind: "push",
109 refType: "branch",
110 refName: "main",
111 commits: [{ sha: "abc123", message: "random" }],
112 }
113 );
114 expect(result.allowed).toBe(true);
115 expect(result.violations.length).toBe(1);
116 expect(result.violations[0].enforcement).toBe("evaluate");
117 });
118
119 it("disabled mode produces zero violations", () => {
120 const result = evaluatePush(
121 [
122 rs("disabled", [
123 {
124 ruleType: "commit_message_pattern",
125 params: { pattern: "^feat:" },
126 },
127 ]),
128 ],
129 {
130 kind: "push",
131 refType: "branch",
132 refName: "main",
133 commits: [{ sha: "abc", message: "anything" }],
134 }
135 );
136 expect(result.allowed).toBe(true);
137 expect(result.violations.length).toBe(0);
138 });
139
140 it("require=false blocks when pattern matches (forbidden)", () => {
141 const result = evaluatePush(
142 [
143 rs("active", [
144 {
145 ruleType: "commit_message_pattern",
146 params: { pattern: "wip", flags: "i", require: false },
147 },
148 ]),
149 ],
150 {
151 kind: "push",
152 refType: "branch",
153 refName: "main",
154 commits: [{ sha: "abc", message: "WIP push" }],
155 }
156 );
157 expect(result.allowed).toBe(false);
158 });
159});
160
161describe("rulesets — evaluatePush branch / tag patterns", () => {
162 it("branch must match required pattern", () => {
163 const r = evaluatePush(
164 [
165 rs("active", [
166 {
167 ruleType: "branch_name_pattern",
168 params: { pattern: "^release/" },
169 },
170 ]),
171 ],
172 {
173 kind: "push",
174 refType: "branch",
175 refName: "feature/x",
176 commits: [],
177 }
178 );
179 expect(r.allowed).toBe(false);
180 });
181
182 it("branch rule is a no-op for tag pushes", () => {
183 const r = evaluatePush(
184 [
185 rs("active", [
186 {
187 ruleType: "branch_name_pattern",
188 params: { pattern: "^release/" },
189 },
190 ]),
191 ],
192 { kind: "push", refType: "tag", refName: "v1.0", commits: [] }
193 );
194 expect(r.allowed).toBe(true);
195 });
196
197 it("tag must match semver-ish", () => {
198 const r = evaluatePush(
199 [
200 rs("active", [
201 {
202 ruleType: "tag_name_pattern",
203 params: { pattern: "^v\\d+\\.\\d+\\.\\d+$" },
204 },
205 ]),
206 ],
207 { kind: "push", refType: "tag", refName: "v1.0", commits: [] }
208 );
209 expect(r.allowed).toBe(false);
210 });
211});
212
213describe("rulesets — evaluatePush blocked_file_paths", () => {
214 it("blocks changes to matching paths", () => {
215 const r = evaluatePush(
216 [
217 rs("active", [
218 {
219 ruleType: "blocked_file_paths",
220 params: { paths: ["secrets/**", "*.pem"] },
221 },
222 ]),
223 ],
224 {
225 kind: "push",
226 refType: "branch",
227 refName: "main",
228 commits: [
229 { sha: "a", message: "x", changedPaths: ["secrets/db.env"] },
230 ],
231 }
232 );
233 expect(r.allowed).toBe(false);
234 });
235
236 it("allows unrelated path changes", () => {
237 const r = evaluatePush(
238 [
239 rs("active", [
240 {
241 ruleType: "blocked_file_paths",
242 params: { paths: ["secrets/**"] },
243 },
244 ]),
245 ],
246 {
247 kind: "push",
248 refType: "branch",
249 refName: "main",
250 commits: [{ sha: "a", message: "x", changedPaths: ["src/app.ts"] }],
251 }
252 );
253 expect(r.allowed).toBe(true);
254 });
255});
256
257describe("rulesets — evaluatePush max_file_size + force push", () => {
258 it("blocks oversize blobs", () => {
259 const r = evaluatePush(
260 [
261 rs("active", [
262 {
263 ruleType: "max_file_size",
264 params: { bytes: 1024 },
265 },
266 ]),
267 ],
268 {
269 kind: "push",
270 refType: "branch",
271 refName: "main",
272 commits: [{ sha: "a", message: "x", maxBlobSize: 2048 }],
273 }
274 );
275 expect(r.allowed).toBe(false);
276 });
277
278 it("blocks force push when configured", () => {
279 const r = evaluatePush(
280 [rs("active", [{ ruleType: "forbid_force_push", params: {} }])],
281 {
282 kind: "push",
283 refType: "branch",
284 refName: "main",
285 commits: [],
286 forcePush: true,
287 }
288 );
289 expect(r.allowed).toBe(false);
290 });
291});
292
293describe("rulesets — RULE_TYPES surface", () => {
294 it("exports all rule types", () => {
295 expect(RULE_TYPES).toContain("commit_message_pattern");
296 expect(RULE_TYPES).toContain("branch_name_pattern");
297 expect(RULE_TYPES).toContain("tag_name_pattern");
298 expect(RULE_TYPES).toContain("blocked_file_paths");
299 expect(RULE_TYPES).toContain("max_file_size");
300 expect(RULE_TYPES).toContain("forbid_force_push");
301 expect(RULE_TYPES.length).toBe(6);
302 });
303});
304
305describe("rulesets — __internal evalRule edge cases", () => {
306 it("empty pattern is a no-op", () => {
307 const msgs = __internal.evalRule(
308 {
309 id: "r1",
310 rulesetId: "s",
311 ruleType: "commit_message_pattern",
312 params: JSON.stringify({ pattern: "" }),
313 createdAt: new Date(),
314 } as any,
315 {
316 kind: "push",
317 refType: "branch",
318 refName: "main",
319 commits: [{ sha: "a", message: "nothing" }],
320 }
321 );
322 expect(msgs.length).toBe(0);
323 });
324
325 it("invalid regex is a no-op", () => {
326 const msgs = __internal.evalRule(
327 {
328 id: "r1",
329 rulesetId: "s",
330 ruleType: "commit_message_pattern",
331 params: JSON.stringify({ pattern: "(" }),
332 createdAt: new Date(),
333 } as any,
334 {
335 kind: "push",
336 refType: "branch",
337 refName: "main",
338 commits: [{ sha: "a", message: "x" }],
339 }
340 );
341 expect(msgs.length).toBe(0);
342 });
343});
344
345describe("rulesets — route auth", () => {
346 it("GET /:o/:r/settings/rulesets without auth → 302 /login", async () => {
347 const res = await app.request("/alice/repo/settings/rulesets");
348 expect(res.status).toBe(302);
349 expect(res.headers.get("location") || "").toContain("/login");
350 });
351
352 it("POST create without auth → 302 /login", async () => {
353 const res = await app.request("/alice/repo/settings/rulesets", {
354 method: "POST",
355 });
356 expect(res.status).toBe(302);
357 expect(res.headers.get("location") || "").toContain("/login");
358 });
359
360 it("POST delete without auth → 302 /login", async () => {
361 const res = await app.request(
362 "/alice/repo/settings/rulesets/00000000-0000-0000-0000-000000000000/delete",
363 { method: "POST" }
364 );
365 expect(res.status).toBe(302);
366 expect(res.headers.get("location") || "").toContain("/login");
367 });
368});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
7373import advisoriesRoutes from "./routes/advisories";
7474import signingKeysRoutes from "./routes/signing-keys";
7575import followsRoutes from "./routes/follows";
76import rulesetsRoutes from "./routes/rulesets";
7677import webRoutes from "./routes/web";
7778
7879const app = new Hono();
254255// User following + personalised feed (Block J4)
255256app.route("/", followsRoutes);
256257
258// Repository rulesets — /:owner/:repo/settings/rulesets (Block J6)
259app.route("/", rulesetsRoutes);
260
257261// Insights + milestones
258262app.route("/", insightsRoutes);
259263
Modifiedsrc/db/schema.ts+52−0View fileUnifiedSplit
22642264);
22652265
22662266export type UserFollow = typeof userFollows.$inferSelect;
2267
2268// ----------------------------------------------------------------------------
2269// Block J6 — Repository rulesets
2270// ----------------------------------------------------------------------------
2271
2272/**
2273 * A ruleset groups N rules under a named policy at enforcement level active /
2274 * evaluate / disabled. Unique per (repo, name) so a repo can carry multiple
2275 * overlapping rulesets (e.g. "release branches" vs "everywhere").
2276 */
2277export const repoRulesets = pgTable(
2278 "repo_rulesets",
2279 {
2280 id: uuid("id").primaryKey().defaultRandom(),
2281 repositoryId: uuid("repository_id")
2282 .notNull()
2283 .references(() => repositories.id, { onDelete: "cascade" }),
2284 name: text("name").notNull(),
2285 enforcement: text("enforcement").default("active").notNull(),
2286 createdBy: uuid("created_by").references(() => users.id, {
2287 onDelete: "set null",
2288 }),
2289 createdAt: timestamp("created_at").defaultNow().notNull(),
2290 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2291 },
2292 (table) => [
2293 index("repo_rulesets_repo_idx").on(table.repositoryId),
2294 uniqueIndex("repo_rulesets_repo_name_unique").on(
2295 table.repositoryId,
2296 table.name
2297 ),
2298 ]
2299);
2300
2301export type RepoRuleset = typeof repoRulesets.$inferSelect;
2302
2303/** Individual rule — type tag plus JSON params. */
2304export const rulesetRules = pgTable(
2305 "ruleset_rules",
2306 {
2307 id: uuid("id").primaryKey().defaultRandom(),
2308 rulesetId: uuid("ruleset_id")
2309 .notNull()
2310 .references(() => repoRulesets.id, { onDelete: "cascade" }),
2311 ruleType: text("rule_type").notNull(),
2312 params: text("params").default("{}").notNull(),
2313 createdAt: timestamp("created_at").defaultNow().notNull(),
2314 },
2315 (table) => [index("ruleset_rules_set_idx").on(table.rulesetId)]
2316);
2317
2318export type RulesetRule = typeof rulesetRules.$inferSelect;
Addedsrc/lib/rulesets.ts+402−0View fileUnifiedSplit
1/**
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 };
Addedsrc/routes/rulesets.tsx+514−0View fileUnifiedSplit
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
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
159 method="POST"
160 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
281 method="POST"
282 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
334 method="POST"
335 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
354 method="POST"
355 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>
386 <form method="POST" action={`${base}/delete`}>
387 <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;
0515