Commita79a9edunknown_key
feat(BLOCK-E): E5 merge queues + E6 required checks + E7 protected tags
feat(BLOCK-E): E5 merge queues + E6 required checks + E7 protected tags E5 Merge queues — serialised merges per (repo, base_branch). New `merge_queue_entries` table (migration 0017); lib (`src/lib/merge-queue.ts`) exposes enqueue/dequeue/peek/markRunning/complete helpers with no side effects beyond the queue table. Route (`src/routes/merge-queue.tsx`) mounts /:owner/:repo/queue list, /pulls/:n/enqueue (requireAuth), /queue/:id/dequeue (owner-or-enqueuer), /queue/process-next (owner-only — re-runs gates against latest base then updates base ref). PR page gains an "Add to merge queue" button next to merge. E6 Required status checks matrix — `branch_required_checks` (migration 0018) lets owners pin specific check names per branch protection rule. `evaluateProtection` gains a third `requiredChecks[]` argument and `listRequiredChecks` / `passingCheckNames` helpers in branch-protection.ts compute the passing set from gate_runs (passed/repaired) + workflow_runs (success). Merge handler wires the check. Settings UI at /:owner/:repo/gates/protection/:id/checks with CRUD. E7 Protected tags — `protected_tags` (migration 0019) + settings UI at /:owner/:repo/settings/protected-tags. Glob patterns via matchGlob. Post-receive writes advisory audit entries for matched tag pushes; pre-receive blocking deferred to v2. +22 tests (435 pass, 0 fail, 27 files).
18 files changed+2131−27a79a9edc735993abbe048df076e10b4c6411937c
18 changed files+2131−27
ModifiedBUILD_BIBLE.md+16−6View fileUnifiedSplit
@@ -155,8 +155,9 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
155155| Sponsors | ❌ | |
156156| Marketplace | ❌ | |
157157| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail. Protected environments (`src/routes/environments.tsx`, `src/lib/environments.ts`) with reviewer-gated approval, branch-glob restrictions, approve/reject decisions recorded in `deployment_approvals` |
158| Merge queues | ❌ | |
159| Required checks matrix | 🟡 | branch_protection has single flag, no matrix |
158| Merge queues | ✅ | E5 — serialised merge with re-test. `src/lib/merge-queue.ts`, `src/routes/merge-queue.tsx`, `drizzle/0017_merge_queue.sql`; per `(repo, base_branch)` queue, owner-only process-next re-runs gates against latest base before merging. |
159| Required checks matrix | ✅ | E6 — per branch-protection named check list. `src/routes/required-checks.tsx`, `drizzle/0018_required_checks.sql`; `listRequiredChecks` + `passingCheckNames` helpers in `src/lib/branch-protection.ts`; merge handler verifies every required name has a passing gate_run or workflow_run. |
160| Protected tags | ✅ | E7 — owners can mark tag patterns (`v*`, `release-*`) protected. `src/lib/protected-tags.ts`, `src/routes/protected-tags.tsx`, `drizzle/0019_protected_tags.sql`; advisory enforcement via post-receive audit log (v1). |
160161
161162### 2.6 Observability + safety
162163| Feature | Status | Notes |
@@ -243,9 +244,9 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
243244- **E2** — Discussions (forum threads per repo) → ✅ shipped. `src/routes/discussions.tsx`, tables `discussions`/`discussion_comments` (migration 0013). Categorised (general/q-and-a/ideas/announcements/show-and-tell), pinnable, lockable, q-and-a answers.
244245- **E3** — Wikis → ✅ shipped as DB-backed v1. `src/routes/wikis.tsx`, tables `wiki_pages`/`wiki_revisions` (migration 0016). Slug auto-derived; every edit bumps revision + appends a revision row; owner can revert. Git-backed mirror deferred.
245246- **E4** — Gists → ✅ shipped. `src/routes/gists.tsx`, tables `gists`/`gist_files`/`gist_revisions`/`gist_stars` (migration 0014). Multi-file; each edit takes a JSON snapshot into `gist_revisions` keyed on revision number; stars toggle; secret gists hidden from non-owners.
246- **E5** — Merge queues (serialised merge with re-test) — NOT STARTED
247- **E6** — Required status checks matrix (multiple named checks per branch protection rule) — NOT STARTED
248- **E7** — Protected tags — NOT STARTED
247- **E5** — Merge queues → ✅ shipped. `src/lib/merge-queue.ts`, `src/routes/merge-queue.tsx`, table `merge_queue_entries` (migration 0017). Per `(repo, base_branch)` FIFO queue; `POST /:owner/:repo/pulls/:n/enqueue` adds from the PR page; owner-only `POST /queue/process-next` re-runs gates against latest base before merging the head. Entries have queued | running | merged | failed | dequeued states.
248- **E6** — Required status checks matrix → ✅ shipped. `src/routes/required-checks.tsx`, table `branch_required_checks` (migration 0018); helpers `listRequiredChecks` + `passingCheckNames` in `src/lib/branch-protection.ts`. Settings UI at `/:owner/:repo/gates/protection/:id/checks`; merge handler (`src/routes/pulls.tsx`) loads required names + computes passing set from `gate_runs` (passed/repaired) + `workflow_runs` (success) and blocks if any required name is missing.
249- **E7** — Protected tags → ✅ shipped. `src/lib/protected-tags.ts`, `src/routes/protected-tags.tsx`, table `protected_tags` (migration 0019). Settings CRUD at `/:owner/:repo/settings/protected-tags`; patterns use same glob syntax as branch protection. v1 enforcement is advisory: post-receive logs audit entries (`protected_tags.{create|update|delete}_violation_candidate`) so owners can see violations; pre-receive blocking is future work.
249250
250251### BLOCK F — Observability + admin
251252- **F1** — Traffic analytics per repo (views, clones, unique visitors)
@@ -290,6 +291,9 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
290291- `drizzle/0014_gists.sql` (Block E4) — migration, never edited in place. Adds `gists`, `gist_files`, `gist_revisions`, `gist_stars`.
291292- `drizzle/0015_projects.sql` (Block E1) — migration, never edited in place. Adds `projects`, `project_columns`, `project_items`.
292293- `drizzle/0016_wikis.sql` (Block E3) — migration, never edited in place. Adds `wiki_pages`, `wiki_revisions`.
294- `drizzle/0017_merge_queue.sql` (Block E5) — migration, never edited in place. Adds `merge_queue_entries` (with partial unique index on `pull_request_id WHERE state IN ('queued','running')`).
295- `drizzle/0018_required_checks.sql` (Block E6) — migration, never edited in place. Adds `branch_required_checks`.
296- `drizzle/0019_protected_tags.sql` (Block E7) — migration, never edited in place. Adds `protected_tags`.
293297
294298### 4.2 Git layer (locked)
295299- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -388,6 +392,12 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
388392- `src/routes/gists.tsx` (Block E4) — `GET /gists` discover, `/gists/new|:slug|:slug/edit|:slug/delete|:slug/star|:slug/revisions|:slug/revisions/:rev` + `/:username/gists`. Exports `generateSlug()` (8-hex) and `snapshotOf(files)` JSON serializer. Retries on slug collision up to 5x.
389393- `src/routes/projects.tsx` (Block E1) — kanban board CRUD. Auto-seeds three default columns on project create. `/:owner/:repo/projects/:number/items/:itemId/move` recomputes position via `max+1` of target column.
390394- `src/routes/wikis.tsx` (Block E3) — DB-backed wiki with revision history + revert. Exports `slugifyTitle(title)` (lowercase alphanumerics joined by single dashes, trimmed). Every edit appends a `wiki_revisions` row; revert creates a new revision.
395- `src/routes/merge-queue.tsx` (Block E5) — `GET /:owner/:repo/queue` list, `POST /:owner/:repo/pulls/:n/enqueue` (requireAuth), `POST /:owner/:repo/queue/:id/dequeue` (owner-or-enqueuer), `POST /:owner/:repo/queue/process-next?base=X` (owner-only, re-runs gates against base then updates base ref). PR page has an extra "Add to merge queue" button.
396- `src/lib/merge-queue.ts` (Block E5) — `enqueuePr`, `dequeueEntry`, `peekHead`, `markHeadRunning`, `completeEntry`, `isQueued`, `queueDepth`, `listQueue`, `listQueueWithPrs`. No side effects beyond the `merge_queue_entries` table; callers own gate execution + git updates.
397- `src/routes/required-checks.tsx` (Block E6) — `/:owner/:repo/gates/protection/:id/checks` CRUD (owner-only, requireAuth). "Required checks" link added on gates settings UI next to each branch protection rule.
398- `src/lib/branch-protection.ts` extends for E6 — `listRequiredChecks(branchProtectionId)`, `passingCheckNames(repositoryId, commitSha)` (scans `gate_runs` + `workflow_runs`), and `evaluateProtection(rule, ctx, requiredChecks[])` now takes a third param + reports `missingChecks`.
399- `src/routes/protected-tags.tsx` (Block E7) — `/:owner/:repo/settings/protected-tags` CRUD (owner-only, requireAuth).
400- `src/lib/protected-tags.ts` (Block E7) — `matchProtectedTag`, `isProtectedTag`, `canBypassProtectedTag`, `listProtectedTags`, `addProtectedTag`, `removeProtectedTag`, `userIdFromUsername`. Matching uses `matchGlob` from environments.ts with `refs/tags/` prefix stripped. Post-receive hook writes audit log entries (`protected_tags.{create|update|delete}_violation_candidate`) on matched pushes.
391401
392402### 4.7 Views (locked contracts)
393403- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -422,7 +432,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
422432```bash
423433bun install
424434bun dev # hot reload
425bun test # 99 tests currently pass
435bun test # 435 tests currently pass
426436bun run db:migrate
427437```
428438
Addeddrizzle/0017_merge_queue.sql+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1-- Gluecron migration 0017: Block E5 — Merge queues.
2--
3-- Serialised merge: instead of merging a PR immediately, it's appended to
4-- a queue scoped on (repository_id, base_branch). A worker (or manual
5-- process-next button for v1) pops the head, re-runs gates against the
6-- latest base, and if green actually merges. If red, kicks the PR back
7-- with a failure comment.
8--
9-- Tables:
10-- merge_queue_entries — one row per PR currently queued / processed
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "merge_queue_entries" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
15 "repository_id" uuid NOT NULL,
16 "pull_request_id" uuid NOT NULL,
17 "base_branch" text NOT NULL,
18 "state" text NOT NULL DEFAULT 'queued', -- queued | running | merged | failed | dequeued
19 "position" integer NOT NULL DEFAULT 0,
20 "enqueued_by" uuid,
21 "enqueued_at" timestamp DEFAULT now() NOT NULL,
22 "started_at" timestamp,
23 "finished_at" timestamp,
24 "error_message" text,
25 CONSTRAINT "merge_queue_entries_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
26 CONSTRAINT "merge_queue_entries_pr_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests"("id") ON DELETE cascade,
27 CONSTRAINT "merge_queue_entries_enqueuer_fk" FOREIGN KEY ("enqueued_by") REFERENCES "users"("id")
28);
29
30--> statement-breakpoint
31CREATE INDEX IF NOT EXISTS "merge_queue_repo_branch" ON "merge_queue_entries" ("repository_id", "base_branch", "state");
32--> statement-breakpoint
33CREATE UNIQUE INDEX IF NOT EXISTS "merge_queue_pr_active" ON "merge_queue_entries" ("pull_request_id") WHERE state IN ('queued', 'running');
Addeddrizzle/0018_required_checks.sql+22−0View fileUnifiedSplit
@@ -0,0 +1,22 @@
1-- Gluecron migration 0018: Block E6 — Required status checks matrix.
2--
3-- The existing `branch_protection.requireGreenGates` flag only says "all
4-- gates must pass". This matrix lets owners require SPECIFIC named checks
5-- (workflow names or gate_run kinds) to pass for a branch pattern.
6--
7-- Tables:
8-- branch_required_checks — one row per (rule, check_name) pairing
9
10--> statement-breakpoint
11CREATE TABLE IF NOT EXISTS "branch_required_checks" (
12 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
13 "branch_protection_id" uuid NOT NULL,
14 "check_name" text NOT NULL,
15 "created_at" timestamp DEFAULT now() NOT NULL,
16 CONSTRAINT "branch_required_checks_rule_fk" FOREIGN KEY ("branch_protection_id") REFERENCES "branch_protection"("id") ON DELETE cascade
17);
18
19--> statement-breakpoint
20CREATE INDEX IF NOT EXISTS "branch_required_checks_rule" ON "branch_required_checks" ("branch_protection_id");
21--> statement-breakpoint
22CREATE UNIQUE INDEX IF NOT EXISTS "branch_required_checks_unique" ON "branch_required_checks" ("branch_protection_id", "check_name");
Addeddrizzle/0019_protected_tags.sql+26−0View fileUnifiedSplit
@@ -0,0 +1,26 @@
1-- Gluecron migration 0019: Block E7 — Protected tags.
2--
3-- Lets owners mark tag patterns (e.g. `v*`, `release-*`) as protected so
4-- that non-owners cannot create, update, or delete matching tags.
5-- Enforcement happens in the git push flow: see post-receive hook for
6-- advisory notifications; the actual block is implemented at the route
7-- level or service layer (for v1 we just record + surface the policy).
8--
9-- Tables:
10-- protected_tags — per-repo tag patterns with glob support
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "protected_tags" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
15 "repository_id" uuid NOT NULL,
16 "pattern" text NOT NULL,
17 "created_at" timestamp DEFAULT now() NOT NULL,
18 "created_by" uuid,
19 CONSTRAINT "protected_tags_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
20 CONSTRAINT "protected_tags_creator_fk" FOREIGN KEY ("created_by") REFERENCES "users"("id")
21);
22
23--> statement-breakpoint
24CREATE INDEX IF NOT EXISTS "protected_tags_repo" ON "protected_tags" ("repository_id");
25--> statement-breakpoint
26CREATE UNIQUE INDEX IF NOT EXISTS "protected_tags_repo_pattern" ON "protected_tags" ("repository_id", "pattern");
Addedsrc/__tests__/merge-queue.test.ts+63−0View fileUnifiedSplit
@@ -0,0 +1,63 @@
1/**
2 * Block E5 — Merge queue smoke tests.
3 *
4 * Integration paths (enqueue → process-next → merge) need a seeded test DB
5 * + a real bare repo on disk. Here we cover:
6 * - helper shape (types + default values)
7 * - route-level auth guards (302 redirects to /login for write actions)
8 * - 404 for missing repo on read path
9 */
10
11import { describe, it, expect } from "bun:test";
12import app from "../app";
13
14describe("merge-queue — route smoke", () => {
15 it("GET /:owner/:repo/queue on missing repo → 404 HTML", async () => {
16 const res = await app.request("/nobody/missing/queue");
17 expect(res.status).toBe(404);
18 const body = await res.text();
19 expect(body.toLowerCase()).toContain("not found");
20 });
21
22 it("POST /:owner/:repo/pulls/1/enqueue without auth → 302 /login", async () => {
23 const res = await app.request("/any/repo/pulls/1/enqueue", {
24 method: "POST",
25 });
26 expect(res.status).toBe(302);
27 const loc = res.headers.get("location") || "";
28 expect(loc.startsWith("/login")).toBe(true);
29 });
30
31 it("POST /:owner/:repo/queue/abc/dequeue without auth → 302 /login", async () => {
32 const res = await app.request("/any/repo/queue/abc/dequeue", {
33 method: "POST",
34 });
35 expect(res.status).toBe(302);
36 const loc = res.headers.get("location") || "";
37 expect(loc.startsWith("/login")).toBe(true);
38 });
39
40 it("POST /:owner/:repo/queue/process-next without auth → 302 /login", async () => {
41 const res = await app.request("/any/repo/queue/process-next", {
42 method: "POST",
43 });
44 expect(res.status).toBe(302);
45 const loc = res.headers.get("location") || "";
46 expect(loc.startsWith("/login")).toBe(true);
47 });
48});
49
50describe("merge-queue — helper exports", () => {
51 it("exports enqueuePr, dequeueEntry, listQueue, peekHead, completeEntry", async () => {
52 const mod = await import("../lib/merge-queue");
53 expect(typeof mod.enqueuePr).toBe("function");
54 expect(typeof mod.dequeueEntry).toBe("function");
55 expect(typeof mod.listQueue).toBe("function");
56 expect(typeof mod.peekHead).toBe("function");
57 expect(typeof mod.completeEntry).toBe("function");
58 expect(typeof mod.markHeadRunning).toBe("function");
59 expect(typeof mod.isQueued).toBe("function");
60 expect(typeof mod.queueDepth).toBe("function");
61 expect(typeof mod.listQueueWithPrs).toBe("function");
62 });
63});
Addedsrc/__tests__/protected-tags.test.ts+106−0View fileUnifiedSplit
@@ -0,0 +1,106 @@
1/**
2 * Block E7 — Protected tags tests.
3 *
4 * Covers pure `matchGlob`-based matching behaviour on a fake rule set + route
5 * auth smoke. We don't hit the DB — instead, a small wrapper reimplements the
6 * matching logic the lib uses (identical semantics).
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { matchGlob } from "../lib/environments";
12
13/**
14 * Mirrors the matching logic in `matchProtectedTag` so we can exercise it
15 * without a DB. Keep in sync with src/lib/protected-tags.ts.
16 */
17function matchPatternLocal(
18 patterns: string[],
19 tagName: string
20): string | null {
21 const name = tagName.startsWith("refs/tags/")
22 ? tagName.slice("refs/tags/".length)
23 : tagName;
24 const exact = patterns.find(
25 (p) => (p.startsWith("refs/tags/") ? p.slice(10) : p) === name
26 );
27 if (exact) return exact;
28 const globs = patterns
29 .filter((p) => p.includes("*"))
30 .sort((a, b) => a.localeCompare(b));
31 for (const p of globs) {
32 if (matchGlob(name, p)) return p;
33 }
34 return null;
35}
36
37describe("protected-tags — pattern matching", () => {
38 it("matches exact tag names", () => {
39 expect(matchPatternLocal(["v1.0.0"], "v1.0.0")).toBe("v1.0.0");
40 expect(matchPatternLocal(["v1.0.0"], "v1.0.1")).toBe(null);
41 });
42
43 it("matches glob prefixes", () => {
44 expect(matchPatternLocal(["v*"], "v1.2.3")).toBe("v*");
45 expect(matchPatternLocal(["release-*"], "release-2024")).toBe("release-*");
46 expect(matchPatternLocal(["release-*"], "feature-x")).toBe(null);
47 });
48
49 it("strips refs/tags/ prefix before matching", () => {
50 expect(matchPatternLocal(["v*"], "refs/tags/v2.0.0")).toBe("v*");
51 });
52
53 it("returns null when no pattern matches", () => {
54 expect(matchPatternLocal(["v*", "release-*"], "main")).toBe(null);
55 expect(matchPatternLocal([], "v1.0.0")).toBe(null);
56 });
57
58 it("exact match wins over glob", () => {
59 // Either order — exact match should be preferred.
60 expect(matchPatternLocal(["v*", "v1.0.0"], "v1.0.0")).toBe("v1.0.0");
61 expect(matchPatternLocal(["v1.0.0", "v*"], "v1.0.0")).toBe("v1.0.0");
62 });
63});
64
65describe("protected-tags — route smoke", () => {
66 it("GET /settings/protected-tags without auth → 302 /login", async () => {
67 const res = await app.request("/any/repo/settings/protected-tags");
68 expect(res.status).toBe(302);
69 const loc = res.headers.get("location") || "";
70 expect(loc.startsWith("/login")).toBe(true);
71 });
72
73 it("POST /settings/protected-tags without auth → 302 /login", async () => {
74 const res = await app.request("/any/repo/settings/protected-tags", {
75 method: "POST",
76 body: new URLSearchParams({ pattern: "v*" }),
77 headers: { "content-type": "application/x-www-form-urlencoded" },
78 });
79 expect(res.status).toBe(302);
80 const loc = res.headers.get("location") || "";
81 expect(loc.startsWith("/login")).toBe(true);
82 });
83
84 it("POST /settings/protected-tags/:id/delete without auth → 302 /login", async () => {
85 const res = await app.request(
86 "/any/repo/settings/protected-tags/abc/delete",
87 { method: "POST" }
88 );
89 expect(res.status).toBe(302);
90 const loc = res.headers.get("location") || "";
91 expect(loc.startsWith("/login")).toBe(true);
92 });
93});
94
95describe("protected-tags — lib exports", () => {
96 it("exports matchProtectedTag, isProtectedTag, list/add/remove + canBypass", async () => {
97 const mod = await import("../lib/protected-tags");
98 expect(typeof mod.matchProtectedTag).toBe("function");
99 expect(typeof mod.isProtectedTag).toBe("function");
100 expect(typeof mod.canBypassProtectedTag).toBe("function");
101 expect(typeof mod.listProtectedTags).toBe("function");
102 expect(typeof mod.addProtectedTag).toBe("function");
103 expect(typeof mod.removeProtectedTag).toBe("function");
104 expect(typeof mod.userIdFromUsername).toBe("function");
105 });
106});
Addedsrc/__tests__/required-checks.test.ts+128−0View fileUnifiedSplit
@@ -0,0 +1,128 @@
1/**
2 * Block E6 — Required status checks matrix tests.
3 *
4 * Covers the pure protection-evaluator path (no DB) + route auth smoke.
5 */
6
7import { describe, it, expect } from "bun:test";
8import app from "../app";
9import { evaluateProtection } from "../lib/branch-protection";
10import type { BranchProtection } from "../db/schema";
11
12function rule(overrides: Partial<BranchProtection> = {}): BranchProtection {
13 return {
14 id: "rule-1",
15 repositoryId: "repo-1",
16 pattern: "main",
17 requirePullRequest: true,
18 requireGreenGates: true,
19 requireAiApproval: false,
20 requireHumanReview: false,
21 requiredApprovals: 0,
22 allowForcePush: false,
23 allowDeletion: false,
24 createdAt: new Date(),
25 updatedAt: new Date(),
26 ...overrides,
27 } as BranchProtection;
28}
29
30describe("evaluateProtection — required checks matrix", () => {
31 const green = {
32 aiApproved: true,
33 humanApprovalCount: 1,
34 gateResultGreen: true,
35 hasFailedGates: false,
36 };
37
38 it("allows when no required checks are configured", () => {
39 const d = evaluateProtection(rule(), green, []);
40 expect(d.allowed).toBe(true);
41 expect(d.reasons.length).toBe(0);
42 expect(d.missingChecks).toBeUndefined();
43 });
44
45 it("allows when all required checks are in passingCheckNames", () => {
46 const d = evaluateProtection(
47 rule(),
48 { ...green, passingCheckNames: ["GateTest", "AI Review", "CI"] },
49 ["GateTest", "AI Review"]
50 );
51 expect(d.allowed).toBe(true);
52 expect(d.missingChecks).toBeUndefined();
53 });
54
55 it("blocks when a required check is missing", () => {
56 const d = evaluateProtection(
57 rule(),
58 { ...green, passingCheckNames: ["GateTest"] },
59 ["GateTest", "AI Review"]
60 );
61 expect(d.allowed).toBe(false);
62 expect(d.missingChecks).toEqual(["AI Review"]);
63 expect(d.reasons.join(" ")).toContain("AI Review");
64 });
65
66 it("blocks when passingCheckNames is empty but checks are required", () => {
67 const d = evaluateProtection(
68 rule(),
69 { ...green, passingCheckNames: [] },
70 ["GateTest"]
71 );
72 expect(d.allowed).toBe(false);
73 expect(d.missingChecks).toEqual(["GateTest"]);
74 });
75
76 it("still reports other failures alongside missing checks", () => {
77 const d = evaluateProtection(
78 rule({ requireAiApproval: true, requiredApprovals: 2 }),
79 {
80 aiApproved: false,
81 humanApprovalCount: 0,
82 gateResultGreen: true,
83 hasFailedGates: false,
84 passingCheckNames: [],
85 },
86 ["CI"]
87 );
88 expect(d.allowed).toBe(false);
89 // 3 reasons: AI approval, required approvals, missing check
90 expect(d.reasons.length).toBeGreaterThanOrEqual(3);
91 expect(d.missingChecks).toEqual(["CI"]);
92 });
93});
94
95describe("required-checks — route smoke", () => {
96 it("GET protection/:id/checks without auth → 302 /login", async () => {
97 const res = await app.request(
98 "/any/repo/gates/protection/abc/checks"
99 );
100 expect(res.status).toBe(302);
101 const loc = res.headers.get("location") || "";
102 expect(loc.startsWith("/login")).toBe(true);
103 });
104
105 it("POST protection/:id/checks without auth → 302 /login", async () => {
106 const res = await app.request(
107 "/any/repo/gates/protection/abc/checks",
108 {
109 method: "POST",
110 body: new URLSearchParams({ checkName: "CI" }),
111 headers: { "content-type": "application/x-www-form-urlencoded" },
112 }
113 );
114 expect(res.status).toBe(302);
115 const loc = res.headers.get("location") || "";
116 expect(loc.startsWith("/login")).toBe(true);
117 });
118
119 it("POST protection/:id/checks/:cid/delete without auth → 302 /login", async () => {
120 const res = await app.request(
121 "/any/repo/gates/protection/abc/checks/xyz/delete",
122 { method: "POST" }
123 );
124 expect(res.status).toBe(302);
125 const loc = res.headers.get("location") || "";
126 expect(loc.startsWith("/login")).toBe(true);
127 });
128});
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
@@ -53,6 +53,9 @@ import discussionRoutes from "./routes/discussions";
5353import gistRoutes from "./routes/gists";
5454import projectRoutes from "./routes/projects";
5555import wikiRoutes from "./routes/wikis";
56import mergeQueueRoutes from "./routes/merge-queue";
57import requiredChecksRoutes from "./routes/required-checks";
58import protectedTagsRoutes from "./routes/protected-tags";
5659import webRoutes from "./routes/web";
5760
5861const app = new Hono();
@@ -187,6 +190,9 @@ app.route("/", discussionRoutes); // E2 — /:owner/:repo/discussions
187190app.route("/", gistRoutes); // E4 — /gists, /gists/:slug, /:user/gists
188191app.route("/", projectRoutes); // E1 — /:owner/:repo/projects
189192app.route("/", wikiRoutes); // E3 — /:owner/:repo/wiki
193app.route("/", mergeQueueRoutes); // E5 — /:owner/:repo/queue
194app.route("/", requiredChecksRoutes); // E6 — /:owner/:repo/gates/protection/:id/checks
195app.route("/", protectedTagsRoutes); // E7 — /:owner/:repo/settings/protected-tags
190196
191197// Insights + milestones
192198app.route("/", insightsRoutes);
Modifiedsrc/db/schema.ts+86−0View fileUnifiedSplit
@@ -1612,3 +1612,89 @@ export const wikiRevisions = pgTable(
16121612
16131613export type WikiPage = typeof wikiPages.$inferSelect;
16141614export type WikiRevision = typeof wikiRevisions.$inferSelect;
1615
1616// ---------------------------------------------------------------------------
1617// Block E5 — Merge queues (migration 0017)
1618// ---------------------------------------------------------------------------
1619
1620export const mergeQueueEntries = pgTable(
1621 "merge_queue_entries",
1622 {
1623 id: uuid("id").primaryKey().defaultRandom(),
1624 repositoryId: uuid("repository_id")
1625 .notNull()
1626 .references(() => repositories.id, { onDelete: "cascade" }),
1627 pullRequestId: uuid("pull_request_id")
1628 .notNull()
1629 .references(() => pullRequests.id, { onDelete: "cascade" }),
1630 baseBranch: text("base_branch").notNull(),
1631 // queued | running | merged | failed | dequeued
1632 state: text("state").notNull().default("queued"),
1633 position: integer("position").notNull().default(0),
1634 enqueuedBy: uuid("enqueued_by").references(() => users.id),
1635 enqueuedAt: timestamp("enqueued_at").defaultNow().notNull(),
1636 startedAt: timestamp("started_at"),
1637 finishedAt: timestamp("finished_at"),
1638 errorMessage: text("error_message"),
1639 },
1640 (table) => [
1641 index("merge_queue_repo_branch").on(
1642 table.repositoryId,
1643 table.baseBranch,
1644 table.state
1645 ),
1646 ]
1647);
1648
1649export type MergeQueueEntry = typeof mergeQueueEntries.$inferSelect;
1650
1651// ---------------------------------------------------------------------------
1652// Block E6 — Required status checks matrix (migration 0018)
1653// ---------------------------------------------------------------------------
1654
1655export const branchRequiredChecks = pgTable(
1656 "branch_required_checks",
1657 {
1658 id: uuid("id").primaryKey().defaultRandom(),
1659 branchProtectionId: uuid("branch_protection_id")
1660 .notNull()
1661 .references(() => branchProtection.id, { onDelete: "cascade" }),
1662 checkName: text("check_name").notNull(),
1663 createdAt: timestamp("created_at").defaultNow().notNull(),
1664 },
1665 (table) => [
1666 index("branch_required_checks_rule").on(table.branchProtectionId),
1667 uniqueIndex("branch_required_checks_unique").on(
1668 table.branchProtectionId,
1669 table.checkName
1670 ),
1671 ]
1672);
1673
1674export type BranchRequiredCheck = typeof branchRequiredChecks.$inferSelect;
1675
1676// ---------------------------------------------------------------------------
1677// Block E7 — Protected tags (migration 0019)
1678// ---------------------------------------------------------------------------
1679
1680export const protectedTags = pgTable(
1681 "protected_tags",
1682 {
1683 id: uuid("id").primaryKey().defaultRandom(),
1684 repositoryId: uuid("repository_id")
1685 .notNull()
1686 .references(() => repositories.id, { onDelete: "cascade" }),
1687 pattern: text("pattern").notNull(),
1688 createdAt: timestamp("created_at").defaultNow().notNull(),
1689 createdBy: uuid("created_by").references(() => users.id),
1690 },
1691 (table) => [
1692 index("protected_tags_repo").on(table.repositoryId),
1693 uniqueIndex("protected_tags_repo_pattern").on(
1694 table.repositoryId,
1695 table.pattern
1696 ),
1697 ]
1698);
1699
1700export type ProtectedTag = typeof protectedTags.$inferSelect;
Modifiedsrc/hooks/post-receive.ts+34−1View fileUnifiedSplit
@@ -26,13 +26,14 @@ import {
2626import { getOrCreateSettings } from "../lib/repo-bootstrap";
2727import { getBlob, getDefaultBranch, getTree } from "../git/repository";
2828import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
29import { notify } from "../lib/notify";
29import { notify, audit } from "../lib/notify";
3030import { workflows, pagesSettings } from "../db/schema";
3131import { parseWorkflow } from "../lib/workflow-parser";
3232import { enqueueRun } from "../lib/workflow-runner";
3333import { onPagesPush } from "../lib/pages";
3434import { requiresApprovalFor } from "../lib/environments";
3535import { onDeployFailure } from "../lib/ai-incident";
36import { matchProtectedTag } from "../lib/protected-tags";
3637
3738interface PushRef {
3839 oldSha: string;
@@ -92,6 +93,38 @@ export async function onPostReceive(
9293 }
9394 }
9495
96 // --- 1b. Protected-tag advisory logging (Block E7) ---
97 // v1 is non-blocking: we log violations to the audit log and fan a notify
98 // event out to the repo owner. Actual pre-receive blocking is future work.
99 if (repoRow) {
100 for (const ref of refs) {
101 if (!ref.refName.startsWith("refs/tags/")) continue;
102 const rule = await matchProtectedTag(repoRow.id, ref.refName);
103 if (!rule) continue;
104 const isDelete = ref.newSha.startsWith("0000");
105 const isCreate = ref.oldSha.startsWith("0000");
106 const action = isDelete
107 ? "delete"
108 : isCreate
109 ? "create"
110 : "update";
111 try {
112 await audit({
113 userId: ownerRow?.id || null,
114 repositoryId: repoRow.id,
115 action: `protected_tags.${action}_violation_candidate`,
116 targetType: "ref",
117 targetId: ref.refName,
118 metadata: {
119 pattern: rule.pattern,
120 oldSha: ref.oldSha,
121 newSha: ref.newSha,
122 },
123 });
124 } catch {}
125 }
126 }
127
95128 // --- 2. CODEOWNERS sync (only when default branch changed) ---
96129 const mainRef = refs.find(
97130 (r) =>
Modifiedsrc/lib/branch-protection.ts+122−5View fileUnifiedSplit
@@ -15,10 +15,17 @@
1515 * Kept minimal: no throwing, no side effects.
1616 */
1717
18import { and, eq } from "drizzle-orm";
18import { and, desc, eq } from "drizzle-orm";
1919import { db } from "../db";
20import { branchProtection, prComments } from "../db/schema";
21import type { BranchProtection } from "../db/schema";
20import {
21 branchProtection,
22 branchRequiredChecks,
23 gateRuns,
24 prComments,
25 workflowRuns,
26 workflows,
27} from "../db/schema";
28import type { BranchProtection, BranchRequiredCheck } from "../db/schema";
2229import { matchGlob } from "./environments";
2330
2431export interface ProtectionEvalContext {
@@ -26,12 +33,15 @@ export interface ProtectionEvalContext {
2633 humanApprovalCount: number;
2734 gateResultGreen: boolean;
2835 hasFailedGates: boolean;
36 /** Names of checks whose latest run passed. Used by E6 required-checks. */
37 passingCheckNames?: string[];
2938}
3039
3140export interface ProtectionDecision {
3241 allowed: boolean;
3342 rule: BranchProtection | null;
3443 reasons: string[];
44 missingChecks?: string[];
3545}
3646
3747/**
@@ -74,7 +84,8 @@ export async function matchProtection(
7484 */
7585export function evaluateProtection(
7686 rule: BranchProtection | null,
77 ctx: ProtectionEvalContext
87 ctx: ProtectionEvalContext,
88 requiredChecks: string[] = []
7889): ProtectionDecision {
7990 if (!rule) {
8091 return { allowed: true, rule: null, reasons: [] };
@@ -105,7 +116,25 @@ export function evaluateProtection(
105116 );
106117 }
107118
108 return { allowed: reasons.length === 0, rule, reasons };
119 // E6 — required status checks matrix
120 let missingChecks: string[] | undefined;
121 if (requiredChecks.length > 0) {
122 const passing = new Set(ctx.passingCheckNames || []);
123 const missing = requiredChecks.filter((n) => !passing.has(n));
124 if (missing.length > 0) {
125 missingChecks = missing;
126 reasons.push(
127 `Branch protection '${rule.pattern}' requires these checks to pass: ${missing.join(", ")}.`
128 );
129 }
130 }
131
132 return {
133 allowed: reasons.length === 0,
134 rule,
135 reasons,
136 ...(missingChecks ? { missingChecks } : {}),
137 };
109138}
110139
111140/**
@@ -137,3 +166,91 @@ export async function countHumanApprovals(pullRequestId: string): Promise<number
137166 return 0;
138167 }
139168}
169
170// ---------------------------------------------------------------------------
171// E6 — Required status checks matrix
172// ---------------------------------------------------------------------------
173
174/**
175 * List required check names for a branch protection rule. Empty array when
176 * nothing is required (the default; same semantics as "no matrix configured").
177 */
178export async function listRequiredChecks(
179 branchProtectionId: string
180): Promise<BranchRequiredCheck[]> {
181 try {
182 return await db
183 .select()
184 .from(branchRequiredChecks)
185 .where(eq(branchRequiredChecks.branchProtectionId, branchProtectionId));
186 } catch {
187 return [];
188 }
189}
190
191/**
192 * Compute the set of check names that have a passing latest result for this
193 * repo + commit. A "check" is either:
194 * - a `gate_runs` row where `status IN ('passed','repaired')` (matched by
195 * gateName), or
196 * - a `workflow_runs` row where `status = 'success'` (matched by workflow
197 * name, joined through the workflows table).
198 *
199 * Passing names are aggregated across the last N rows to survive re-runs.
200 */
201export async function passingCheckNames(
202 repositoryId: string,
203 commitSha: string | null
204): Promise<string[]> {
205 const names = new Set<string>();
206
207 try {
208 const whereClause = commitSha
209 ? and(
210 eq(gateRuns.repositoryId, repositoryId),
211 eq(gateRuns.commitSha, commitSha)
212 )
213 : eq(gateRuns.repositoryId, repositoryId);
214 const gRows = await db
215 .select({ name: gateRuns.gateName, status: gateRuns.status })
216 .from(gateRuns)
217 .where(whereClause)
218 .orderBy(desc(gateRuns.createdAt))
219 .limit(200);
220 for (const r of gRows) {
221 if (r.status === "passed" || r.status === "repaired") {
222 names.add(r.name);
223 }
224 }
225 } catch {
226 // ignore
227 }
228
229 try {
230 const whereWf = commitSha
231 ? and(
232 eq(workflowRuns.repositoryId, repositoryId),
233 eq(workflowRuns.commitSha, commitSha)
234 )
235 : eq(workflowRuns.repositoryId, repositoryId);
236 const wRows = await db
237 .select({
238 name: workflows.name,
239 status: workflowRuns.status,
240 })
241 .from(workflowRuns)
242 .innerJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
243 .where(whereWf)
244 .orderBy(desc(workflowRuns.createdAt))
245 .limit(200);
246 for (const r of wRows) {
247 if (r.status === "success") {
248 names.add(r.name);
249 }
250 }
251 } catch {
252 // ignore
253 }
254
255 return Array.from(names);
256}
Addedsrc/lib/merge-queue.ts+313−0View fileUnifiedSplit
@@ -0,0 +1,313 @@
1/**
2 * Block E5 — Merge queue helpers.
3 *
4 * A merge queue serialises merges on `(repository_id, base_branch)`: instead
5 * of merging a PR immediately, it's enqueued. A worker (or the manual
6 * "process next" button surfaced on the queue UI) pops the head of the queue,
7 * re-runs gates against the latest base, and — if green — performs the merge.
8 *
9 * This module is deliberately minimal: no side-effects on gate execution or
10 * the actual git merge (those are owned by `pulls.tsx`). We just manage
11 * the queue state + ordering. Every DB path is wrapped to never throw.
12 */
13
14import { and, asc, eq, sql } from "drizzle-orm";
15import { db } from "../db";
16import { mergeQueueEntries, pullRequests } from "../db/schema";
17import type { MergeQueueEntry } from "../db/schema";
18
19export interface EnqueueArgs {
20 repositoryId: string;
21 pullRequestId: string;
22 baseBranch: string;
23 enqueuedBy?: string | null;
24}
25
26export interface EnqueueResult {
27 ok: boolean;
28 entry?: MergeQueueEntry;
29 reason?: string;
30}
31
32/**
33 * Append a PR to the end of the queue for its `(repo, baseBranch)`. No-op
34 * (returns ok:false with a reason) if the PR is already queued or running.
35 */
36export async function enqueuePr(args: EnqueueArgs): Promise<EnqueueResult> {
37 try {
38 // Check for existing active entry for this PR.
39 const existing = await db
40 .select()
41 .from(mergeQueueEntries)
42 .where(eq(mergeQueueEntries.pullRequestId, args.pullRequestId));
43 const active = existing.find(
44 (e) => e.state === "queued" || e.state === "running"
45 );
46 if (active) {
47 return { ok: false, reason: "Pull request is already in the queue." };
48 }
49
50 // Compute next position in this (repo, base) queue.
51 const rows = await db
52 .select({ maxPos: sql<number>`COALESCE(MAX(${mergeQueueEntries.position}), -1)` })
53 .from(mergeQueueEntries)
54 .where(
55 and(
56 eq(mergeQueueEntries.repositoryId, args.repositoryId),
57 eq(mergeQueueEntries.baseBranch, args.baseBranch),
58 sql`${mergeQueueEntries.state} IN ('queued','running')`
59 )
60 );
61 const nextPos = (rows[0]?.maxPos ?? -1) + 1;
62
63 const [entry] = await db
64 .insert(mergeQueueEntries)
65 .values({
66 repositoryId: args.repositoryId,
67 pullRequestId: args.pullRequestId,
68 baseBranch: args.baseBranch,
69 position: nextPos,
70 enqueuedBy: args.enqueuedBy || null,
71 state: "queued",
72 })
73 .returning();
74 return { ok: true, entry };
75 } catch (err) {
76 console.error("[merge-queue] enqueue:", err);
77 return { ok: false, reason: "Failed to enqueue pull request." };
78 }
79}
80
81/**
82 * Remove an active entry from the queue (user-initiated cancel, or a PR
83 * closed while queued). Marks it `dequeued` rather than deleting for audit.
84 */
85export async function dequeueEntry(entryId: string): Promise<boolean> {
86 try {
87 const res = await db
88 .update(mergeQueueEntries)
89 .set({ state: "dequeued", finishedAt: new Date() })
90 .where(
91 and(
92 eq(mergeQueueEntries.id, entryId),
93 sql`${mergeQueueEntries.state} IN ('queued','running')`
94 )
95 )
96 .returning({ id: mergeQueueEntries.id });
97 return res.length > 0;
98 } catch (err) {
99 console.error("[merge-queue] dequeue:", err);
100 return false;
101 }
102}
103
104/**
105 * Peek the head of the queue for a `(repo, baseBranch)` pair. Returns the
106 * oldest `queued` entry — the one that would be popped next by processNext.
107 */
108export async function peekHead(
109 repositoryId: string,
110 baseBranch: string
111): Promise<MergeQueueEntry | null> {
112 try {
113 const rows = await db
114 .select()
115 .from(mergeQueueEntries)
116 .where(
117 and(
118 eq(mergeQueueEntries.repositoryId, repositoryId),
119 eq(mergeQueueEntries.baseBranch, baseBranch),
120 eq(mergeQueueEntries.state, "queued")
121 )
122 )
123 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
124 .limit(1);
125 return rows[0] || null;
126 } catch {
127 return null;
128 }
129}
130
131/**
132 * List queue entries for a repo, newest-first per base branch. Includes
133 * terminal states so the queue UI can show recent merges/failures.
134 */
135export async function listQueue(
136 repositoryId: string,
137 opts: { limit?: number; baseBranch?: string } = {}
138): Promise<MergeQueueEntry[]> {
139 const limit = opts.limit ?? 100;
140 try {
141 if (opts.baseBranch) {
142 return await db
143 .select()
144 .from(mergeQueueEntries)
145 .where(
146 and(
147 eq(mergeQueueEntries.repositoryId, repositoryId),
148 eq(mergeQueueEntries.baseBranch, opts.baseBranch)
149 )
150 )
151 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
152 .limit(limit);
153 }
154 return await db
155 .select()
156 .from(mergeQueueEntries)
157 .where(eq(mergeQueueEntries.repositoryId, repositoryId))
158 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
159 .limit(limit);
160 } catch {
161 return [];
162 }
163}
164
165/**
166 * Transition the head entry → `running`. Returns the entry (if any) so the
167 * caller can kick off gates + perform the merge. The caller must eventually
168 * call `completeEntry` with success/failure.
169 */
170export async function markHeadRunning(
171 repositoryId: string,
172 baseBranch: string
173): Promise<MergeQueueEntry | null> {
174 const head = await peekHead(repositoryId, baseBranch);
175 if (!head) return null;
176 try {
177 const [updated] = await db
178 .update(mergeQueueEntries)
179 .set({ state: "running", startedAt: new Date() })
180 .where(
181 and(
182 eq(mergeQueueEntries.id, head.id),
183 eq(mergeQueueEntries.state, "queued")
184 )
185 )
186 .returning();
187 return updated || null;
188 } catch {
189 return null;
190 }
191}
192
193/**
194 * Mark a running entry as finished. `state` is the final state
195 * (`merged` | `failed`). Non-running entries are left untouched.
196 */
197export async function completeEntry(
198 entryId: string,
199 finalState: "merged" | "failed",
200 errorMessage?: string
201): Promise<boolean> {
202 try {
203 const res = await db
204 .update(mergeQueueEntries)
205 .set({
206 state: finalState,
207 finishedAt: new Date(),
208 errorMessage: errorMessage || null,
209 })
210 .where(eq(mergeQueueEntries.id, entryId))
211 .returning({ id: mergeQueueEntries.id });
212 return res.length > 0;
213 } catch (err) {
214 console.error("[merge-queue] complete:", err);
215 return false;
216 }
217}
218
219/**
220 * Is this PR currently queued or running? Convenience helper for the merge
221 * UI (so we can swap the button label to "In queue…").
222 */
223export async function isQueued(pullRequestId: string): Promise<boolean> {
224 try {
225 const rows = await db
226 .select({ id: mergeQueueEntries.id })
227 .from(mergeQueueEntries)
228 .where(
229 and(
230 eq(mergeQueueEntries.pullRequestId, pullRequestId),
231 sql`${mergeQueueEntries.state} IN ('queued','running')`
232 )
233 )
234 .limit(1);
235 return rows.length > 0;
236 } catch {
237 return false;
238 }
239}
240
241/**
242 * Check queue depth for `(repo, baseBranch)` — number of `queued` + `running`.
243 */
244export async function queueDepth(
245 repositoryId: string,
246 baseBranch: string
247): Promise<number> {
248 try {
249 const rows = await db
250 .select({ n: sql<number>`COUNT(*)` })
251 .from(mergeQueueEntries)
252 .where(
253 and(
254 eq(mergeQueueEntries.repositoryId, repositoryId),
255 eq(mergeQueueEntries.baseBranch, baseBranch),
256 sql`${mergeQueueEntries.state} IN ('queued','running')`
257 )
258 );
259 return Number(rows[0]?.n || 0);
260 } catch {
261 return 0;
262 }
263}
264
265/**
266 * Resolve PR metadata (number, title) for a list of entries — the queue UI
267 * needs those to render links. Kept in the helper so routes don't have to
268 * re-join.
269 */
270export interface QueueEntryWithPr extends MergeQueueEntry {
271 prNumber: number | null;
272 prTitle: string | null;
273 prState: string | null;
274 prHeadBranch: string | null;
275 prAuthorId: string | null;
276}
277
278export async function listQueueWithPrs(
279 repositoryId: string
280): Promise<QueueEntryWithPr[]> {
281 try {
282 const rows = await db
283 .select({
284 id: mergeQueueEntries.id,
285 repositoryId: mergeQueueEntries.repositoryId,
286 pullRequestId: mergeQueueEntries.pullRequestId,
287 baseBranch: mergeQueueEntries.baseBranch,
288 state: mergeQueueEntries.state,
289 position: mergeQueueEntries.position,
290 enqueuedBy: mergeQueueEntries.enqueuedBy,
291 enqueuedAt: mergeQueueEntries.enqueuedAt,
292 startedAt: mergeQueueEntries.startedAt,
293 finishedAt: mergeQueueEntries.finishedAt,
294 errorMessage: mergeQueueEntries.errorMessage,
295 prNumber: pullRequests.number,
296 prTitle: pullRequests.title,
297 prState: pullRequests.state,
298 prHeadBranch: pullRequests.headBranch,
299 prAuthorId: pullRequests.authorId,
300 })
301 .from(mergeQueueEntries)
302 .leftJoin(
303 pullRequests,
304 eq(mergeQueueEntries.pullRequestId, pullRequests.id)
305 )
306 .where(eq(mergeQueueEntries.repositoryId, repositoryId))
307 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
308 .limit(200);
309 return rows as QueueEntryWithPr[];
310 } catch {
311 return [];
312 }
313}
Addedsrc/lib/protected-tags.ts+155−0View fileUnifiedSplit
@@ -0,0 +1,155 @@
1/**
2 * Block E7 — Protected tags helpers.
3 *
4 * Owners can mark tag patterns (e.g. `v*`, `release-*`) as protected so that
5 * only owners can create, update, or delete matching tags. We enforce this
6 * inside the git push flow (post-receive + route-level) by calling
7 * `isProtectedTag`.
8 *
9 * Patterns support the same glob syntax as branch protection via `matchGlob`.
10 */
11
12import { and, eq } from "drizzle-orm";
13import { db } from "../db";
14import { protectedTags, repositories, users } from "../db/schema";
15import type { ProtectedTag } from "../db/schema";
16import { matchGlob } from "./environments";
17
18/**
19 * Return the most specific matching protected-tag pattern for `tagName`.
20 * Exact string matches win over globs. Returns null when unprotected.
21 */
22export async function matchProtectedTag(
23 repositoryId: string,
24 tagName: string
25): Promise<ProtectedTag | null> {
26 const name = stripRefsTags(tagName);
27 let rows: ProtectedTag[];
28 try {
29 rows = await db
30 .select()
31 .from(protectedTags)
32 .where(eq(protectedTags.repositoryId, repositoryId));
33 } catch {
34 return null;
35 }
36 if (!rows || rows.length === 0) return null;
37
38 const exact = rows.find((r) => stripRefsTags(r.pattern) === name);
39 if (exact) return exact;
40
41 const globs = rows
42 .filter((r) => r.pattern.includes("*"))
43 .sort((a, b) => a.pattern.localeCompare(b.pattern));
44 for (const rule of globs) {
45 if (matchGlob(name, rule.pattern)) return rule;
46 }
47 return null;
48}
49
50export async function isProtectedTag(
51 repositoryId: string,
52 tagName: string
53): Promise<boolean> {
54 return (await matchProtectedTag(repositoryId, tagName)) !== null;
55}
56
57/**
58 * True when the given user is authorised to bypass a protected-tag rule for
59 * this repo. Currently that means "is the repo owner". A richer
60 * implementation would check org-level tag admins.
61 */
62export async function canBypassProtectedTag(
63 repositoryId: string,
64 userId: string | null | undefined
65): Promise<boolean> {
66 if (!userId) return false;
67 try {
68 const [row] = await db
69 .select({ ownerId: repositories.ownerId })
70 .from(repositories)
71 .where(eq(repositories.id, repositoryId))
72 .limit(1);
73 return !!row && row.ownerId === userId;
74 } catch {
75 return false;
76 }
77}
78
79export async function listProtectedTags(
80 repositoryId: string
81): Promise<ProtectedTag[]> {
82 try {
83 return await db
84 .select()
85 .from(protectedTags)
86 .where(eq(protectedTags.repositoryId, repositoryId));
87 } catch {
88 return [];
89 }
90}
91
92export async function addProtectedTag(args: {
93 repositoryId: string;
94 pattern: string;
95 createdBy?: string | null;
96}): Promise<ProtectedTag | null> {
97 try {
98 const [row] = await db
99 .insert(protectedTags)
100 .values({
101 repositoryId: args.repositoryId,
102 pattern: args.pattern,
103 createdBy: args.createdBy || null,
104 })
105 .returning();
106 return row || null;
107 } catch (err) {
108 console.error("[protected-tags] add:", err);
109 return null;
110 }
111}
112
113export async function removeProtectedTag(
114 repositoryId: string,
115 id: string
116): Promise<boolean> {
117 try {
118 const res = await db
119 .delete(protectedTags)
120 .where(
121 and(
122 eq(protectedTags.id, id),
123 eq(protectedTags.repositoryId, repositoryId)
124 )
125 )
126 .returning({ id: protectedTags.id });
127 return res.length > 0;
128 } catch {
129 return false;
130 }
131}
132
133function stripRefsTags(s: string): string {
134 return s.startsWith("refs/tags/") ? s.slice("refs/tags/".length) : s;
135}
136
137/**
138 * Resolve a username → user id. Used by enforcement points that only have
139 * the pusher's username (e.g. the git smart-HTTP route).
140 */
141export async function userIdFromUsername(
142 username: string | null | undefined
143): Promise<string | null> {
144 if (!username) return null;
145 try {
146 const [u] = await db
147 .select({ id: users.id })
148 .from(users)
149 .where(eq(users.username, username))
150 .limit(1);
151 return u?.id || null;
152 } catch {
153 return null;
154 }
155}
Modifiedsrc/routes/gates.tsx+18−9View fileUnifiedSplit
@@ -301,15 +301,24 @@ gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
301301 {!p.allowDeletion ? "No deletion" : ""}
302302 </div>
303303 </div>
304 <form
305 method="POST"
306 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
307 onsubmit="return confirm('Remove this rule?')"
308 >
309 <button type="submit" class="btn btn-sm btn-danger">
310 Remove
311 </button>
312 </form>
304 <div style="display:flex;gap:6px">
305 <a
306 href={`/${owner}/${repo}/gates/protection/${p.id}/checks`}
307 class="btn btn-sm"
308 title="Manage required status checks for this rule"
309 >
310 Required checks
311 </a>
312 <form
313 method="POST"
314 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
315 onsubmit="return confirm('Remove this rule?')"
316 >
317 <button type="submit" class="btn btn-sm btn-danger">
318 Remove
319 </button>
320 </form>
321 </div>
313322 </div>
314323 ))
315324 )}
Addedsrc/routes/merge-queue.tsx+491−0View fileUnifiedSplit
@@ -0,0 +1,491 @@
1/**
2 * Block E5 — Merge queue UI + actions.
3 *
4 * GET /:owner/:repo/queue — queue history + current state
5 * POST /:owner/:repo/pulls/:n/enqueue — enqueue a PR (requireAuth)
6 * POST /:owner/:repo/queue/:id/dequeue — remove entry (owner OR enqueuer)
7 * POST /:owner/:repo/queue/process-next — owner-only: run the head
8 *
9 * The "process-next" handler is v1 — it just re-runs gates against the base
10 * and, if green, merges by updating the base branch ref. A full background
11 * worker is future work; this keeps the feature usable without a daemon.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import {
18 mergeQueueEntries,
19 prComments,
20 pullRequests,
21 repositories,
22 users,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import {
29 enqueuePr,
30 dequeueEntry,
31 listQueueWithPrs,
32 markHeadRunning,
33 completeEntry,
34 peekHead,
35} from "../lib/merge-queue";
36import { runAllGateChecks } from "../lib/gate";
37import { resolveRef, getRepoPath } from "../git/repository";
38import { audit } from "../lib/notify";
39
40const queue = new Hono<AuthEnv>();
41queue.use("*", softAuth);
42
43async function loadRepo(ownerName: string, repoName: string) {
44 try {
45 const [row] = await db
46 .select({
47 id: repositories.id,
48 name: repositories.name,
49 ownerId: repositories.ownerId,
50 defaultBranch: repositories.defaultBranch,
51 starCount: repositories.starCount,
52 forkCount: repositories.forkCount,
53 })
54 .from(repositories)
55 .innerJoin(users, eq(repositories.ownerId, users.id))
56 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
57 .limit(1);
58 return row || null;
59 } catch {
60 return null;
61 }
62}
63
64function relTime(d: Date | string): string {
65 const t = typeof d === "string" ? new Date(d) : d;
66 const diffMs = Date.now() - t.getTime();
67 const mins = Math.floor(diffMs / 60000);
68 if (mins < 1) return "just now";
69 if (mins < 60) return `${mins}m ago`;
70 const hrs = Math.floor(mins / 60);
71 if (hrs < 24) return `${hrs}h ago`;
72 const days = Math.floor(hrs / 24);
73 if (days < 30) return `${days}d ago`;
74 return t.toLocaleDateString();
75}
76
77// ---------- Queue list ----------
78
79queue.get("/:owner/:repo/queue", async (c) => {
80 const user = c.get("user");
81 const { owner, repo } = c.req.param();
82 const repoRow = await loadRepo(owner, repo);
83 if (!repoRow) {
84 return c.html(
85 <Layout title="Not found" user={user}>
86 <div class="empty-state">
87 <h2>Repository not found</h2>
88 </div>
89 </Layout>,
90 404
91 );
92 }
93
94 const entries = await listQueueWithPrs(repoRow.id);
95 const byBranch = new Map<string, typeof entries>();
96 for (const e of entries) {
97 const arr = byBranch.get(e.baseBranch) || [];
98 arr.push(e);
99 byBranch.set(e.baseBranch, arr);
100 }
101
102 const isOwner = !!user && user.id === repoRow.ownerId;
103 const success = c.req.query("success");
104 const error = c.req.query("error");
105
106 const stateBadge = (s: string) => {
107 const style: Record<string, string> = {
108 queued: "background:#30363d;color:#c9d1d9",
109 running: "background:#1f6feb;color:white",
110 merged: "background:#8957e5;color:white",
111 failed: "background:#da3633;color:white",
112 dequeued: "background:#484f58;color:#c9d1d9",
113 };
114 return (
115 <span
116 style={`${style[s] || style.queued};padding:2px 8px;border-radius:3px;font-size:11px;text-transform:uppercase;font-weight:600`}
117 >
118 {s}
119 </span>
120 );
121 };
122
123 return c.html(
124 <Layout title={`Merge queue — ${owner}/${repo}`} user={user}>
125 <RepoHeader
126 owner={owner}
127 repo={repo}
128 starCount={repoRow.starCount}
129 forkCount={repoRow.forkCount}
130 currentUser={user?.username || null}
131 />
132 <RepoNav owner={owner} repo={repo} active="pulls" />
133
134 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
135 <h3>Merge queue</h3>
136 <a href={`/${owner}/${repo}/pulls`} class="btn btn-sm">
137 Back to PRs
138 </a>
139 </div>
140
141 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
142 Serialised merges: PRs queued here re-run gates against the latest base
143 before being merged. This prevents green-in-isolation / red-after-merge
144 races.
145 </p>
146
147 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
148 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
149
150 {entries.length === 0 ? (
151 <div class="empty-state">
152 <p>Queue is empty. Enqueue a PR from the pull-request page.</p>
153 </div>
154 ) : (
155 Array.from(byBranch.entries()).map(([branch, items]) => {
156 const active = items.filter(
157 (i) => i.state === "queued" || i.state === "running"
158 );
159 return (
160 <div class="panel" style="margin-bottom:20px;overflow:hidden">
161 <div style="padding:12px 14px;background:var(--bg-tertiary);display:flex;justify-content:space-between;align-items:center">
162 <div style="font-weight:600">
163 Base: <code>{branch}</code>{" "}
164 <span style="font-size:12px;color:var(--text-muted);font-weight:400;margin-left:8px">
165 {active.length} active
166 </span>
167 </div>
168 {isOwner && active.length > 0 && (
169 <form
170 method="POST"
171 action={`/${owner}/${repo}/queue/process-next?base=${encodeURIComponent(branch)}`}
172 >
173 <button type="submit" class="btn btn-sm btn-primary">
174 Process next
175 </button>
176 </form>
177 )}
178 </div>
179 {items.map((it) => (
180 <div
181 class="panel-item"
182 style="justify-content:space-between;align-items:flex-start"
183 >
184 <div style="flex:1;min-width:0">
185 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
186 {stateBadge(it.state)}
187 {it.prNumber != null ? (
188 <a
189 href={`/${owner}/${repo}/pulls/${it.prNumber}`}
190 style="font-weight:600"
191 >
192 #{it.prNumber} {it.prTitle}
193 </a>
194 ) : (
195 <span style="color:var(--text-muted)">(PR gone)</span>
196 )}
197 </div>
198 <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
199 pos {it.position} ·{" "}
200 {it.prHeadBranch ? <code>{it.prHeadBranch}</code> : ""} ·
201 enqueued {relTime(it.enqueuedAt)}
202 {it.startedAt
203 ? ` · started ${relTime(it.startedAt)}`
204 : ""}
205 {it.finishedAt
206 ? ` · finished ${relTime(it.finishedAt)}`
207 : ""}
208 </div>
209 {it.errorMessage && (
210 <div style="font-size:12px;color:var(--red);margin-top:4px">
211 {it.errorMessage}
212 </div>
213 )}
214 </div>
215 {(it.state === "queued" || it.state === "running") &&
216 user &&
217 (isOwner || user.id === it.enqueuedBy) && (
218 <form
219 method="POST"
220 action={`/${owner}/${repo}/queue/${it.id}/dequeue`}
221 onsubmit="return confirm('Remove from queue?')"
222 >
223 <button type="submit" class="btn btn-sm">
224 Remove
225 </button>
226 </form>
227 )}
228 </div>
229 ))}
230 </div>
231 );
232 })
233 )}
234 </Layout>
235 );
236});
237
238// ---------- Enqueue a PR ----------
239
240queue.post("/:owner/:repo/pulls/:number/enqueue", requireAuth, async (c) => {
241 const user = c.get("user")!;
242 const { owner, repo } = c.req.param();
243 const prNum = parseInt(c.req.param("number"), 10);
244 const repoRow = await loadRepo(owner, repo);
245 if (!repoRow) return c.notFound();
246
247 const [pr] = await db
248 .select()
249 .from(pullRequests)
250 .where(
251 and(
252 eq(pullRequests.repositoryId, repoRow.id),
253 eq(pullRequests.number, prNum)
254 )
255 )
256 .limit(1);
257 if (!pr || pr.state !== "open") {
258 return c.redirect(
259 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
260 "PR must be open to enqueue."
261 )}`
262 );
263 }
264 if (pr.isDraft) {
265 return c.redirect(
266 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
267 "Cannot enqueue a draft PR."
268 )}`
269 );
270 }
271
272 const result = await enqueuePr({
273 repositoryId: repoRow.id,
274 pullRequestId: pr.id,
275 baseBranch: pr.baseBranch,
276 enqueuedBy: user.id,
277 });
278 if (!result.ok) {
279 return c.redirect(
280 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
281 result.reason || "Enqueue failed"
282 )}`
283 );
284 }
285
286 await audit({
287 userId: user.id,
288 repositoryId: repoRow.id,
289 action: "merge_queue.enqueue",
290 targetId: pr.id,
291 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
292 });
293
294 return c.redirect(
295 `/${owner}/${repo}/queue?success=${encodeURIComponent(
296 `PR #${pr.number} enqueued`
297 )}`
298 );
299});
300
301// ---------- Dequeue ----------
302
303queue.post("/:owner/:repo/queue/:id/dequeue", requireAuth, async (c) => {
304 const user = c.get("user")!;
305 const { owner, repo, id } = c.req.param();
306 const repoRow = await loadRepo(owner, repo);
307 if (!repoRow) return c.notFound();
308
309 const [entry] = await db
310 .select()
311 .from(mergeQueueEntries)
312 .where(
313 and(
314 eq(mergeQueueEntries.id, id),
315 eq(mergeQueueEntries.repositoryId, repoRow.id)
316 )
317 )
318 .limit(1);
319 if (!entry) {
320 return c.redirect(
321 `/${owner}/${repo}/queue?error=${encodeURIComponent("Entry not found")}`
322 );
323 }
324 const isOwner = user.id === repoRow.ownerId;
325 if (!isOwner && entry.enqueuedBy !== user.id) {
326 return c.redirect(
327 `/${owner}/${repo}/queue?error=${encodeURIComponent(
328 "Only the enqueuer or a repo owner can remove this entry."
329 )}`
330 );
331 }
332
333 const ok = await dequeueEntry(id);
334 if (!ok) {
335 return c.redirect(
336 `/${owner}/${repo}/queue?error=${encodeURIComponent("Could not remove entry")}`
337 );
338 }
339
340 await audit({
341 userId: user.id,
342 repositoryId: repoRow.id,
343 action: "merge_queue.dequeue",
344 targetId: entry.pullRequestId,
345 });
346
347 return c.redirect(
348 `/${owner}/${repo}/queue?success=${encodeURIComponent("Entry removed")}`
349 );
350});
351
352// ---------- Process next ----------
353
354queue.post("/:owner/:repo/queue/process-next", requireAuth, async (c) => {
355 const user = c.get("user")!;
356 const { owner, repo } = c.req.param();
357 const base = c.req.query("base");
358 const repoRow = await loadRepo(owner, repo);
359 if (!repoRow) return c.notFound();
360 if (repoRow.ownerId !== user.id) {
361 return c.redirect(
362 `/${owner}/${repo}/queue?error=${encodeURIComponent(
363 "Only repo owners can process the queue."
364 )}`
365 );
366 }
367
368 const targetBase = base || repoRow.defaultBranch || "main";
369 const head = await peekHead(repoRow.id, targetBase);
370 if (!head) {
371 return c.redirect(
372 `/${owner}/${repo}/queue?error=${encodeURIComponent(
373 `No queued entries for base ${targetBase}`
374 )}`
375 );
376 }
377
378 const started = await markHeadRunning(repoRow.id, targetBase);
379 if (!started) {
380 return c.redirect(
381 `/${owner}/${repo}/queue?error=${encodeURIComponent(
382 "Could not transition head to running"
383 )}`
384 );
385 }
386
387 // Re-run gates against latest base.
388 const [pr] = await db
389 .select()
390 .from(pullRequests)
391 .where(eq(pullRequests.id, started.pullRequestId))
392 .limit(1);
393 if (!pr) {
394 await completeEntry(started.id, "failed", "Pull request no longer exists.");
395 return c.redirect(
396 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR vanished")}`
397 );
398 }
399 if (pr.state !== "open") {
400 await completeEntry(started.id, "failed", "Pull request is no longer open.");
401 return c.redirect(
402 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR is no longer open")}`
403 );
404 }
405
406 const headSha = await resolveRef(owner, repo, pr.headBranch);
407 if (!headSha) {
408 await completeEntry(started.id, "failed", "Head branch not found.");
409 return c.redirect(
410 `/${owner}/${repo}/queue?error=${encodeURIComponent("Head branch not found")}`
411 );
412 }
413
414 const gateResult = await runAllGateChecks(
415 owner,
416 repo,
417 pr.baseBranch,
418 pr.headBranch,
419 headSha,
420 true
421 );
422 const hardFailures = gateResult.checks.filter(
423 (check) => !check.passed && check.name !== "Merge check"
424 );
425 if (hardFailures.length > 0) {
426 const msg = hardFailures
427 .map((f) => `${f.name}: ${f.details}`)
428 .join("; ");
429 await completeEntry(started.id, "failed", msg);
430 try {
431 await db.insert(prComments).values({
432 pullRequestId: pr.id,
433 authorId: user.id,
434 body: `**Merge queue:** gates failed on latest base — ${msg}`,
435 isAiReview: false,
436 });
437 } catch {}
438 return c.redirect(
439 `/${owner}/${repo}/queue?error=${encodeURIComponent(msg)}`
440 );
441 }
442
443 // Gates passed — merge by updating base ref to head.
444 const repoDir = getRepoPath(owner, repo);
445 const proc = Bun.spawn(
446 [
447 "git",
448 "update-ref",
449 `refs/heads/${pr.baseBranch}`,
450 `refs/heads/${pr.headBranch}`,
451 ],
452 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
453 );
454 const exit = await proc.exited;
455 if (exit !== 0) {
456 await completeEntry(started.id, "failed", "update-ref failed");
457 return c.redirect(
458 `/${owner}/${repo}/queue?error=${encodeURIComponent(
459 "Merge failed — unable to update base ref"
460 )}`
461 );
462 }
463
464 await db
465 .update(pullRequests)
466 .set({
467 state: "merged",
468 mergedAt: new Date(),
469 mergedBy: user.id,
470 updatedAt: new Date(),
471 })
472 .where(eq(pullRequests.id, pr.id));
473
474 await completeEntry(started.id, "merged");
475
476 await audit({
477 userId: user.id,
478 repositoryId: repoRow.id,
479 action: "merge_queue.merged",
480 targetId: pr.id,
481 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
482 });
483
484 return c.redirect(
485 `/${owner}/${repo}/queue?success=${encodeURIComponent(
486 `PR #${pr.number} merged via queue`
487 )}`
488 );
489});
490
491export default queue;
Addedsrc/routes/protected-tags.tsx+218−0View fileUnifiedSplit
@@ -0,0 +1,218 @@
1/**
2 * Block E7 — Protected tags settings UI.
3 *
4 * GET /:owner/:repo/settings/protected-tags — CRUD list
5 * POST /:owner/:repo/settings/protected-tags — create
6 * POST /:owner/:repo/settings/protected-tags/:id/delete — remove
7 */
8
9import { Hono } from "hono";
10import { and, eq } from "drizzle-orm";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { RepoHeader, RepoNav } from "../views/components";
15import { softAuth, requireAuth } from "../middleware/auth";
16import type { AuthEnv } from "../middleware/auth";
17import {
18 addProtectedTag,
19 listProtectedTags,
20 removeProtectedTag,
21} from "../lib/protected-tags";
22import { audit } from "../lib/notify";
23
24const protectedTagsRoutes = new Hono<AuthEnv>();
25protectedTagsRoutes.use("*", softAuth);
26
27async function loadRepo(ownerName: string, repoName: string) {
28 try {
29 const [row] = await db
30 .select({
31 id: repositories.id,
32 name: repositories.name,
33 ownerId: repositories.ownerId,
34 starCount: repositories.starCount,
35 forkCount: repositories.forkCount,
36 })
37 .from(repositories)
38 .innerJoin(users, eq(repositories.ownerId, users.id))
39 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
40 .limit(1);
41 return row || null;
42 } catch {
43 return null;
44 }
45}
46
47protectedTagsRoutes.get(
48 "/:owner/:repo/settings/protected-tags",
49 requireAuth,
50 async (c) => {
51 const user = c.get("user")!;
52 const { owner, repo } = c.req.param();
53 const repoRow = await loadRepo(owner, repo);
54 if (!repoRow) return c.notFound();
55 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
56
57 const tags = await listProtectedTags(repoRow.id);
58 const success = c.req.query("success");
59 const error = c.req.query("error");
60
61 return c.html(
62 <Layout title={`Protected tags — ${owner}/${repo}`} user={user}>
63 <RepoHeader
64 owner={owner}
65 repo={repo}
66 starCount={repoRow.starCount}
67 forkCount={repoRow.forkCount}
68 currentUser={user.username}
69 />
70 <RepoNav owner={owner} repo={repo} active="gates" />
71
72 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
73 <h3>Protected tags</h3>
74 <a href={`/${owner}/${repo}/settings`} class="btn btn-sm">
75 Back to settings
76 </a>
77 </div>
78
79 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
80 Mark tag patterns as protected. Only repo owners can create, update,
81 or delete tags matching one of these patterns. Supports globs:
82 <code>v*</code>, <code>release-*</code>, <code>**</code>.
83 </p>
84
85 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
86 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
87
88 <div class="panel" style="margin-bottom:16px">
89 {tags.length === 0 ? (
90 <div class="panel-empty">No protected tag patterns.</div>
91 ) : (
92 tags.map((t) => (
93 <div class="panel-item" style="justify-content:space-between">
94 <div>
95 <code
96 style="background:var(--bg-tertiary);padding:2px 8px;border-radius:3px"
97 >
98 {t.pattern}
99 </code>
100 <div
101 class="meta"
102 style="margin-top:4px;font-size:12px;color:var(--text-muted)"
103 >
104 Added{" "}
105 {t.createdAt
106 ? new Date(t.createdAt as unknown as string).toLocaleDateString()
107 : ""}
108 </div>
109 </div>
110 <form
111 method="POST"
112 action={`/${owner}/${repo}/settings/protected-tags/${t.id}/delete`}
113 onsubmit="return confirm('Remove protection for this pattern?')"
114 >
115 <button type="submit" class="btn btn-sm btn-danger">
116 Remove
117 </button>
118 </form>
119 </div>
120 ))
121 )}
122 </div>
123
124 <form
125 method="POST"
126 action={`/${owner}/${repo}/settings/protected-tags`}
127 class="panel"
128 style="padding:16px"
129 >
130 <div class="form-group">
131 <label>Pattern</label>
132 <input
133 type="text"
134 name="pattern"
135 required
136 placeholder="v* or release-*"
137 style="font-family:var(--font-mono)"
138 />
139 </div>
140 <button type="submit" class="btn btn-primary">
141 Protect pattern
142 </button>
143 </form>
144 </Layout>
145 );
146 }
147);
148
149protectedTagsRoutes.post(
150 "/:owner/:repo/settings/protected-tags",
151 requireAuth,
152 async (c) => {
153 const user = c.get("user")!;
154 const { owner, repo } = c.req.param();
155 const repoRow = await loadRepo(owner, repo);
156 if (!repoRow) return c.notFound();
157 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
158
159 const body = await c.req.parseBody();
160 const pattern = String(body.pattern || "").trim();
161 if (!pattern) {
162 return c.redirect(
163 `/${owner}/${repo}/settings/protected-tags?error=${encodeURIComponent("Pattern required")}`
164 );
165 }
166
167 const created = await addProtectedTag({
168 repositoryId: repoRow.id,
169 pattern,
170 createdBy: user.id,
171 });
172
173 if (created) {
174 await audit({
175 userId: user.id,
176 repositoryId: repoRow.id,
177 action: "protected_tags.create",
178 metadata: { pattern },
179 });
180 }
181
182 return c.redirect(
183 `/${owner}/${repo}/settings/protected-tags?success=${encodeURIComponent(
184 created ? `Pattern '${pattern}' protected` : "Could not save pattern"
185 )}`
186 );
187 }
188);
189
190protectedTagsRoutes.post(
191 "/:owner/:repo/settings/protected-tags/:id/delete",
192 requireAuth,
193 async (c) => {
194 const user = c.get("user")!;
195 const { owner, repo, id } = c.req.param();
196 const repoRow = await loadRepo(owner, repo);
197 if (!repoRow) return c.notFound();
198 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
199
200 const ok = await removeProtectedTag(repoRow.id, id);
201 if (ok) {
202 await audit({
203 userId: user.id,
204 repositoryId: repoRow.id,
205 action: "protected_tags.delete",
206 targetId: id,
207 });
208 }
209
210 return c.redirect(
211 `/${owner}/${repo}/settings/protected-tags?success=${encodeURIComponent(
212 ok ? "Pattern removed" : "Nothing removed"
213 )}`
214 );
215 }
216);
217
218export default protectedTagsRoutes;
Modifiedsrc/routes/pulls.tsx+25−6View fileUnifiedSplit
@@ -35,6 +35,8 @@ import {
3535 matchProtection,
3636 evaluateProtection,
3737 countHumanApprovals,
38 listRequiredChecks,
39 passingCheckNames,
3840} from "../lib/branch-protection";
3941
4042const pulls = new Hono<AuthEnv>();
@@ -667,6 +669,14 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
667669 ? "Merge with auto-resolve"
668670 : "Merge pull request"}
669671 </button>
672 <button
673 type="submit"
674 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/enqueue`}
675 class="btn"
676 title="Queue this PR — gates will re-run against latest base before merge"
677 >
678 Add to merge queue
679 </button>
670680 <button
671681 type="submit"
672682 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
@@ -832,12 +842,21 @@ pulls.post(
832842 );
833843 if (protectionRule) {
834844 const humanApprovals = await countHumanApprovals(pr.id);
835 const decision = evaluateProtection(protectionRule, {
836 aiApproved,
837 humanApprovalCount: humanApprovals,
838 gateResultGreen: hardFailures.length === 0,
839 hasFailedGates: hardFailures.length > 0,
840 });
845 const required = await listRequiredChecks(protectionRule.id);
846 const passingNames = required.length > 0
847 ? await passingCheckNames(resolved.repo.id, headSha)
848 : [];
849 const decision = evaluateProtection(
850 protectionRule,
851 {
852 aiApproved,
853 humanApprovalCount: humanApprovals,
854 gateResultGreen: hardFailures.length === 0,
855 hasFailedGates: hardFailures.length > 0,
856 passingCheckNames: passingNames,
857 },
858 required.map((r) => r.checkName)
859 );
841860 if (!decision.allowed) {
842861 return c.redirect(
843862 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
Addedsrc/routes/required-checks.tsx+269−0View fileUnifiedSplit
@@ -0,0 +1,269 @@
1/**
2 * Block E6 — Required status checks matrix settings UI.
3 *
4 * GET /:owner/:repo/gates/protection/:id/checks — manage required checks
5 * POST /:owner/:repo/gates/protection/:id/checks — add a check name
6 * POST /:owner/:repo/gates/protection/:id/checks/:cid/delete — remove
7 *
8 * Required checks are scoped to a single branch-protection rule. Adding a
9 * check tells the merge handler "in addition to green gates, the check with
10 * this name must have a passing gate_run OR workflow_run against the head
11 * commit". Name matching is exact (case-sensitive); callers typically use
12 * workflow `name:` or the gate kinds (e.g. `GateTest`, `AI Review`).
13 */
14
15import { Hono } from "hono";
16import { and, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 branchProtection,
20 branchRequiredChecks,
21 repositories,
22 users,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { listRequiredChecks } from "../lib/branch-protection";
29import { audit } from "../lib/notify";
30
31const required = new Hono<AuthEnv>();
32required.use("*", softAuth);
33
34async function loadRepo(ownerName: string, repoName: string) {
35 try {
36 const [row] = await db
37 .select({
38 id: repositories.id,
39 name: repositories.name,
40 ownerId: repositories.ownerId,
41 starCount: repositories.starCount,
42 forkCount: repositories.forkCount,
43 })
44 .from(repositories)
45 .innerJoin(users, eq(repositories.ownerId, users.id))
46 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
47 .limit(1);
48 return row || null;
49 } catch {
50 return null;
51 }
52}
53
54async function loadRule(repositoryId: string, ruleId: string) {
55 try {
56 const [rule] = await db
57 .select()
58 .from(branchProtection)
59 .where(
60 and(
61 eq(branchProtection.id, ruleId),
62 eq(branchProtection.repositoryId, repositoryId)
63 )
64 )
65 .limit(1);
66 return rule || null;
67 } catch {
68 return null;
69 }
70}
71
72required.get(
73 "/:owner/:repo/gates/protection/:id/checks",
74 requireAuth,
75 async (c) => {
76 const user = c.get("user")!;
77 const { owner, repo, id } = c.req.param();
78 const repoRow = await loadRepo(owner, repo);
79 if (!repoRow) return c.notFound();
80 if (repoRow.ownerId !== user.id) {
81 return c.redirect(`/${owner}/${repo}/gates`);
82 }
83 const rule = await loadRule(repoRow.id, id);
84 if (!rule) {
85 return c.redirect(
86 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
87 );
88 }
89
90 const checks = await listRequiredChecks(rule.id);
91 const success = c.req.query("success");
92 const error = c.req.query("error");
93
94 return c.html(
95 <Layout title={`Required checks — ${rule.pattern}`} user={user}>
96 <RepoHeader
97 owner={owner}
98 repo={repo}
99 starCount={repoRow.starCount}
100 forkCount={repoRow.forkCount}
101 currentUser={user.username}
102 />
103 <RepoNav owner={owner} repo={repo} active="gates" />
104
105 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
106 <h3>
107 Required checks · <code>{rule.pattern}</code>
108 </h3>
109 <a href={`/${owner}/${repo}/gates/settings`} class="btn btn-sm">
110 Back to protection
111 </a>
112 </div>
113
114 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
115 Merges into branches matching this rule require a passing run for
116 each named check. Names match against <code>gate_runs.gate_name</code>{" "}
117 (e.g. <code>GateTest</code>, <code>AI Review</code>,{" "}
118 <code>Secret Scan</code>, <code>Type Check</code>) or the{" "}
119 <code>name:</code> field of a workflow in{" "}
120 <code>.gluecron/workflows/*.yml</code>.
121 </p>
122
123 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
124 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
125
126 <div class="panel" style="margin-bottom:16px">
127 {checks.length === 0 ? (
128 <div class="panel-empty">No required checks configured.</div>
129 ) : (
130 checks.map((ch) => (
131 <div class="panel-item" style="justify-content:space-between">
132 <code
133 style="background:var(--bg-tertiary);padding:2px 8px;border-radius:3px"
134 >
135 {ch.checkName}
136 </code>
137 <form
138 method="POST"
139 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks/${ch.id}/delete`}
140 onsubmit="return confirm('Remove this required check?')"
141 >
142 <button type="submit" class="btn btn-sm btn-danger">
143 Remove
144 </button>
145 </form>
146 </div>
147 ))
148 )}
149 </div>
150
151 <form
152 method="POST"
153 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks`}
154 class="panel"
155 style="padding:16px"
156 >
157 <div class="form-group">
158 <label>Check name</label>
159 <input
160 type="text"
161 name="checkName"
162 required
163 placeholder="GateTest"
164 style="font-family:var(--font-mono)"
165 />
166 </div>
167 <button type="submit" class="btn btn-primary">
168 Add required check
169 </button>
170 </form>
171 </Layout>
172 );
173 }
174);
175
176required.post(
177 "/:owner/:repo/gates/protection/:id/checks",
178 requireAuth,
179 async (c) => {
180 const user = c.get("user")!;
181 const { owner, repo, id } = c.req.param();
182 const repoRow = await loadRepo(owner, repo);
183 if (!repoRow) return c.notFound();
184 if (repoRow.ownerId !== user.id) {
185 return c.redirect(`/${owner}/${repo}/gates`);
186 }
187 const rule = await loadRule(repoRow.id, id);
188 if (!rule) {
189 return c.redirect(
190 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
191 );
192 }
193
194 const body = await c.req.parseBody();
195 const checkName = String(body.checkName || "").trim();
196 if (!checkName) {
197 return c.redirect(
198 `/${owner}/${repo}/gates/protection/${rule.id}/checks?error=${encodeURIComponent("Name required")}`
199 );
200 }
201
202 try {
203 await db
204 .insert(branchRequiredChecks)
205 .values({ branchProtectionId: rule.id, checkName });
206 } catch (err) {
207 // Likely a unique-index collision — treat as success.
208 console.error("[required-checks] insert:", err);
209 }
210
211 await audit({
212 userId: user.id,
213 repositoryId: repoRow.id,
214 action: "branch_required_checks.create",
215 targetId: rule.id,
216 metadata: { checkName, pattern: rule.pattern },
217 });
218
219 return c.redirect(
220 `/${owner}/${repo}/gates/protection/${rule.id}/checks?success=${encodeURIComponent("Check added")}`
221 );
222 }
223);
224
225required.post(
226 "/:owner/:repo/gates/protection/:id/checks/:cid/delete",
227 requireAuth,
228 async (c) => {
229 const user = c.get("user")!;
230 const { owner, repo, id, cid } = c.req.param();
231 const repoRow = await loadRepo(owner, repo);
232 if (!repoRow) return c.notFound();
233 if (repoRow.ownerId !== user.id) {
234 return c.redirect(`/${owner}/${repo}/gates`);
235 }
236 const rule = await loadRule(repoRow.id, id);
237 if (!rule) {
238 return c.redirect(
239 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
240 );
241 }
242
243 try {
244 await db
245 .delete(branchRequiredChecks)
246 .where(
247 and(
248 eq(branchRequiredChecks.id, cid),
249 eq(branchRequiredChecks.branchProtectionId, rule.id)
250 )
251 );
252 } catch (err) {
253 console.error("[required-checks] delete:", err);
254 }
255
256 await audit({
257 userId: user.id,
258 repositoryId: repoRow.id,
259 action: "branch_required_checks.delete",
260 targetId: rule.id,
261 });
262
263 return c.redirect(
264 `/${owner}/${repo}/gates/protection/${rule.id}/checks?success=${encodeURIComponent("Check removed")}`
265 );
266 }
267);
268
269export default required;
0270