Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

required-checks.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

required-checks.tsxBlame965 lines · 1 contributor
a79a9edClaude1/**
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`).
7581253Claude13 *
14 * 2026 polish: scoped under `.rc-`. Each check renders as a card with a
15 * traffic-light dot showing the last observed status + duration pulled from
16 * gate_runs. Form actions, validation, and POST handlers are preserved
17 * verbatim — security-critical surface.
a79a9edClaude18 */
19
20import { Hono } from "hono";
7581253Claude21import { and, desc, eq } from "drizzle-orm";
a79a9edClaude22import { db } from "../db";
23import {
24 branchProtection,
25 branchRequiredChecks,
7581253Claude26 gateRuns,
a79a9edClaude27 repositories,
28 users,
29} from "../db/schema";
30import { Layout } from "../views/layout";
31import { RepoHeader, RepoNav } from "../views/components";
32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import { listRequiredChecks } from "../lib/branch-protection";
35import { audit } from "../lib/notify";
36
37const required = new Hono<AuthEnv>();
38required.use("*", softAuth);
39
40async function loadRepo(ownerName: string, repoName: string) {
41 try {
42 const [row] = await db
43 .select({
44 id: repositories.id,
45 name: repositories.name,
46 ownerId: repositories.ownerId,
47 starCount: repositories.starCount,
48 forkCount: repositories.forkCount,
49 })
50 .from(repositories)
51 .innerJoin(users, eq(repositories.ownerId, users.id))
52 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
53 .limit(1);
54 return row || null;
55 } catch {
56 return null;
57 }
58}
59
60async function loadRule(repositoryId: string, ruleId: string) {
61 try {
62 const [rule] = await db
63 .select()
64 .from(branchProtection)
65 .where(
66 and(
67 eq(branchProtection.id, ruleId),
68 eq(branchProtection.repositoryId, repositoryId)
69 )
70 )
71 .limit(1);
72 return rule || null;
73 } catch {
74 return null;
75 }
76}
77
7581253Claude78interface CheckStatus {
79 status: string | null;
80 durationMs: number | null;
81 createdAt: Date | null;
82}
83
84/**
85 * Best-effort lookup of the most recent gate_run for a given check name in this repo.
86 * Returns null if the table is unavailable or no run exists.
87 */
88async function lastStatusForCheck(
89 repositoryId: string,
90 checkName: string
91): Promise<CheckStatus> {
92 try {
93 const [row] = await db
94 .select({
95 status: gateRuns.status,
96 durationMs: gateRuns.durationMs,
97 createdAt: gateRuns.createdAt,
98 })
99 .from(gateRuns)
100 .where(
101 and(
102 eq(gateRuns.repositoryId, repositoryId),
103 eq(gateRuns.gateName, checkName)
104 )
105 )
106 .orderBy(desc(gateRuns.createdAt))
107 .limit(1);
108 return {
109 status: row?.status ?? null,
110 durationMs: row?.durationMs ?? null,
111 createdAt: row?.createdAt ?? null,
112 };
113 } catch {
114 return { status: null, durationMs: null, createdAt: null };
115 }
116}
117
118function relTime(d: Date | null): string {
119 if (!d) return "—";
120 const t = typeof d === "string" ? new Date(d) : d;
121 const diffMs = Date.now() - t.getTime();
122 const mins = Math.floor(diffMs / 60000);
123 if (mins < 1) return "just now";
124 if (mins < 60) return `${mins}m ago`;
125 const hrs = Math.floor(mins / 60);
126 if (hrs < 24) return `${hrs}h ago`;
127 const days = Math.floor(hrs / 24);
128 if (days < 30) return `${days}d ago`;
129 return t.toLocaleDateString();
130}
131
132function lightClass(status: string | null): "is-pass" | "is-fail" | "is-warn" | "is-idle" {
133 if (status === "passed" || status === "repaired") return "is-pass";
134 if (status === "failed") return "is-fail";
135 if (status === "pending" || status === "running" || status === "skipped") return "is-warn";
136 return "is-idle";
137}
138
139/* ─────────────────────────────────────────────────────────────────────────
140 * Scoped CSS — every selector prefixed `.rc-` so this surface can't leak.
141 * ───────────────────────────────────────────────────────────────────── */
142const rcStyles = `
eed4684Claude143 .rc-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-4) 0; }
7581253Claude144
145 .rc-hero {
146 position: relative;
147 margin-bottom: var(--space-5);
148 padding: var(--space-5) var(--space-6);
149 background: var(--bg-elevated);
150 border: 1px solid var(--border);
151 border-radius: 16px;
152 overflow: hidden;
153 }
154 .rc-hero::before {
155 content: '';
156 position: absolute;
157 top: 0; left: 0; right: 0;
158 height: 2px;
6fd5915Claude159 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude160 opacity: 0.7;
161 pointer-events: none;
162 }
163 .rc-hero-orb {
164 position: absolute;
165 inset: -20% -10% auto auto;
166 width: 380px; height: 380px;
6fd5915Claude167 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
7581253Claude168 filter: blur(80px);
169 opacity: 0.7;
170 pointer-events: none;
171 z-index: 0;
172 }
173 .rc-hero-inner {
174 position: relative;
175 z-index: 1;
176 display: flex;
177 align-items: flex-end;
178 justify-content: space-between;
179 gap: var(--space-4);
180 flex-wrap: wrap;
181 }
182 .rc-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
183 .rc-eyebrow {
184 font-size: 12px;
185 color: var(--text-muted);
186 margin-bottom: var(--space-2);
187 letter-spacing: 0.02em;
188 display: inline-flex;
189 align-items: center;
190 gap: 8px;
191 text-transform: uppercase;
192 font-family: var(--font-mono);
193 font-weight: 600;
194 }
195 .rc-eyebrow-pill {
196 display: inline-flex;
197 align-items: center;
198 justify-content: center;
199 width: 18px; height: 18px;
200 border-radius: 6px;
6fd5915Claude201 background: rgba(91,110,232,0.14);
202 color: #5b6ee8;
203 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude204 }
205 .rc-title {
206 font-size: clamp(26px, 3.6vw, 36px);
207 font-family: var(--font-display);
208 font-weight: 800;
209 letter-spacing: -0.028em;
210 line-height: 1.05;
211 margin: 0 0 var(--space-2);
212 color: var(--text-strong);
213 }
214 .rc-title-grad {
6fd5915Claude215 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7581253Claude216 -webkit-background-clip: text;
217 background-clip: text;
218 -webkit-text-fill-color: transparent;
219 color: transparent;
220 }
221 .rc-sub {
222 font-size: 14.5px;
223 color: var(--text-muted);
224 margin: 0;
225 line-height: 1.55;
226 max-width: 620px;
227 }
228 .rc-sub code {
229 font-family: var(--font-mono);
230 font-size: 12.5px;
231 background: rgba(255,255,255,0.04);
232 border: 1px solid var(--border);
233 padding: 1px 6px;
234 border-radius: 5px;
235 color: var(--text);
236 }
237 .rc-hero-link {
238 display: inline-flex;
239 align-items: center;
240 gap: 6px;
241 padding: 7px 12px;
242 font-size: 12.5px;
243 color: var(--text-muted);
244 background: rgba(255,255,255,0.02);
245 border: 1px solid var(--border);
246 border-radius: 8px;
247 text-decoration: none;
248 font-weight: 500;
249 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
250 }
251 .rc-hero-link:hover {
252 border-color: var(--border-strong);
253 color: var(--text-strong);
254 background: rgba(255,255,255,0.04);
255 text-decoration: none;
256 }
257
258 .rc-banner {
259 margin-bottom: var(--space-4);
260 padding: 10px 14px;
261 border-radius: 10px;
262 font-size: 13.5px;
263 border: 1px solid var(--border);
264 background: rgba(255,255,255,0.025);
265 color: var(--text);
266 display: flex;
267 align-items: center;
268 gap: 10px;
269 }
270 .rc-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
271 .rc-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
272 .rc-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
273
274 /* ─── Status card ─── */
275 .rc-status {
276 position: relative;
277 margin-bottom: var(--space-5);
278 padding: var(--space-5);
279 background: var(--bg-elevated);
280 border: 1px solid var(--border);
281 border-radius: 14px;
282 overflow: hidden;
283 }
284 .rc-status.is-on {
285 border-color: rgba(52,211,153,0.32);
6fd5915Claude286 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
7581253Claude287 }
288 .rc-status.is-warn {
289 border-color: rgba(248,113,113,0.32);
6fd5915Claude290 background: linear-gradient(135deg, rgba(248,113,113,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
7581253Claude291 }
292 .rc-status.is-empty {
293 border-color: rgba(251,191,36,0.30);
6fd5915Claude294 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
7581253Claude295 }
296 .rc-status-row { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
297 .rc-status-mark {
298 flex-shrink: 0;
299 width: 52px; height: 52px;
300 border-radius: 14px;
301 display: flex;
302 align-items: center;
303 justify-content: center;
304 color: #fff;
6fd5915Claude305 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
306 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.18);
7581253Claude307 }
308 .rc-status.is-on .rc-status-mark {
309 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
310 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
311 }
312 .rc-status.is-warn .rc-status-mark {
313 background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
314 box-shadow: 0 8px 20px -8px rgba(239,68,68,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
315 }
316 .rc-status.is-empty .rc-status-mark {
317 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
318 color: #1a1206;
319 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
320 }
321 .rc-status-text { flex: 1; min-width: 220px; }
322 .rc-status-headline {
323 margin: 0 0 4px;
324 font-family: var(--font-display);
325 font-size: 18px;
326 font-weight: 700;
327 letter-spacing: -0.018em;
328 color: var(--text-strong);
329 }
330 .rc-status-desc { margin: 0; font-size: 13.5px; color: var(--text-muted); line-height: 1.5; }
331 .rc-pattern-pill {
332 display: inline-flex;
333 align-items: center;
334 gap: 6px;
335 padding: 3px 10px;
336 font-family: var(--font-mono);
337 font-size: 12px;
338 font-weight: 600;
339 color: var(--text-strong);
6fd5915Claude340 background: rgba(91,110,232,0.10);
341 border: 1px solid rgba(91,110,232,0.30);
7581253Claude342 border-radius: 8px;
343 }
344
345 /* ─── Section card ─── */
346 .rc-section {
347 margin-bottom: var(--space-5);
348 background: var(--bg-elevated);
349 border: 1px solid var(--border);
350 border-radius: 14px;
351 overflow: hidden;
352 }
353 .rc-section-head {
354 padding: var(--space-4) var(--space-5);
355 border-bottom: 1px solid var(--border);
356 }
357 .rc-section-title {
358 margin: 0;
359 font-family: var(--font-display);
360 font-size: 16px;
361 font-weight: 700;
362 letter-spacing: -0.018em;
363 color: var(--text-strong);
364 display: flex;
365 align-items: center;
366 gap: 10px;
367 }
368 .rc-section-icon {
369 display: inline-flex;
370 align-items: center;
371 justify-content: center;
372 width: 26px; height: 26px;
373 border-radius: 8px;
6fd5915Claude374 background: rgba(91,110,232,0.12);
375 color: #5b6ee8;
376 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
7581253Claude377 flex-shrink: 0;
378 }
379 .rc-section-sub {
380 margin: 6px 0 0 36px;
381 font-size: 12.5px;
382 color: var(--text-muted);
383 line-height: 1.5;
384 }
385 .rc-section-body { padding: var(--space-4) var(--space-5); }
386
387 /* ─── Check cards ─── */
388 .rc-list { display: flex; flex-direction: column; gap: 10px; }
389 .rc-check {
390 display: flex;
391 align-items: center;
392 justify-content: space-between;
393 gap: var(--space-3);
394 padding: var(--space-4);
395 background: var(--bg-elevated);
396 border: 1px solid var(--border);
397 border-radius: 12px;
398 transition: border-color 140ms ease, transform 140ms ease;
399 }
400 .rc-check:hover {
6fd5915Claude401 border-color: rgba(91,110,232,0.30);
7581253Claude402 transform: translateY(-1px);
403 }
404 .rc-check-main { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; }
405 .rc-light {
406 flex-shrink: 0;
407 width: 14px; height: 14px;
408 border-radius: 9999px;
409 background: #6b7280;
410 box-shadow: 0 0 0 3px rgba(107,114,128,0.18);
411 position: relative;
412 }
413 .rc-light.is-pass {
414 background: #34d399;
415 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 10px rgba(52,211,153,0.45);
416 }
417 .rc-light.is-fail {
418 background: #f87171;
419 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 12px rgba(248,113,113,0.50);
420 animation: rcPulse 1.8s ease-in-out infinite;
421 }
422 .rc-light.is-warn {
423 background: #fbbf24;
424 box-shadow: 0 0 0 3px rgba(251,191,36,0.22), 0 0 10px rgba(251,191,36,0.40);
425 }
426 .rc-light.is-idle {
427 background: #6b7280;
428 box-shadow: 0 0 0 3px rgba(107,114,128,0.18);
429 }
430 @keyframes rcPulse {
431 0%, 100% { opacity: 1; transform: scale(1); }
432 50% { opacity: 0.75; transform: scale(0.92); }
433 }
434 @media (prefers-reduced-motion: reduce) {
435 .rc-light.is-fail { animation: none; }
436 }
437 .rc-check-body { min-width: 0; flex: 1; }
438 .rc-check-name {
439 font-family: var(--font-mono);
440 font-size: 14px;
441 font-weight: 700;
442 color: var(--text-strong);
443 word-break: break-word;
444 }
445 .rc-check-meta {
446 margin-top: 4px;
447 font-size: 12px;
448 color: var(--text-muted);
449 display: flex;
450 flex-wrap: wrap;
451 align-items: center;
452 gap: 8px;
453 }
454 .rc-check-pill {
455 display: inline-flex;
456 align-items: center;
457 gap: 5px;
458 padding: 2px 9px;
459 font-size: 10.5px;
460 font-weight: 700;
461 text-transform: uppercase;
462 letter-spacing: 0.06em;
463 border-radius: 9999px;
464 }
465 .rc-check-pill.is-pass { background: rgba(52,211,153,0.14); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
466 .rc-check-pill.is-fail { background: rgba(248,113,113,0.14); color: #fecaca; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
467 .rc-check-pill.is-warn { background: rgba(251,191,36,0.14); color: #fde68a; box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32); }
468 .rc-check-pill.is-idle { background: rgba(255,255,255,0.04); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
469
470 /* ─── Empty state ─── */
471 .rc-empty {
472 padding: var(--space-6);
473 text-align: center;
474 background: var(--bg-elevated);
475 border: 1px dashed var(--border-strong);
476 border-radius: 14px;
477 position: relative;
478 overflow: hidden;
479 }
480 .rc-empty-orb {
481 position: absolute;
482 inset: -30% auto auto -10%;
483 width: 260px; height: 260px;
6fd5915Claude484 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
7581253Claude485 filter: blur(60px);
486 opacity: 0.8;
487 pointer-events: none;
488 }
489 .rc-empty-mark {
490 position: relative;
491 z-index: 1;
492 margin: 0 auto var(--space-3);
493 width: 52px; height: 52px;
494 border-radius: 14px;
6fd5915Claude495 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12));
496 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
7581253Claude497 display: flex;
498 align-items: center;
499 justify-content: center;
500 color: #c4b5fd;
501 }
502 .rc-empty-title {
503 position: relative;
504 z-index: 1;
505 margin: 0 0 6px;
506 font-family: var(--font-display);
507 font-size: 17px;
508 font-weight: 700;
509 color: var(--text-strong);
510 letter-spacing: -0.018em;
511 }
512 .rc-empty-body {
513 position: relative;
514 z-index: 1;
515 margin: 0 auto;
516 max-width: 460px;
517 font-size: 13.5px;
518 color: var(--text-muted);
519 line-height: 1.55;
520 }
521
522 /* ─── Form ─── */
523 .rc-form { padding: var(--space-5); }
524 .rc-form-group { margin-bottom: var(--space-4); }
525 .rc-form-label {
526 display: block;
527 font-family: var(--font-mono);
528 font-size: 11.5px;
529 font-weight: 700;
530 text-transform: uppercase;
531 letter-spacing: 0.12em;
532 color: var(--text-muted);
533 margin-bottom: 6px;
534 }
535 .rc-input {
536 width: 100%;
537 padding: 9px 12px;
538 font-size: 13.5px;
539 color: var(--text);
540 background: var(--bg);
541 border: 1px solid var(--border-strong);
542 border-radius: 8px;
543 outline: none;
544 font-family: var(--font-mono);
545 transition: border-color 120ms ease, box-shadow 120ms ease;
546 box-sizing: border-box;
547 }
548 .rc-input:focus {
6fd5915Claude549 border-color: var(--border-focus, rgba(91,110,232,0.55));
550 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
7581253Claude551 }
552 .rc-form-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
553 .rc-form-hint code {
554 font-family: var(--font-mono);
555 font-size: 11.5px;
556 background: rgba(255,255,255,0.04);
557 border: 1px solid var(--border);
558 padding: 1px 6px;
559 border-radius: 5px;
560 color: var(--text);
561 }
562
563 /* ─── Buttons ─── */
564 .rc-btn {
565 display: inline-flex;
566 align-items: center;
567 gap: 6px;
568 padding: 9px 16px;
569 border-radius: 10px;
570 font-size: 13px;
571 font-weight: 600;
572 text-decoration: none;
573 border: 1px solid transparent;
574 cursor: pointer;
575 font-family: inherit;
576 line-height: 1;
577 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
578 }
579 .rc-btn-primary {
6fd5915Claude580 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
7581253Claude581 color: #fff;
6fd5915Claude582 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
7581253Claude583 }
584 .rc-btn-primary:hover {
585 transform: translateY(-1px);
6fd5915Claude586 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
7581253Claude587 color: #fff;
588 text-decoration: none;
589 }
590 .rc-btn-danger {
591 background: transparent;
592 color: #fecaca;
593 border-color: rgba(248,113,113,0.40);
594 }
595 .rc-btn-danger:hover {
596 background: rgba(248,113,113,0.10);
597 border-color: rgba(248,113,113,0.65);
598 color: #fee2e2;
599 text-decoration: none;
600 }
601 .rc-btn-sm { padding: 6px 11px; font-size: 12px; }
602`;
603
604/* Icons */
605const ChecklistIcon = () => (
606 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
607 <path d="M9 11l3 3L22 4" />
608 <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
609 </svg>
610);
611const PlusIcon = () => (
612 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
613 <line x1="12" y1="5" x2="12" y2="19" />
614 <line x1="5" y1="12" x2="19" y2="12" />
615 </svg>
616);
617const ArrowLeft = () => (
618 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
619 <line x1="19" y1="12" x2="5" y2="12" />
620 <polyline points="12 19 5 12 12 5" />
621 </svg>
622);
623const ShieldIcon = () => (
624 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
625 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
626 </svg>
627);
628
a79a9edClaude629required.get(
630 "/:owner/:repo/gates/protection/:id/checks",
631 requireAuth,
632 async (c) => {
633 const user = c.get("user")!;
634 const { owner, repo, id } = c.req.param();
635 const repoRow = await loadRepo(owner, repo);
636 if (!repoRow) return c.notFound();
637 if (repoRow.ownerId !== user.id) {
638 return c.redirect(`/${owner}/${repo}/gates`);
639 }
640 const rule = await loadRule(repoRow.id, id);
641 if (!rule) {
642 return c.redirect(
643 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
644 );
645 }
646
647 const checks = await listRequiredChecks(rule.id);
648 const success = c.req.query("success");
649 const error = c.req.query("error");
650
7581253Claude651 // Pull the last status for every required check in parallel so the page
652 // renders fast even with a dozen entries.
653 const statuses = await Promise.all(
654 checks.map((ch) => lastStatusForCheck(repoRow.id, ch.checkName))
655 );
656 const enrichedChecks = checks.map((ch, i) => ({
657 ...ch,
658 lastStatus: statuses[i]!,
659 }));
660
661 const failingCount = enrichedChecks.filter(
662 (c) => c.lastStatus.status === "failed"
663 ).length;
664 const passingCount = enrichedChecks.filter(
665 (c) =>
666 c.lastStatus.status === "passed" || c.lastStatus.status === "repaired"
667 ).length;
668
669 let statusVariant: "is-on" | "is-warn" | "is-empty" = "is-empty";
670 let statusHead = "No required checks configured";
671 let statusDesc =
672 "Merges into matching branches don't require any named check right now. Add one below.";
673 if (checks.length > 0) {
674 if (failingCount > 0) {
675 statusVariant = "is-warn";
676 statusHead = `${failingCount} of ${checks.length} failing`;
677 statusDesc = `Last observed run: ${failingCount} red, ${passingCount} green. Merges are blocked until they're all green.`;
678 } else {
679 statusVariant = "is-on";
680 statusHead = `${checks.length} required check${checks.length === 1 ? "" : "s"} configured`;
681 statusDesc =
682 passingCount === checks.length
683 ? "All required checks last reported green. Merges into matching branches will be allowed."
684 : "Required checks haven't all reported yet. Merges block until each one has a passing run on the head commit.";
685 }
686 }
687
a79a9edClaude688 return c.html(
689 <Layout title={`Required checks — ${rule.pattern}`} user={user}>
690 <RepoHeader
691 owner={owner}
692 repo={repo}
693 starCount={repoRow.starCount}
694 forkCount={repoRow.forkCount}
695 currentUser={user.username}
696 />
697 <RepoNav owner={owner} repo={repo} active="gates" />
698
7581253Claude699 <div class="rc-wrap">
700 <section class="rc-hero">
701 <div class="rc-hero-orb" aria-hidden="true" />
702 <div class="rc-hero-inner">
703 <div class="rc-hero-text">
704 <div class="rc-eyebrow">
705 <span class="rc-eyebrow-pill" aria-hidden="true">
706 <ChecklistIcon />
707 </span>
708 Required checks · {owner}/{repo}
709 </div>
710 <h1 class="rc-title">
711 <span class="rc-title-grad">Mergeability gates.</span>
712 </h1>
713 <p class="rc-sub">
714 Merges into branches matching <code>{rule.pattern}</code>{" "}
715 require a passing run for each named check. Names match
716 against <code>gate_runs.gate_name</code> or a workflow{" "}
717 <code>name:</code> field.
718 </p>
a79a9edClaude719 </div>
7581253Claude720 <a href={`/${owner}/${repo}/gates/settings`} class="rc-hero-link">
721 <ArrowLeft /> Back to protection
722 </a>
723 </div>
724 </section>
725
726 {success && (
727 <div class="rc-banner is-ok" role="status">
728 <span class="rc-banner-dot" aria-hidden="true" />
729 {decodeURIComponent(success)}
730 </div>
a79a9edClaude731 )}
7581253Claude732 {error && (
733 <div class="rc-banner is-error" role="alert">
734 <span class="rc-banner-dot" aria-hidden="true" />
735 {decodeURIComponent(error)}
736 </div>
737 )}
738
739 <section class={`rc-status ${statusVariant}`}>
740 <div class="rc-status-row">
741 <span class="rc-status-mark" aria-hidden="true">
742 <ChecklistIcon />
743 </span>
744 <div class="rc-status-text">
745 <h2 class="rc-status-headline">{statusHead}</h2>
746 <p class="rc-status-desc">{statusDesc}</p>
747 </div>
748 <span class="rc-pattern-pill">
749 <ShieldIcon />
750 {rule.pattern}
751 </span>
752 </div>
753 </section>
a79a9edClaude754
7581253Claude755 <section class="rc-section">
756 <header class="rc-section-head">
757 <h3 class="rc-section-title">
758 <span class="rc-section-icon" aria-hidden="true">
759 <ChecklistIcon />
760 </span>
761 Configured checks
762 </h3>
763 <p class="rc-section-sub">
764 Each check must have a passing run on the head commit before a
765 merge is allowed.
766 </p>
767 </header>
768 <div class="rc-section-body">
769 {enrichedChecks.length === 0 ? (
770 <div class="rc-empty">
771 <div class="rc-empty-orb" aria-hidden="true" />
772 <div class="rc-empty-mark" aria-hidden="true">
773 <ChecklistIcon />
774 </div>
775 <h4 class="rc-empty-title">No required checks yet</h4>
776 <p class="rc-empty-body">
777 Common names: <code>GateTest</code>, <code>AI Review</code>,{" "}
778 <code>Secret Scan</code>, <code>Type Check</code>. Add one
779 below to gate merges on it.
780 </p>
781 </div>
782 ) : (
783 <div class="rc-list">
784 {enrichedChecks.map((ch) => {
785 const lc = lightClass(ch.lastStatus.status);
786 const dur = ch.lastStatus.durationMs
787 ? `${(ch.lastStatus.durationMs / 1000).toFixed(1)}s`
788 : null;
789 return (
790 <div class="rc-check">
791 <div class="rc-check-main">
792 <span
793 class={`rc-light ${lc}`}
794 aria-label={ch.lastStatus.status ?? "no runs yet"}
795 />
796 <div class="rc-check-body">
797 <div class="rc-check-name">{ch.checkName}</div>
798 <div class="rc-check-meta">
799 <span class={`rc-check-pill ${lc}`}>
800 {ch.lastStatus.status ?? "no runs"}
801 </span>
802 {dur && <span>·</span>}
803 {dur && <span>last run {dur}</span>}
804 <span>·</span>
805 <span>{relTime(ch.lastStatus.createdAt)}</span>
806 </div>
807 </div>
808 </div>
809 <form
810 method="post"
811 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks/${ch.id}/delete`}
812 onsubmit="return confirm('Remove this required check?')"
813 >
814 <button type="submit" class="rc-btn rc-btn-danger rc-btn-sm">
815 Remove
816 </button>
817 </form>
818 </div>
819 );
820 })}
821 </div>
822 )}
823 </div>
824 </section>
825
826 <section class="rc-section">
827 <header class="rc-section-head">
828 <h3 class="rc-section-title">
829 <span class="rc-section-icon" aria-hidden="true">
830 <PlusIcon />
831 </span>
832 Add required check
833 </h3>
834 <p class="rc-section-sub">
835 Names are case-sensitive. Match exactly the gate or workflow{" "}
836 <code>name:</code>.
837 </p>
838 </header>
839 <form
840 method="post"
841 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks`}
842 class="rc-form"
843 >
844 <div class="rc-form-group">
845 <label class="rc-form-label" for="rc-check-name">Check name</label>
846 <input
847 type="text"
848 id="rc-check-name"
849 name="checkName"
850 required
851 placeholder="GateTest"
852 aria-label="Check name"
853 class="rc-input"
854 />
855 <div class="rc-form-hint">
856 Examples: <code>GateTest</code>, <code>AI Review</code>,{" "}
857 <code>Secret Scan</code>, <code>Type Check</code>.
858 </div>
859 </div>
860 <button type="submit" class="rc-btn rc-btn-primary">
861 <PlusIcon /> Add required check
862 </button>
863 </form>
864 </section>
865 </div>
866 <style dangerouslySetInnerHTML={{ __html: rcStyles }} />
a79a9edClaude867 </Layout>
868 );
869 }
870);
871
872required.post(
873 "/:owner/:repo/gates/protection/:id/checks",
874 requireAuth,
875 async (c) => {
876 const user = c.get("user")!;
877 const { owner, repo, id } = c.req.param();
878 const repoRow = await loadRepo(owner, repo);
879 if (!repoRow) return c.notFound();
880 if (repoRow.ownerId !== user.id) {
881 return c.redirect(`/${owner}/${repo}/gates`);
882 }
883 const rule = await loadRule(repoRow.id, id);
884 if (!rule) {
885 return c.redirect(
886 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
887 );
888 }
889
890 const body = await c.req.parseBody();
891 const checkName = String(body.checkName || "").trim();
892 if (!checkName) {
893 return c.redirect(
894 `/${owner}/${repo}/gates/protection/${rule.id}/checks?error=${encodeURIComponent("Name required")}`
895 );
896 }
897
898 try {
899 await db
900 .insert(branchRequiredChecks)
901 .values({ branchProtectionId: rule.id, checkName });
902 } catch (err) {
903 // Likely a unique-index collision — treat as success.
904 console.error("[required-checks] insert:", err);
905 }
906
907 await audit({
908 userId: user.id,
909 repositoryId: repoRow.id,
910 action: "branch_required_checks.create",
911 targetId: rule.id,
912 metadata: { checkName, pattern: rule.pattern },
913 });
914
915 return c.redirect(
916 `/${owner}/${repo}/gates/protection/${rule.id}/checks?success=${encodeURIComponent("Check added")}`
917 );
918 }
919);
920
921required.post(
922 "/:owner/:repo/gates/protection/:id/checks/:cid/delete",
923 requireAuth,
924 async (c) => {
925 const user = c.get("user")!;
926 const { owner, repo, id, cid } = c.req.param();
927 const repoRow = await loadRepo(owner, repo);
928 if (!repoRow) return c.notFound();
929 if (repoRow.ownerId !== user.id) {
930 return c.redirect(`/${owner}/${repo}/gates`);
931 }
932 const rule = await loadRule(repoRow.id, id);
933 if (!rule) {
934 return c.redirect(
935 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
936 );
937 }
938
939 try {
940 await db
941 .delete(branchRequiredChecks)
942 .where(
943 and(
944 eq(branchRequiredChecks.id, cid),
945 eq(branchRequiredChecks.branchProtectionId, rule.id)
946 )
947 );
948 } catch (err) {
949 console.error("[required-checks] delete:", err);
950 }
951
952 await audit({
953 userId: user.id,
954 repositoryId: repoRow.id,
955 action: "branch_required_checks.delete",
956 targetId: rule.id,
957 });
958
959 return c.redirect(
960 `/${owner}/${repo}/gates/protection/${rule.id}/checks?success=${encodeURIComponent("Check removed")}`
961 );
962 }
963);
964
965export default required;