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

gates.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.

gates.tsxBlame1472 lines · 1 contributor
3ef4c9dClaude1/**
2 * Gates UI — gate run history + branch protection settings + repo settings toggles.
3 *
4 * GET /:owner/:repo/gates — per-repo gate run history
5 * GET /:owner/:repo/gates/settings — settings toggles + branch protection (owner-only)
6 * POST /:owner/:repo/gates/settings — save toggles
7 * POST /:owner/:repo/gates/protection — save/update branch protection rule
8 * POST /:owner/:repo/gates/protection/:id/delete — remove a protection rule
9 * POST /:owner/:repo/gates/run — manually trigger a gate run on the default branch
7581253Claude10 *
11 * 2026 polish: hero + status card pattern shared with admin-integrations / admin-ops
12 * / settings-2fa. Every selector scoped under `.gates-` so it cannot bleed into any
13 * other surface. Form actions, validation, POST handlers, and audit hooks are
14 * preserved verbatim — this is a security-critical surface.
3ef4c9dClaude15 */
16
17import { Hono } from "hono";
18import { and, desc, eq } from "drizzle-orm";
19import { db } from "../db";
20import {
21 branchProtection,
22 gateRuns,
23 repoSettings,
24 repositories,
25 users,
26} from "../db/schema";
27import { Layout } from "../views/layout";
28import { RepoHeader, RepoNav } from "../views/components";
29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import { getOrCreateSettings } from "../lib/repo-bootstrap";
32import { getUnreadCount } from "../lib/unread";
33import { audit } from "../lib/notify";
34
35const gates = new Hono<AuthEnv>();
36gates.use("*", softAuth);
37
38async function loadRepo(owner: string, repo: string) {
39 const [row] = await db
40 .select({
41 id: repositories.id,
42 name: repositories.name,
43 defaultBranch: repositories.defaultBranch,
44 ownerId: repositories.ownerId,
45 starCount: repositories.starCount,
46 forkCount: repositories.forkCount,
47 })
48 .from(repositories)
49 .innerJoin(users, eq(repositories.ownerId, users.id))
50 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
51 .limit(1);
52 return row;
53}
54
55function relTime(d: Date | string): string {
56 const t = typeof d === "string" ? new Date(d) : d;
57 const diffMs = Date.now() - t.getTime();
58 const mins = Math.floor(diffMs / 60000);
59 if (mins < 1) return "just now";
60 if (mins < 60) return `${mins}m ago`;
61 const hrs = Math.floor(mins / 60);
62 if (hrs < 24) return `${hrs}h ago`;
63 const days = Math.floor(hrs / 24);
64 if (days < 30) return `${days}d ago`;
65 return t.toLocaleDateString();
66}
67
7581253Claude68/* ─────────────────────────────────────────────────────────────────────────
69 * Scoped CSS — every class prefixed `.gates-` so this surface can't bleed
70 * into other repo settings pages. Mirrors the gradient-hairline hero +
71 * status card patterns from admin-integrations.tsx / admin-ops.tsx /
72 * settings-2fa.tsx.
73 * ───────────────────────────────────────────────────────────────────── */
74const gatesStyles = `
eed4684Claude75 .gates-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-4) 0; }
7581253Claude76
77 .gates-hero {
78 position: relative;
79 margin-bottom: var(--space-5);
80 padding: var(--space-5) var(--space-6);
81 background: var(--bg-elevated);
82 border: 1px solid var(--border);
83 border-radius: 16px;
84 overflow: hidden;
85 }
86 .gates-hero::before {
87 content: '';
88 position: absolute;
89 top: 0; left: 0; right: 0;
90 height: 2px;
91 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
92 opacity: 0.7;
93 pointer-events: none;
94 }
95 .gates-hero-orb {
96 position: absolute;
97 inset: -20% -10% auto auto;
98 width: 380px; height: 380px;
99 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
100 filter: blur(80px);
101 opacity: 0.7;
102 pointer-events: none;
103 z-index: 0;
104 }
105 .gates-hero-inner {
106 position: relative;
107 z-index: 1;
108 display: flex;
109 align-items: flex-end;
110 justify-content: space-between;
111 gap: var(--space-4);
112 flex-wrap: wrap;
113 }
114 .gates-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
115 .gates-eyebrow {
116 font-size: 12px;
117 color: var(--text-muted);
118 margin-bottom: var(--space-2);
119 letter-spacing: 0.02em;
120 display: inline-flex;
121 align-items: center;
122 gap: 8px;
123 text-transform: uppercase;
124 font-family: var(--font-mono);
125 font-weight: 600;
126 }
127 .gates-eyebrow-pill {
128 display: inline-flex;
129 align-items: center;
130 justify-content: center;
131 width: 18px; height: 18px;
132 border-radius: 6px;
133 background: rgba(140,109,255,0.14);
134 color: #b69dff;
135 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
136 }
137 .gates-title {
138 font-size: clamp(26px, 3.6vw, 36px);
139 font-family: var(--font-display);
140 font-weight: 800;
141 letter-spacing: -0.028em;
142 line-height: 1.05;
143 margin: 0 0 var(--space-2);
144 color: var(--text-strong);
145 }
146 .gates-title-grad {
147 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
148 -webkit-background-clip: text;
149 background-clip: text;
150 -webkit-text-fill-color: transparent;
151 color: transparent;
152 }
153 .gates-sub {
154 font-size: 14.5px;
155 color: var(--text-muted);
156 margin: 0;
157 line-height: 1.55;
158 max-width: 620px;
159 }
160 .gates-hero-link {
161 display: inline-flex;
162 align-items: center;
163 gap: 6px;
164 padding: 7px 12px;
165 font-size: 12.5px;
166 color: var(--text-muted);
167 background: rgba(255,255,255,0.02);
168 border: 1px solid var(--border);
169 border-radius: 8px;
170 text-decoration: none;
171 font-weight: 500;
172 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
173 }
174 .gates-hero-link:hover {
175 border-color: var(--border-strong);
176 color: var(--text-strong);
177 background: rgba(255,255,255,0.04);
178 text-decoration: none;
179 }
180
181 .gates-banner {
182 margin-bottom: var(--space-4);
183 padding: 10px 14px;
184 border-radius: 10px;
185 font-size: 13.5px;
186 border: 1px solid var(--border);
187 background: rgba(255,255,255,0.025);
188 color: var(--text);
189 display: flex;
190 align-items: center;
191 gap: 10px;
192 }
193 .gates-banner.is-ok {
194 border-color: rgba(52,211,153,0.40);
195 background: rgba(52,211,153,0.08);
196 color: #bbf7d0;
197 }
198 .gates-banner-dot {
199 width: 8px; height: 8px;
200 border-radius: 9999px;
201 background: currentColor;
202 flex-shrink: 0;
203 }
204
205 /* ─── Status card (top-of-page summary) ─── */
206 .gates-status {
207 position: relative;
208 margin-bottom: var(--space-5);
209 padding: var(--space-5);
210 background: var(--bg-elevated);
211 border: 1px solid var(--border);
212 border-radius: 14px;
213 overflow: hidden;
214 }
215 .gates-status.is-on {
216 border-color: rgba(52,211,153,0.32);
217 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
218 }
219 .gates-status.is-warn {
220 border-color: rgba(248,113,113,0.32);
221 background: linear-gradient(135deg, rgba(248,113,113,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
222 }
223 .gates-status.is-empty {
224 border-color: rgba(251,191,36,0.30);
225 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
226 }
227 .gates-status-row {
228 display: flex;
229 align-items: center;
230 gap: var(--space-4);
231 flex-wrap: wrap;
232 }
233 .gates-status-mark {
234 flex-shrink: 0;
235 width: 52px; height: 52px;
236 border-radius: 14px;
237 display: flex;
238 align-items: center;
239 justify-content: center;
240 color: #fff;
241 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
242 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.18);
243 }
244 .gates-status.is-on .gates-status-mark {
245 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
246 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
247 }
248 .gates-status.is-warn .gates-status-mark {
249 background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
250 box-shadow: 0 8px 20px -8px rgba(239,68,68,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
251 }
252 .gates-status.is-empty .gates-status-mark {
253 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
254 color: #1a1206;
255 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
256 }
257 .gates-status-text { flex: 1; min-width: 220px; }
258 .gates-status-headline {
259 margin: 0 0 4px;
260 font-family: var(--font-display);
261 font-size: 18px;
262 font-weight: 700;
263 letter-spacing: -0.018em;
264 color: var(--text-strong);
265 }
266 .gates-status-desc {
267 margin: 0;
268 font-size: 13.5px;
269 color: var(--text-muted);
270 line-height: 1.5;
271 }
272 .gates-status-actions {
273 display: flex;
274 gap: 8px;
275 flex-wrap: wrap;
276 align-items: center;
277 }
278
279 /* ─── Stat grid ─── */
280 .gates-stats {
281 display: grid;
282 grid-template-columns: repeat(4, 1fr);
283 gap: 12px;
284 margin-bottom: var(--space-5);
285 }
286 @media (max-width: 640px) {
287 .gates-stats { grid-template-columns: repeat(2, 1fr); }
288 }
289 .gates-stat {
290 padding: var(--space-4);
291 background: var(--bg-elevated);
292 border: 1px solid var(--border);
293 border-radius: 12px;
294 text-align: center;
295 position: relative;
296 overflow: hidden;
297 }
298 .gates-stat::before {
299 content: '';
300 position: absolute;
301 top: 0; left: 0; right: 0;
302 height: 2px;
303 background: currentColor;
304 opacity: 0.55;
305 }
306 .gates-stat.is-pass { color: #34d399; }
307 .gates-stat.is-repaired { color: #b69dff; }
308 .gates-stat.is-fail { color: #f87171; }
309 .gates-stat.is-skipped { color: var(--text-muted); }
310 .gates-stat-num {
311 font-family: var(--font-display);
312 font-size: 26px;
313 font-weight: 800;
314 letter-spacing: -0.022em;
315 line-height: 1;
316 color: currentColor;
317 }
318 .gates-stat-label {
319 margin-top: 4px;
320 font-family: var(--font-mono);
321 font-size: 10.5px;
322 font-weight: 700;
323 letter-spacing: 0.14em;
324 text-transform: uppercase;
325 color: var(--text-muted);
326 }
327
328 /* ─── Section card ─── */
329 .gates-section {
330 margin-bottom: var(--space-5);
331 background: var(--bg-elevated);
332 border: 1px solid var(--border);
333 border-radius: 14px;
334 overflow: hidden;
335 }
336 .gates-section-head {
337 padding: var(--space-4) var(--space-5);
338 border-bottom: 1px solid var(--border);
339 }
340 .gates-section-title {
341 margin: 0;
342 font-family: var(--font-display);
343 font-size: 16px;
344 font-weight: 700;
345 letter-spacing: -0.018em;
346 color: var(--text-strong);
347 display: flex;
348 align-items: center;
349 gap: 10px;
350 }
351 .gates-section-icon {
352 display: inline-flex;
353 align-items: center;
354 justify-content: center;
355 width: 26px; height: 26px;
356 border-radius: 8px;
357 background: rgba(140,109,255,0.12);
358 color: #b69dff;
359 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
360 flex-shrink: 0;
361 }
362 .gates-section-sub {
363 margin: 6px 0 0 36px;
364 font-size: 12.5px;
365 color: var(--text-muted);
366 line-height: 1.5;
367 }
368 .gates-section-body { padding: var(--space-4) var(--space-5); }
369
370 /* ─── Gate run rows ─── */
371 .gates-list {
372 display: flex;
373 flex-direction: column;
374 gap: 8px;
375 }
376 .gates-run {
377 display: flex;
378 align-items: flex-start;
379 gap: var(--space-3);
380 padding: var(--space-4);
381 background: var(--bg-elevated);
382 border: 1px solid var(--border);
383 border-radius: 12px;
384 transition: border-color 140ms ease, transform 140ms ease;
385 }
386 .gates-run:hover {
387 border-color: rgba(140,109,255,0.30);
388 transform: translateY(-1px);
389 }
390 .gates-run-light {
391 flex-shrink: 0;
392 margin-top: 6px;
393 width: 10px; height: 10px;
394 border-radius: 9999px;
395 background: #6b7280;
396 box-shadow: 0 0 0 3px rgba(107,114,128,0.18);
397 }
398 .gates-run-light.is-pass { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.40); }
399 .gates-run-light.is-fail { background: #f87171; box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.45); }
400 .gates-run-light.is-repaired { background: #b69dff; box-shadow: 0 0 0 3px rgba(140,109,255,0.22), 0 0 8px rgba(140,109,255,0.40); }
401 .gates-run-body { flex: 1; min-width: 0; }
402 .gates-run-head {
403 display: flex;
404 align-items: center;
405 gap: 10px;
406 flex-wrap: wrap;
407 }
408 .gates-run-name {
409 font-weight: 600;
410 color: var(--text-strong);
411 font-size: 14px;
412 }
413 .gates-run-pill {
414 display: inline-flex;
415 align-items: center;
416 gap: 5px;
417 padding: 2px 8px;
418 font-size: 10.5px;
419 font-weight: 700;
420 text-transform: uppercase;
421 letter-spacing: 0.06em;
422 border-radius: 9999px;
423 }
424 .gates-run-pill.is-pass { background: rgba(52,211,153,0.14); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
425 .gates-run-pill.is-fail { background: rgba(248,113,113,0.14); color: #fecaca; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30); }
426 .gates-run-pill.is-repaired { background: rgba(140,109,255,0.14); color: #c4b5fd; box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30); }
427 .gates-run-pill.is-skipped { background: rgba(255,255,255,0.04); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
428 .gates-run-meta {
429 margin-top: 4px;
430 font-size: 12px;
431 color: var(--text-muted);
432 display: flex;
433 flex-wrap: wrap;
434 gap: 8px;
435 align-items: center;
436 }
437 .gates-run-meta a { color: var(--accent); text-decoration: none; }
438 .gates-run-meta a:hover { text-decoration: underline; }
439 .gates-run-meta code {
440 font-family: var(--font-mono);
441 font-size: 11.5px;
442 background: rgba(255,255,255,0.04);
443 border: 1px solid var(--border);
444 padding: 1px 6px;
445 border-radius: 5px;
446 color: var(--text);
447 }
448 .gates-run-summary {
449 margin-top: 6px;
450 font-size: 12.5px;
451 color: var(--text-muted);
452 line-height: 1.5;
453 }
454 .gates-run-repair {
455 margin-top: 6px;
456 font-size: 12px;
457 color: #c4b5fd;
458 }
459 .gates-run-repair a { color: inherit; text-decoration: underline; }
460
461 /* ─── Empty state ─── */
462 .gates-empty {
463 padding: var(--space-6);
464 text-align: center;
465 background: var(--bg-elevated);
466 border: 1px dashed var(--border-strong);
467 border-radius: 14px;
468 position: relative;
469 overflow: hidden;
470 }
471 .gates-empty-orb {
472 position: absolute;
473 inset: -30% auto auto -10%;
474 width: 260px; height: 260px;
475 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
476 filter: blur(60px);
477 opacity: 0.8;
478 pointer-events: none;
479 }
480 .gates-empty-mark {
481 position: relative;
482 z-index: 1;
483 margin: 0 auto var(--space-3);
484 width: 52px; height: 52px;
485 border-radius: 14px;
486 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
487 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
488 display: flex;
489 align-items: center;
490 justify-content: center;
491 color: #c4b5fd;
492 }
493 .gates-empty-title {
494 position: relative;
495 z-index: 1;
496 margin: 0 0 6px;
497 font-family: var(--font-display);
498 font-size: 17px;
499 font-weight: 700;
500 color: var(--text-strong);
501 letter-spacing: -0.018em;
502 }
503 .gates-empty-body {
504 position: relative;
505 z-index: 1;
506 margin: 0 auto;
507 max-width: 440px;
508 font-size: 13.5px;
509 color: var(--text-muted);
510 line-height: 1.55;
511 }
512
513 /* ─── Toggle list (settings page) ─── */
514 .gates-toggles {
515 display: flex;
516 flex-direction: column;
517 }
518 .gates-toggle {
519 display: flex;
520 align-items: flex-start;
521 gap: 14px;
522 padding: 14px 16px;
523 border-top: 1px solid var(--border);
524 cursor: pointer;
525 transition: background 120ms ease;
526 }
527 .gates-toggle:first-child { border-top: none; }
528 .gates-toggle:hover { background: rgba(140,109,255,0.04); }
529 .gates-toggle-input {
530 margin-top: 3px;
531 flex-shrink: 0;
532 width: 18px; height: 18px;
533 accent-color: #8c6dff;
534 cursor: pointer;
535 }
536 .gates-toggle-text { flex: 1; min-width: 0; }
537 .gates-toggle-name {
538 font-weight: 600;
539 color: var(--text-strong);
540 font-size: 14px;
541 }
542 .gates-toggle-desc {
543 margin-top: 3px;
544 font-size: 12.5px;
545 color: var(--text-muted);
546 line-height: 1.5;
547 }
548
549 /* ─── Protection rule cards ─── */
550 .gates-rule {
551 padding: var(--space-4);
552 background: var(--bg-elevated);
553 border: 1px solid var(--border);
554 border-radius: 12px;
555 margin-bottom: 10px;
556 transition: border-color 140ms ease;
557 }
558 .gates-rule:hover { border-color: rgba(140,109,255,0.28); }
559 .gates-rule-head {
560 display: flex;
561 align-items: center;
562 justify-content: space-between;
563 gap: var(--space-3);
564 flex-wrap: wrap;
565 }
566 .gates-rule-pattern {
567 font-family: var(--font-mono);
568 font-size: 13px;
569 color: var(--text-strong);
570 background: rgba(140,109,255,0.10);
571 border: 1px solid rgba(140,109,255,0.30);
572 padding: 3px 10px;
573 border-radius: 8px;
574 font-weight: 600;
575 }
576 .gates-chips {
577 margin-top: 10px;
578 display: flex;
579 flex-wrap: wrap;
580 gap: 6px;
581 }
582 .gates-chip {
583 display: inline-flex;
584 align-items: center;
585 gap: 5px;
586 padding: 2px 9px;
587 font-size: 11px;
588 font-weight: 600;
589 border-radius: 9999px;
590 background: rgba(255,255,255,0.04);
591 color: var(--text);
592 border: 1px solid var(--border);
593 }
594 .gates-chip.is-on {
595 background: rgba(52,211,153,0.12);
596 color: #6ee7b7;
597 border-color: rgba(52,211,153,0.30);
598 }
599 .gates-chip.is-warn {
600 background: rgba(251,191,36,0.10);
601 color: #fde68a;
602 border-color: rgba(251,191,36,0.30);
603 }
604 .gates-rule-actions {
605 display: flex;
606 gap: 6px;
607 align-items: center;
608 flex-wrap: wrap;
609 }
610 .gates-rule-actions form { margin: 0; }
611
612 /* ─── Form card ─── */
613 .gates-form {
614 padding: var(--space-5);
615 }
616 .gates-form-group { margin-bottom: var(--space-4); }
617 .gates-form-group:last-child { margin-bottom: 0; }
618 .gates-form-label {
619 display: block;
620 font-family: var(--font-mono);
621 font-size: 11.5px;
622 font-weight: 700;
623 text-transform: uppercase;
624 letter-spacing: 0.12em;
625 color: var(--text-muted);
626 margin-bottom: 6px;
627 }
628 .gates-input,
629 .gates-select {
630 width: 100%;
631 padding: 9px 12px;
632 font-size: 13.5px;
633 color: var(--text);
634 background: var(--bg);
635 border: 1px solid var(--border-strong);
636 border-radius: 8px;
637 outline: none;
638 font-family: var(--font-mono);
639 transition: border-color 120ms ease, box-shadow 120ms ease;
640 box-sizing: border-box;
641 }
642 .gates-input:focus,
643 .gates-select:focus {
644 border-color: var(--border-focus, rgba(140,109,255,0.55));
645 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
646 }
647 .gates-number {
648 width: 80px;
649 text-align: center;
650 }
651 .gates-checkrow {
652 display: flex;
653 flex-wrap: wrap;
654 gap: 10px;
655 }
656 .gates-checkbox {
657 display: inline-flex;
658 align-items: center;
659 gap: 7px;
660 padding: 7px 11px;
661 background: rgba(255,255,255,0.025);
662 border: 1px solid var(--border);
663 border-radius: 8px;
664 font-size: 12.5px;
665 color: var(--text);
666 cursor: pointer;
667 transition: border-color 120ms ease, background 120ms ease;
668 }
669 .gates-checkbox:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); }
670 .gates-checkbox input { accent-color: #8c6dff; cursor: pointer; }
671 .gates-checkbox.is-num { gap: 8px; padding: 4px 8px 4px 11px; }
672
673 /* ─── Buttons ─── */
674 .gates-btn {
675 display: inline-flex;
676 align-items: center;
677 gap: 6px;
678 padding: 9px 16px;
679 border-radius: 10px;
680 font-size: 13px;
681 font-weight: 600;
682 text-decoration: none;
683 border: 1px solid transparent;
684 cursor: pointer;
685 font-family: inherit;
686 line-height: 1;
687 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
688 }
689 .gates-btn-primary {
690 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
691 color: #fff;
692 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
693 }
694 .gates-btn-primary:hover {
695 transform: translateY(-1px);
696 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
697 color: #fff;
698 text-decoration: none;
699 }
700 .gates-btn-ghost {
701 background: rgba(255,255,255,0.025);
702 color: var(--text);
703 border-color: var(--border-strong);
704 }
705 .gates-btn-ghost:hover {
706 background: rgba(140,109,255,0.06);
707 border-color: rgba(140,109,255,0.45);
708 color: var(--text-strong);
709 text-decoration: none;
710 }
711 .gates-btn-danger {
712 background: transparent;
713 color: #fecaca;
714 border-color: rgba(248,113,113,0.40);
715 }
716 .gates-btn-danger:hover {
717 background: rgba(248,113,113,0.10);
718 border-color: rgba(248,113,113,0.65);
719 color: #fee2e2;
720 text-decoration: none;
721 }
722 .gates-btn-sm { padding: 6px 11px; font-size: 12px; }
723`;
724
725/* ─── Inline icons (decorative — aria-hidden) ─── */
726const ShieldGlyph = () => (
727 <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">
728 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
729 <polyline points="9 12 11 14 15 10" />
730 </svg>
731);
732const WarnGlyph = () => (
733 <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">
734 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
735 <line x1="12" y1="9" x2="12" y2="13" />
736 <line x1="12" y1="17" x2="12.01" y2="17" />
737 </svg>
738);
739const EmptyGlyph = () => (
740 <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">
741 <circle cx="12" cy="12" r="10" />
742 <line x1="12" y1="8" x2="12" y2="12" />
743 <line x1="12" y1="16" x2="12.01" y2="16" />
744 </svg>
745);
746const CogGlyph = () => (
747 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
748 <circle cx="12" cy="12" r="3" />
749 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
750 </svg>
751);
752const ArrowLeft = () => (
753 <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">
754 <line x1="19" y1="12" x2="5" y2="12" />
755 <polyline points="12 19 5 12 12 5" />
756 </svg>
757);
758const PlayGlyph = () => (
759 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
760 <polygon points="5 3 19 12 5 21 5 3" />
761 </svg>
762);
763const LockGlyph = () => (
764 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
765 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
766 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
767 </svg>
768);
769const SparkleGlyph = () => (
770 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
771 <path d="M12 2l1.6 5.2L19 9l-5.4 1.8L12 16l-1.6-5.2L5 9l5.4-1.8L12 2z" />
772 </svg>
773);
774const RocketGlyph = () => (
775 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
776 <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
777 <path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
778 </svg>
779);
780const HammerGlyph = () => (
781 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
782 <path d="M14 7l5 5-9 9-5-5 9-9z" />
783 <path d="M17 4l3 3" />
784 </svg>
785);
786
787function statusClass(status: string): "is-pass" | "is-fail" | "is-repaired" | "is-skipped" {
788 if (status === "passed") return "is-pass";
789 if (status === "failed") return "is-fail";
790 if (status === "repaired") return "is-repaired";
791 return "is-skipped";
792}
793
3ef4c9dClaude794// ---------- Gate run history ----------
795
796gates.get("/:owner/:repo/gates", async (c) => {
797 const user = c.get("user");
798 const { owner, repo } = c.req.param();
799 const repoRow = await loadRepo(owner, repo);
800 if (!repoRow) return c.notFound();
801
802 const runs = await db
803 .select()
804 .from(gateRuns)
805 .where(eq(gateRuns.repositoryId, repoRow.id))
806 .orderBy(desc(gateRuns.createdAt))
807 .limit(100);
808
809 const unread = user ? await getUnreadCount(user.id) : 0;
810 const total = runs.length;
811 const passed = runs.filter((r) => r.status === "passed").length;
812 const failed = runs.filter((r) => r.status === "failed").length;
813 const repaired = runs.filter((r) => r.status === "repaired").length;
814 const skipped = runs.filter((r) => r.status === "skipped").length;
815
7581253Claude816 const lastRun = runs[0];
817 let statusVariant: "is-on" | "is-warn" | "is-empty" = "is-empty";
818 let statusHead = "No gate runs yet";
819 let statusDesc = "Push a commit to trigger the full green ecosystem — every enabled gate runs automatically.";
820 let statusMark = <EmptyGlyph />;
821 if (lastRun) {
822 if (lastRun.status === "passed" || lastRun.status === "repaired") {
823 statusVariant = "is-on";
824 statusHead = `All clear · ${total} run${total === 1 ? "" : "s"} on file`;
825 statusDesc = `Latest: ${lastRun.gateName} ${lastRun.status} on ${lastRun.commitSha.slice(0, 7)} (${relTime(lastRun.createdAt)}).`;
826 statusMark = <ShieldGlyph />;
827 } else if (lastRun.status === "failed") {
828 statusVariant = "is-warn";
829 statusHead = `${failed} failing · ${total} total`;
830 statusDesc = `Latest: ${lastRun.gateName} failed on ${lastRun.commitSha.slice(0, 7)} (${relTime(lastRun.createdAt)}).`;
831 statusMark = <WarnGlyph />;
832 } else {
833 statusVariant = "is-empty";
834 statusHead = `${total} run${total === 1 ? "" : "s"} recorded`;
835 statusDesc = `Latest: ${lastRun.gateName} ${lastRun.status} on ${lastRun.commitSha.slice(0, 7)} (${relTime(lastRun.createdAt)}).`;
836 }
837 }
838
3ef4c9dClaude839 return c.html(
840 <Layout
841 title={`Gates — ${owner}/${repo}`}
842 user={user}
843 notificationCount={unread}
844 >
845 <RepoHeader
846 owner={owner}
847 repo={repo}
848 starCount={repoRow.starCount}
849 forkCount={repoRow.forkCount}
850 currentUser={user?.username || null}
851 />
852 <RepoNav owner={owner} repo={repo} active="gates" />
853
7581253Claude854 <div class="gates-wrap">
855 <section class="gates-hero">
856 <div class="gates-hero-orb" aria-hidden="true" />
857 <div class="gates-hero-inner">
858 <div class="gates-hero-text">
859 <div class="gates-eyebrow">
860 <span class="gates-eyebrow-pill" aria-hidden="true">
861 <ShieldGlyph />
862 </span>
863 Gates · {owner}/{repo}
864 </div>
865 <h1 class="gates-title">
866 <span class="gates-title-grad">Push-time guards.</span>
867 </h1>
868 <p class="gates-sub">
869 Every gate that ran against this repository — passing, failing,
870 or auto-repaired. Configure which gates are enforced under
871 settings.
872 </p>
873 </div>
874 {user && user.id === repoRow.ownerId && (
875 <a
876 href={`/${owner}/${repo}/gates/settings`}
877 class="gates-hero-link"
878 >
879 <CogGlyph /> Settings
880 </a>
881 )}
882 </div>
883 </section>
3ef4c9dClaude884
7581253Claude885 <section class={`gates-status ${statusVariant}`}>
886 <div class="gates-status-row">
887 <span class="gates-status-mark" aria-hidden="true">
888 {statusMark}
889 </span>
890 <div class="gates-status-text">
891 <h2 class="gates-status-headline">{statusHead}</h2>
892 <p class="gates-status-desc">{statusDesc}</p>
893 </div>
894 </div>
895 </section>
896
897 <div class="gates-stats">
898 <div class="gates-stat is-pass">
899 <div class="gates-stat-num">{passed}</div>
900 <div class="gates-stat-label">Passed</div>
901 </div>
902 <div class="gates-stat is-repaired">
903 <div class="gates-stat-num">{repaired}</div>
904 <div class="gates-stat-label">Repaired</div>
905 </div>
906 <div class="gates-stat is-fail">
907 <div class="gates-stat-num">{failed}</div>
908 <div class="gates-stat-label">Failed</div>
909 </div>
910 <div class="gates-stat is-skipped">
911 <div class="gates-stat-num">{skipped}</div>
912 <div class="gates-stat-label">Skipped</div>
913 </div>
3ef4c9dClaude914 </div>
7581253Claude915
916 {total === 0 ? (
917 <div class="gates-empty">
918 <div class="gates-empty-orb" aria-hidden="true" />
919 <div class="gates-empty-mark" aria-hidden="true">
920 <ShieldGlyph />
921 </div>
922 <h3 class="gates-empty-title">No gate runs yet</h3>
923 <p class="gates-empty-body">
924 Gates fire automatically on every push. Configure which gates are
925 enabled in settings, then push a commit to see the green light
926 show up here.
927 </p>
928 </div>
929 ) : (
930 <div class="gates-list">
931 {runs.map((r) => (
932 <div class="gates-run">
933 <span class={`gates-run-light ${statusClass(r.status)}`} aria-hidden="true" />
934 <div class="gates-run-body">
935 <div class="gates-run-head">
936 <span class="gates-run-name">{r.gateName}</span>
937 <span class={`gates-run-pill ${statusClass(r.status)}`}>
938 {r.status}
939 </span>
3ef4c9dClaude940 </div>
7581253Claude941 <div class="gates-run-meta">
942 <a href={`/${owner}/${repo}/commit/${r.commitSha}`}>
943 <code>{r.commitSha.slice(0, 7)}</code>
3ef4c9dClaude944 </a>
7581253Claude945 <span>·</span>
946 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
947 <span>·</span>
948 <span title={typeof r.createdAt === "string" ? r.createdAt : (r.createdAt as Date).toISOString()}>
949 {relTime(r.createdAt)}
950 </span>
951 {r.durationMs ? (
952 <>
953 <span>·</span>
954 <span>{(r.durationMs / 1000).toFixed(1)}s</span>
955 </>
956 ) : null}
3ef4c9dClaude957 </div>
7581253Claude958 {r.summary && <div class="gates-run-summary">{r.summary}</div>}
959 {r.repairCommitSha && (
960 <div class="gates-run-repair">
961 Auto-repaired in{" "}
962 <a href={`/${owner}/${repo}/commit/${r.repairCommitSha}`}>
963 {r.repairCommitSha.slice(0, 7)}
964 </a>
965 </div>
966 )}
967 </div>
3ef4c9dClaude968 </div>
7581253Claude969 ))}
970 </div>
971 )}
972 </div>
973 <style dangerouslySetInnerHTML={{ __html: gatesStyles }} />
3ef4c9dClaude974 </Layout>
975 );
976});
977
978// ---------- Settings UI ----------
979
980gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
981 const user = c.get("user")!;
982 const { owner, repo } = c.req.param();
983 const repoRow = await loadRepo(owner, repo);
984 if (!repoRow) return c.notFound();
985 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
986
987 const settings = await getOrCreateSettings(repoRow.id);
988 const protections = await db
989 .select()
990 .from(branchProtection)
991 .where(eq(branchProtection.repositoryId, repoRow.id));
992
993 const unread = await getUnreadCount(user.id);
994 const success = c.req.query("success");
995
7581253Claude996 // Count of enabled gates for the status card
997 const enabledGateCount = [
998 settings!.gateTestEnabled,
999 settings!.aiReviewEnabled,
1000 settings!.secretScanEnabled,
1001 settings!.securityScanEnabled,
1002 settings!.dependencyScanEnabled,
1003 settings!.lintEnabled,
1004 settings!.typeCheckEnabled,
1005 settings!.testEnabled,
1006 ].filter(Boolean).length;
1007 const totalGateCount = 8;
1008
1009 const statusVariant = enabledGateCount === 0 ? "is-warn" : "is-on";
1010 const statusHead =
1011 enabledGateCount === 0
1012 ? "No gates active"
1013 : `${enabledGateCount} of ${totalGateCount} gates active`;
1014 const statusDesc =
1015 enabledGateCount === 0
1016 ? "Pushes are accepted without any push-time validation. Enable at least one gate below."
1017 : `${protections.length} branch protection rule${protections.length === 1 ? "" : "s"} · auto-repair ${settings!.autoFixEnabled ? "on" : "off"}.`;
1018
1019 const toggle = (
1020 name: string,
1021 label: string,
1022 checked: boolean,
1023 desc?: string
1024 ) => (
1025 <label class="gates-toggle">
1026 <input
1027 type="checkbox"
1028 name={name}
1029 value="1"
1030 checked={checked}
1031 aria-label={label}
1032 class="gates-toggle-input"
1033 />
1034 <div class="gates-toggle-text">
1035 <div class="gates-toggle-name">{label}</div>
1036 {desc && <div class="gates-toggle-desc">{desc}</div>}
3ef4c9dClaude1037 </div>
1038 </label>
1039 );
1040
1041 return c.html(
1042 <Layout
1043 title={`Gate settings — ${owner}/${repo}`}
1044 user={user}
1045 notificationCount={unread}
1046 >
1047 <RepoHeader
1048 owner={owner}
1049 repo={repo}
1050 starCount={repoRow.starCount}
1051 forkCount={repoRow.forkCount}
1052 currentUser={user.username}
1053 />
1054 <RepoNav owner={owner} repo={repo} active="gates" />
7581253Claude1055
1056 <div class="gates-wrap">
1057 <section class="gates-hero">
1058 <div class="gates-hero-orb" aria-hidden="true" />
1059 <div class="gates-hero-inner">
1060 <div class="gates-hero-text">
1061 <div class="gates-eyebrow">
1062 <span class="gates-eyebrow-pill" aria-hidden="true">
1063 <ShieldGlyph />
1064 </span>
1065 Gate settings · {owner}/{repo}
1066 </div>
1067 <h1 class="gates-title">
1068 <span class="gates-title-grad">Wire the gates.</span>
1069 </h1>
1070 <p class="gates-sub">
1071 Toggle which gates run on every push, configure auto-repair, and
1072 lock down release branches with protection rules.
1073 </p>
1074 </div>
1075 <a href={`/${owner}/${repo}/gates`} class="gates-hero-link">
1076 <ArrowLeft /> Back to runs
1077 </a>
3ef4c9dClaude1078 </div>
7581253Claude1079 </section>
3ef4c9dClaude1080
7581253Claude1081 {success && (
1082 <div class="gates-banner is-ok" role="status">
1083 <span class="gates-banner-dot" aria-hidden="true" />
1084 {decodeURIComponent(success)}
3ef4c9dClaude1085 </div>
7581253Claude1086 )}
3ef4c9dClaude1087
7581253Claude1088 <section class={`gates-status ${statusVariant}`}>
1089 <div class="gates-status-row">
1090 <span class="gates-status-mark" aria-hidden="true">
1091 {enabledGateCount === 0 ? <WarnGlyph /> : <ShieldGlyph />}
1092 </span>
1093 <div class="gates-status-text">
1094 <h2 class="gates-status-headline">{statusHead}</h2>
1095 <p class="gates-status-desc">{statusDesc}</p>
1096 </div>
3ef4c9dClaude1097 </div>
7581253Claude1098 </section>
1099
1100 <form method="post" action={`/${owner}/${repo}/gates/settings`}>
1101 <section class="gates-section">
1102 <header class="gates-section-head">
1103 <h3 class="gates-section-title">
1104 <span class="gates-section-icon" aria-hidden="true">
1105 <ShieldGlyph />
1106 </span>
1107 Gates
1108 </h3>
1109 <p class="gates-section-sub">
1110 Every push runs the gates checked here. Failed gates can
1111 optionally auto-repair below.
1112 </p>
1113 </header>
1114 <div class="gates-toggles">
1115 {toggle("gateTestEnabled", "GateTest scan", settings!.gateTestEnabled, "External test/lint runner")}
1116 {toggle("aiReviewEnabled", "AI code review", settings!.aiReviewEnabled, "Claude reviews every PR")}
1117 {toggle("secretScanEnabled", "Secret scan", settings!.secretScanEnabled, "Regex + AI secret detection on every push")}
1118 {toggle("securityScanEnabled", "Security scan", settings!.securityScanEnabled, "Claude-powered semantic security review")}
1119 {toggle("dependencyScanEnabled", "Dependency scan", settings!.dependencyScanEnabled, "Vulnerability scanning on lockfiles")}
1120 {toggle("lintEnabled", "Lint", settings!.lintEnabled, "Auto-lint every push")}
1121 {toggle("typeCheckEnabled", "Type check", settings!.typeCheckEnabled)}
1122 {toggle("testEnabled", "Tests", settings!.testEnabled, "Run your test suite on every push")}
1123 </div>
1124 </section>
1125
1126 <section class="gates-section">
1127 <header class="gates-section-head">
1128 <h3 class="gates-section-title">
1129 <span class="gates-section-icon" aria-hidden="true">
1130 <HammerGlyph />
1131 </span>
1132 Auto-repair
1133 </h3>
1134 <p class="gates-section-sub">
1135 Let Claude attempt a fix before pinging a human.
1136 </p>
1137 </header>
1138 <div class="gates-toggles">
1139 {toggle("autoFixEnabled", "Auto-fix failing gates", settings!.autoFixEnabled, "Claude attempts a fix before a human is pinged")}
1140 {toggle("autoMergeResolveEnabled", "Auto-resolve merge conflicts", settings!.autoMergeResolveEnabled)}
1141 {toggle("autoFormatEnabled", "Auto-format on commit", settings!.autoFormatEnabled)}
1142 </div>
1143 </section>
1144
1145 <section class="gates-section">
1146 <header class="gates-section-head">
1147 <h3 class="gates-section-title">
1148 <span class="gates-section-icon" aria-hidden="true">
1149 <SparkleGlyph />
1150 </span>
1151 AI features
1152 </h3>
1153 <p class="gates-section-sub">
1154 Optional AI assistance for human-facing artefacts.
1155 </p>
1156 </header>
1157 <div class="gates-toggles">
1158 {toggle("aiCommitMessagesEnabled", "AI commit messages", settings!.aiCommitMessagesEnabled)}
1159 {toggle("aiPrSummaryEnabled", "AI PR summaries", settings!.aiPrSummaryEnabled)}
1160 {toggle("aiChangelogEnabled", "AI release changelogs", settings!.aiChangelogEnabled)}
1161 </div>
1162 </section>
3ef4c9dClaude1163
7581253Claude1164 <section class="gates-section">
1165 <header class="gates-section-head">
1166 <h3 class="gates-section-title">
1167 <span class="gates-section-icon" aria-hidden="true">
1168 <RocketGlyph />
1169 </span>
1170 Deploy
1171 </h3>
1172 <p class="gates-section-sub">
1173 Control whether green pushes deploy themselves.
1174 </p>
1175 </header>
1176 <div class="gates-toggles">
1177 {toggle("autoDeployEnabled", "Auto-deploy on green pushes to default branch", settings!.autoDeployEnabled)}
1178 {toggle("deployRequireAllGreen", "Block deploys unless all gates are green", settings!.deployRequireAllGreen)}
1179 </div>
1180 </section>
1181
1182 <div style="display:flex;gap:8px;align-items:center;margin-bottom:var(--space-5)">
1183 <button type="submit" class="gates-btn gates-btn-primary">
1184 <PlayGlyph /> Save settings
1185 </button>
1186 <span style="font-size:12px;color:var(--text-muted)">
1187 Changes apply on the next push.
1188 </span>
3ef4c9dClaude1189 </div>
7581253Claude1190 </form>
3ef4c9dClaude1191
7581253Claude1192 <section class="gates-section">
1193 <header class="gates-section-head">
1194 <h3 class="gates-section-title">
1195 <span class="gates-section-icon" aria-hidden="true">
1196 <LockGlyph />
1197 </span>
1198 Branch protection
1199 </h3>
1200 <p class="gates-section-sub">
1201 The default branch is protected on every new repo. Add extra rules
1202 for release branches — required PRs, green gates, AI approval,
1203 human reviewers, no force push.
1204 </p>
1205 </header>
1206 <div class="gates-section-body">
1207 {protections.length === 0 ? (
1208 <div class="gates-empty" style="padding:var(--space-5)">
1209 <div class="gates-empty-mark" aria-hidden="true">
1210 <LockGlyph />
3ef4c9dClaude1211 </div>
7581253Claude1212 <h4 class="gates-empty-title">No protection rules yet</h4>
1213 <p class="gates-empty-body">
1214 Add a rule below to gate merges into <code>main</code>,{" "}
1215 <code>release/*</code>, or any glob you choose.
1216 </p>
3ef4c9dClaude1217 </div>
7581253Claude1218 ) : (
1219 protections.map((p) => (
1220 <div class="gates-rule">
1221 <div class="gates-rule-head">
1222 <span class="gates-rule-pattern">{p.pattern}</span>
1223 <div class="gates-rule-actions">
1224 <a
1225 href={`/${owner}/${repo}/gates/protection/${p.id}/checks`}
1226 class="gates-btn gates-btn-ghost gates-btn-sm"
1227 title="Manage required status checks for this rule"
1228 >
1229 Required checks
1230 </a>
1231 <form
1232 method="post"
1233 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
1234 onsubmit="return confirm('Remove this rule?')"
1235 >
1236 <button type="submit" class="gates-btn gates-btn-danger gates-btn-sm">
1237 Remove
1238 </button>
1239 </form>
1240 </div>
1241 </div>
1242 <div class="gates-chips">
1243 {p.requirePullRequest && <span class="gates-chip is-on">PR required</span>}
1244 {p.requireGreenGates && <span class="gates-chip is-on">Green gates</span>}
1245 {p.requireAiApproval && <span class="gates-chip is-on">AI approval</span>}
1246 {p.requireHumanReview && (
1247 <span class="gates-chip is-on">
1248 {p.requiredApprovals} human approval{p.requiredApprovals === 1 ? "" : "s"}
1249 </span>
1250 )}
1251 {p.enableAutoMerge && <span class="gates-chip is-on">AI auto-merge</span>}
1252 {!p.allowForcePush && <span class="gates-chip">No force push</span>}
1253 {!p.allowDeletion && <span class="gates-chip">No deletion</span>}
1254 {p.allowForcePush && <span class="gates-chip is-warn">Force push allowed</span>}
1255 {p.allowDeletion && <span class="gates-chip is-warn">Deletion allowed</span>}
1256 </div>
1257 </div>
1258 ))
1259 )}
1260 </div>
1261 </section>
1262
1263 <section class="gates-section">
1264 <header class="gates-section-head">
1265 <h3 class="gates-section-title">
1266 <span class="gates-section-icon" aria-hidden="true">
1267 <PlayGlyph />
1268 </span>
1269 Add protection rule
1270 </h3>
1271 <p class="gates-section-sub">
1272 Pattern matches branch names (supports globs like{" "}
1273 <code>release/*</code>).
1274 </p>
1275 </header>
1276 <form
1277 method="post"
1278 action={`/${owner}/${repo}/gates/protection`}
1279 class="gates-form"
1280 >
1281 <div class="gates-form-group">
1282 <label class="gates-form-label" for="gates-pattern">Pattern</label>
1283 <input
1284 type="text"
1285 name="pattern"
1286 id="gates-pattern"
1287 required
1288 placeholder="release/* or main"
1289 aria-label="Branch protection pattern"
1290 class="gates-input"
1291 />
1292 </div>
1293 <div class="gates-form-group">
1294 <label class="gates-form-label">Requirements</label>
1295 <div class="gates-checkrow">
1296 <label class="gates-checkbox">
1297 <input type="checkbox" name="requirePullRequest" value="1" checked />
1298 Require PR
1299 </label>
1300 <label class="gates-checkbox">
1301 <input type="checkbox" name="requireGreenGates" value="1" checked />
1302 Require green gates
1303 </label>
1304 <label class="gates-checkbox">
1305 <input type="checkbox" name="requireAiApproval" value="1" checked />
1306 Require AI approval
1307 </label>
1308 <label class="gates-checkbox">
1309 <input type="checkbox" name="requireHumanReview" value="1" />
1310 Require human review
1311 </label>
1312 <label class="gates-checkbox is-num">
1313 Approvals
1314 <input
1315 type="number"
1316 name="requiredApprovals"
1317 min="0"
1318 max="10"
1319 value="1"
1320 class="gates-input gates-number"
1321 />
1322 </label>
1323 <label class="gates-checkbox">
1324 <input type="checkbox" name="allowForcePush" value="1" />
1325 Allow force push
1326 </label>
1327 <label class="gates-checkbox">
1328 <input type="checkbox" name="allowDeletion" value="1" />
1329 Allow deletion
1330 </label>
1331 <label
1332 class="gates-checkbox"
1333 title="K2 — Let the autopilot ticker auto-merge PRs that pass every gate this rule enforces."
a79a9edClaude1334 >
7581253Claude1335 <input type="checkbox" name="enableAutoMerge" value="1" />
1336 Enable AI auto-merge
1337 </label>
a79a9edClaude1338 </div>
3ef4c9dClaude1339 </div>
7581253Claude1340 <button type="submit" class="gates-btn gates-btn-primary">
1341 <LockGlyph /> Add rule
1342 </button>
1343 </form>
1344 </section>
3ef4c9dClaude1345 </div>
7581253Claude1346 <style dangerouslySetInnerHTML={{ __html: gatesStyles }} />
3ef4c9dClaude1347 </Layout>
1348 );
1349});
1350
1351gates.post("/:owner/:repo/gates/settings", requireAuth, async (c) => {
1352 const user = c.get("user")!;
1353 const { owner, repo } = c.req.param();
1354 const repoRow = await loadRepo(owner, repo);
1355 if (!repoRow) return c.notFound();
1356 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
1357
1358 const body = await c.req.parseBody();
1359 const b = (k: string) => body[k] === "1" || body[k] === "on";
1360
1361 try {
1362 await db
1363 .update(repoSettings)
1364 .set({
1365 gateTestEnabled: b("gateTestEnabled"),
1366 aiReviewEnabled: b("aiReviewEnabled"),
1367 secretScanEnabled: b("secretScanEnabled"),
1368 securityScanEnabled: b("securityScanEnabled"),
1369 dependencyScanEnabled: b("dependencyScanEnabled"),
1370 lintEnabled: b("lintEnabled"),
1371 typeCheckEnabled: b("typeCheckEnabled"),
1372 testEnabled: b("testEnabled"),
1373 autoFixEnabled: b("autoFixEnabled"),
1374 autoMergeResolveEnabled: b("autoMergeResolveEnabled"),
1375 autoFormatEnabled: b("autoFormatEnabled"),
1376 aiCommitMessagesEnabled: b("aiCommitMessagesEnabled"),
1377 aiPrSummaryEnabled: b("aiPrSummaryEnabled"),
1378 aiChangelogEnabled: b("aiChangelogEnabled"),
1379 autoDeployEnabled: b("autoDeployEnabled"),
1380 deployRequireAllGreen: b("deployRequireAllGreen"),
1381 updatedAt: new Date(),
1382 })
1383 .where(eq(repoSettings.repositoryId, repoRow.id));
1384 } catch (err) {
1385 console.error("[gates] settings save:", err);
1386 }
1387
1388 await audit({
1389 userId: user.id,
1390 repositoryId: repoRow.id,
1391 action: "gates.settings.update",
1392 });
1393
1394 return c.redirect(
1395 `/${owner}/${repo}/gates/settings?success=Settings+saved`
1396 );
1397});
1398
1399gates.post("/:owner/:repo/gates/protection", requireAuth, async (c) => {
1400 const user = c.get("user")!;
1401 const { owner, repo } = c.req.param();
1402 const repoRow = await loadRepo(owner, repo);
1403 if (!repoRow) return c.notFound();
1404 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
1405
1406 const body = await c.req.parseBody();
1407 const pattern = String(body.pattern || "").trim();
1408 if (!pattern) return c.redirect(`/${owner}/${repo}/gates/settings`);
1409 const b = (k: string) => body[k] === "1" || body[k] === "on";
1410 const requiredApprovals = Math.max(
1411 0,
1412 Math.min(10, parseInt(String(body.requiredApprovals || "0"), 10) || 0)
1413 );
1414
1415 try {
1416 await db.insert(branchProtection).values({
1417 repositoryId: repoRow.id,
1418 pattern,
1419 requirePullRequest: b("requirePullRequest"),
1420 requireGreenGates: b("requireGreenGates"),
1421 requireAiApproval: b("requireAiApproval"),
1422 requireHumanReview: b("requireHumanReview"),
1423 requiredApprovals,
1424 allowForcePush: b("allowForcePush"),
1425 allowDeletion: b("allowDeletion"),
4626e61Claude1426 // K2 — opt-in flag for the autopilot auto-merger.
1427 enableAutoMerge: b("enableAutoMerge"),
3ef4c9dClaude1428 });
1429 } catch (err) {
1430 console.error("[gates] protection save:", err);
1431 }
1432
1433 await audit({
1434 userId: user.id,
1435 repositoryId: repoRow.id,
1436 action: "branch_protection.create",
1437 metadata: { pattern },
1438 });
1439
1440 return c.redirect(
1441 `/${owner}/${repo}/gates/settings?success=Rule+added`
1442 );
1443});
1444
1445gates.post(
1446 "/:owner/:repo/gates/protection/:id/delete",
1447 requireAuth,
1448 async (c) => {
1449 const user = c.get("user")!;
1450 const { owner, repo, id } = c.req.param();
1451 const repoRow = await loadRepo(owner, repo);
1452 if (!repoRow) return c.notFound();
1453 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
1454 await db
1455 .delete(branchProtection)
1456 .where(
1457 and(
1458 eq(branchProtection.id, id),
1459 eq(branchProtection.repositoryId, repoRow.id)
1460 )
1461 );
1462 await audit({
1463 userId: user.id,
1464 repositoryId: repoRow.id,
1465 action: "branch_protection.delete",
1466 targetId: id,
1467 });
1468 return c.redirect(`/${owner}/${repo}/gates/settings?success=Rule+removed`);
1469 }
1470);
1471
1472export default gates;