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.
| 3ef4c9d | 1 | /** |
| 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 | |
| 7581253 | 10 | * |
| 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. | |
| 3ef4c9d | 15 | */ |
| 16 | ||
| 17 | import { Hono } from "hono"; | |
| 18 | import { and, desc, eq } from "drizzle-orm"; | |
| 19 | import { db } from "../db"; | |
| 20 | import { | |
| 21 | branchProtection, | |
| 22 | gateRuns, | |
| 23 | repoSettings, | |
| 24 | repositories, | |
| 25 | users, | |
| 26 | } from "../db/schema"; | |
| 27 | import { Layout } from "../views/layout"; | |
| 28 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 29 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 30 | import type { AuthEnv } from "../middleware/auth"; | |
| 31 | import { getOrCreateSettings } from "../lib/repo-bootstrap"; | |
| 32 | import { getUnreadCount } from "../lib/unread"; | |
| 33 | import { audit } from "../lib/notify"; | |
| 34 | ||
| 35 | const gates = new Hono<AuthEnv>(); | |
| 36 | gates.use("*", softAuth); | |
| 37 | ||
| 38 | async 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 | ||
| 55 | function 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 | ||
| 7581253 | 68 | /* ───────────────────────────────────────────────────────────────────────── |
| 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 | * ───────────────────────────────────────────────────────────────────── */ | |
| 74 | const gatesStyles = ` | |
| eed4684 | 75 | .gates-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-4) 0; } |
| 7581253 | 76 | |
| 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; | |
| 6fd5915 | 91 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| 7581253 | 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; | |
| 6fd5915 | 99 | background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%); |
| 7581253 | 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; | |
| 6fd5915 | 133 | background: rgba(91,110,232,0.14); |
| 134 | color: #5b6ee8; | |
| 135 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35); | |
| 7581253 | 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 { | |
| 6fd5915 | 147 | background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 7581253 | 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); | |
| 6fd5915 | 217 | background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated); |
| 7581253 | 218 | } |
| 219 | .gates-status.is-warn { | |
| 220 | border-color: rgba(248,113,113,0.32); | |
| 6fd5915 | 221 | background: linear-gradient(135deg, rgba(248,113,113,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated); |
| 7581253 | 222 | } |
| 223 | .gates-status.is-empty { | |
| 224 | border-color: rgba(251,191,36,0.30); | |
| 6fd5915 | 225 | background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated); |
| 7581253 | 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; | |
| 6fd5915 | 241 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| 242 | box-shadow: 0 8px 20px -8px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.18); | |
| 7581253 | 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; } | |
| 6fd5915 | 307 | .gates-stat.is-repaired { color: #5b6ee8; } |
| 7581253 | 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; | |
| 6fd5915 | 357 | background: rgba(91,110,232,0.12); |
| 358 | color: #5b6ee8; | |
| 359 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28); | |
| 7581253 | 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 { | |
| 6fd5915 | 387 | border-color: rgba(91,110,232,0.30); |
| 7581253 | 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); } | |
| 6fd5915 | 400 | .gates-run-light.is-repaired { background: #5b6ee8; box-shadow: 0 0 0 3px rgba(91,110,232,0.22), 0 0 8px rgba(91,110,232,0.40); } |
| 7581253 | 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); } | |
| 6fd5915 | 426 | .gates-run-pill.is-repaired { background: rgba(91,110,232,0.14); color: #c4b5fd; box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30); } |
| 7581253 | 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; | |
| 6fd5915 | 475 | background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%); |
| 7581253 | 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; | |
| 6fd5915 | 486 | background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12)); |
| 487 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30); | |
| 7581253 | 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; } | |
| 6fd5915 | 528 | .gates-toggle:hover { background: rgba(91,110,232,0.04); } |
| 7581253 | 529 | .gates-toggle-input { |
| 530 | margin-top: 3px; | |
| 531 | flex-shrink: 0; | |
| 532 | width: 18px; height: 18px; | |
| 6fd5915 | 533 | accent-color: #5b6ee8; |
| 7581253 | 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 | } | |
| 6fd5915 | 558 | .gates-rule:hover { border-color: rgba(91,110,232,0.28); } |
| 7581253 | 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); | |
| 6fd5915 | 570 | background: rgba(91,110,232,0.10); |
| 571 | border: 1px solid rgba(91,110,232,0.30); | |
| 7581253 | 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 { | |
| 6fd5915 | 644 | border-color: var(--border-focus, rgba(91,110,232,0.55)); |
| 645 | box-shadow: 0 0 0 3px rgba(91,110,232,0.18); | |
| 7581253 | 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 | } | |
| 6fd5915 | 669 | .gates-checkbox:hover { border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.06); } |
| 670 | .gates-checkbox input { accent-color: #5b6ee8; cursor: pointer; } | |
| 7581253 | 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 { | |
| 6fd5915 | 690 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| 7581253 | 691 | color: #fff; |
| 6fd5915 | 692 | box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16); |
| 7581253 | 693 | } |
| 694 | .gates-btn-primary:hover { | |
| 695 | transform: translateY(-1px); | |
| 6fd5915 | 696 | box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20); |
| 7581253 | 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 { | |
| 6fd5915 | 706 | background: rgba(91,110,232,0.06); |
| 707 | border-color: rgba(91,110,232,0.45); | |
| 7581253 | 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) ─── */ | |
| 726 | const 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 | ); | |
| 732 | const 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 | ); | |
| 739 | const 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 | ); | |
| 746 | const 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 | ); | |
| 752 | const 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 | ); | |
| 758 | const 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 | ); | |
| 763 | const 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 | ); | |
| 769 | const 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 | ); | |
| 774 | const 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 | ); | |
| 780 | const 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 | ||
| 787 | function 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 | ||
| 3ef4c9d | 794 | // ---------- Gate run history ---------- |
| 795 | ||
| 796 | gates.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 | ||
| 7581253 | 816 | 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 | ||
| 3ef4c9d | 839 | 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 | ||
| 7581253 | 854 | <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> | |
| 3ef4c9d | 884 | |
| 7581253 | 885 | <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> | |
| 3ef4c9d | 914 | </div> |
| 7581253 | 915 | |
| 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> | |
| 3ef4c9d | 940 | </div> |
| 7581253 | 941 | <div class="gates-run-meta"> |
| 942 | <a href={`/${owner}/${repo}/commit/${r.commitSha}`}> | |
| 943 | <code>{r.commitSha.slice(0, 7)}</code> | |
| 3ef4c9d | 944 | </a> |
| 7581253 | 945 | <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} | |
| 3ef4c9d | 957 | </div> |
| 7581253 | 958 | {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> | |
| 3ef4c9d | 968 | </div> |
| 7581253 | 969 | ))} |
| 970 | </div> | |
| 971 | )} | |
| 972 | </div> | |
| 973 | <style dangerouslySetInnerHTML={{ __html: gatesStyles }} /> | |
| 3ef4c9d | 974 | </Layout> |
| 975 | ); | |
| 976 | }); | |
| 977 | ||
| 978 | // ---------- Settings UI ---------- | |
| 979 | ||
| 980 | gates.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 | ||
| 7581253 | 996 | // 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>} | |
| 3ef4c9d | 1037 | </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" /> | |
| 7581253 | 1055 | |
| 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> | |
| 3ef4c9d | 1078 | </div> |
| 7581253 | 1079 | </section> |
| 3ef4c9d | 1080 | |
| 7581253 | 1081 | {success && ( |
| 1082 | <div class="gates-banner is-ok" role="status"> | |
| 1083 | <span class="gates-banner-dot" aria-hidden="true" /> | |
| 1084 | {decodeURIComponent(success)} | |
| 3ef4c9d | 1085 | </div> |
| 7581253 | 1086 | )} |
| 3ef4c9d | 1087 | |
| 7581253 | 1088 | <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> | |
| 3ef4c9d | 1097 | </div> |
| 7581253 | 1098 | </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> | |
| 3ef4c9d | 1163 | |
| 7581253 | 1164 | <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> | |
| 3ef4c9d | 1189 | </div> |
| 7581253 | 1190 | </form> |
| 3ef4c9d | 1191 | |
| 7581253 | 1192 | <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 /> | |
| 3ef4c9d | 1211 | </div> |
| 7581253 | 1212 | <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> | |
| 3ef4c9d | 1217 | </div> |
| 7581253 | 1218 | ) : ( |
| 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." | |
| a79a9ed | 1334 | > |
| 7581253 | 1335 | <input type="checkbox" name="enableAutoMerge" value="1" /> |
| 1336 | Enable AI auto-merge | |
| 1337 | </label> | |
| a79a9ed | 1338 | </div> |
| 3ef4c9d | 1339 | </div> |
| 7581253 | 1340 | <button type="submit" class="gates-btn gates-btn-primary"> |
| 1341 | <LockGlyph /> Add rule | |
| 1342 | </button> | |
| 1343 | </form> | |
| 1344 | </section> | |
| 3ef4c9d | 1345 | </div> |
| 7581253 | 1346 | <style dangerouslySetInnerHTML={{ __html: gatesStyles }} /> |
| 3ef4c9d | 1347 | </Layout> |
| 1348 | ); | |
| 1349 | }); | |
| 1350 | ||
| 1351 | gates.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 | ||
| 1399 | gates.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"), | |
| 4626e61 | 1426 | // K2 — opt-in flag for the autopilot auto-merger. |
| 1427 | enableAutoMerge: b("enableAutoMerge"), | |
| 3ef4c9d | 1428 | }); |
| 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 | ||
| 1445 | gates.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 | ||
| 1472 | export default gates; |