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

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

environments.tsxBlame1176 lines · 1 contributor
25a91a6Claude1/**
2 * Environments settings + approval routes (Block C4).
3 *
4 * GET /:owner/:repo/settings/environments list + create form (owner-only)
5 * POST /:owner/:repo/settings/environments create
6 * POST /:owner/:repo/settings/environments/:envId update
7 * POST /:owner/:repo/settings/environments/:envId/delete
8 *
9 * POST /:owner/:repo/deployments/:deploymentId/approve approve a pending deploy
10 * POST /:owner/:repo/deployments/:deploymentId/reject reject a pending deploy
11 *
12 * Approve/reject live under /deployments/:id/... so they don't collide with
13 * the existing `GET /:owner/:repo/deployments/:id` detail page.
9ec3d15Claude14 *
15 * Visual polish (2026): adopts the gradient-hairline + orb pattern from
16 * admin-integrations / error-page. Page-level CSS is scoped under `.envs-*`
17 * so it can't bleed into the layout. RepoHeader + RepoNav are untouched.
25a91a6Claude18 */
19
20import { Hono } from "hono";
21import type { Context } from "hono";
22import { and, eq, inArray } from "drizzle-orm";
23import { db } from "../db";
24import {
25 environments,
26 deployments,
27 repositories,
28 users,
29} from "../db/schema";
30import type { Environment } from "../db/schema";
31import { softAuth, requireAuth } from "../middleware/auth";
32import type { AuthEnv } from "../middleware/auth";
33import { Layout } from "../views/layout";
34import { RepoHeader, RepoNav } from "../views/components";
35import { getUnreadCount } from "../lib/unread";
36import { audit, notify } from "../lib/notify";
37import {
38 allowedBranchesOf,
39 computeApprovalState,
40 getEnvironmentById,
41 getEnvironmentByName,
42 isReviewer,
43 listEnvironments,
44 recordApproval,
45 reviewerIdsOf,
46} from "../lib/environments";
47
48const r = new Hono<AuthEnv>();
49r.use("*", softAuth);
50
51// ---------------------------------------------------------------------------
52// helpers
53// ---------------------------------------------------------------------------
54
55async function loadRepo(owner: string, repo: string) {
56 try {
57 const [row] = await db
58 .select({
59 id: repositories.id,
60 name: repositories.name,
61 defaultBranch: repositories.defaultBranch,
62 ownerId: repositories.ownerId,
63 starCount: repositories.starCount,
64 forkCount: repositories.forkCount,
65 })
66 .from(repositories)
67 .innerJoin(users, eq(repositories.ownerId, users.id))
68 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
69 .limit(1);
70 return row || null;
71 } catch (err) {
72 console.error("[environments] loadRepo failed:", err);
73 return null;
74 }
75}
76
77function splitCsv(raw: unknown): string[] {
78 if (typeof raw !== "string") return [];
79 return raw
80 .split(",")
81 .map((s) => s.trim())
82 .filter(Boolean);
83}
84
85async function resolveUsernamesToIds(usernames: string[]): Promise<string[]> {
86 if (usernames.length === 0) return [];
87 try {
88 const rows = await db
89 .select({ id: users.id, username: users.username })
90 .from(users)
91 .where(inArray(users.username, usernames));
92 return rows.map((r) => r.id);
93 } catch (err) {
94 console.error("[environments] resolve usernames failed:", err);
95 return [];
96 }
97}
98
99async function idsToUsernames(ids: string[]): Promise<string[]> {
100 if (ids.length === 0) return [];
101 try {
102 const rows = await db
103 .select({ id: users.id, username: users.username })
104 .from(users)
105 .where(inArray(users.id, ids));
106 const map = new Map(rows.map((r) => [r.id, r.username]));
107 return ids.map((id) => map.get(id) || id);
108 } catch {
109 return ids;
110 }
111}
112
9ec3d15Claude113function envProtectionCount(env: Environment): number {
114 let n = 0;
115 if (env.requireApproval) n++;
116 if ((env.waitTimerMinutes ?? 0) > 0) n++;
117 if (allowedBranchesOf(env).length > 0) n++;
118 return n;
119}
120
121/* ─────────────────────────────────────────────────────────────────────────
122 * Scoped CSS — every class prefixed `.envs-*` so this surface can't bleed
123 * into other pages. Mirrors the gradient hairline + orb language used by
124 * admin-integrations and error-page.
125 * ───────────────────────────────────────────────────────────────────── */
126const envsStyles = `
eed4684Claude127 .envs-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
9ec3d15Claude128
129 .envs-head {
130 position: relative;
131 margin-bottom: var(--space-5);
132 padding: var(--space-4) var(--space-5);
133 background: var(--bg-elevated);
134 border: 1px solid var(--border);
135 border-radius: 14px;
136 overflow: hidden;
137 }
138 .envs-head::before {
139 content: '';
140 position: absolute;
141 top: 0; left: 0; right: 0;
142 height: 2px;
143 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
144 opacity: 0.7;
145 pointer-events: none;
146 }
147 .envs-head-orb {
148 position: absolute;
149 inset: -30% -10% auto auto;
150 width: 320px; height: 320px;
151 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
152 filter: blur(70px);
153 opacity: 0.7;
154 pointer-events: none;
155 z-index: 0;
156 }
157 .envs-head-inner {
158 position: relative;
159 z-index: 1;
160 display: flex;
161 align-items: flex-end;
162 justify-content: space-between;
163 gap: var(--space-4);
164 flex-wrap: wrap;
165 }
166 .envs-head-text { flex: 1; min-width: 240px; max-width: 720px; }
167 .envs-head-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
168 .envs-eyebrow {
169 display: inline-flex;
170 align-items: center;
171 gap: 8px;
172 font-family: var(--font-mono);
173 font-size: 11px;
174 letter-spacing: 0.14em;
175 text-transform: uppercase;
176 font-weight: 600;
177 color: var(--text-muted);
178 margin-bottom: 10px;
179 }
180 .envs-eyebrow-dot {
181 width: 8px; height: 8px;
182 border-radius: 9999px;
183 background: linear-gradient(135deg, #8c6dff, #36c5d6);
184 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
185 }
186 .envs-title {
187 margin: 0 0 6px;
188 font-family: var(--font-display);
189 font-size: clamp(22px, 2.6vw, 30px);
190 font-weight: 800;
191 letter-spacing: -0.022em;
192 line-height: 1.1;
193 color: var(--text-strong);
194 }
195 .envs-title-grad {
196 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
197 -webkit-background-clip: text;
198 background-clip: text;
199 -webkit-text-fill-color: transparent;
200 color: transparent;
201 }
202 .envs-sub {
203 margin: 0;
204 font-size: 13.5px;
205 line-height: 1.5;
206 color: var(--text-muted);
207 }
208
209 .envs-banner {
210 margin-bottom: var(--space-4);
211 padding: 10px 14px;
212 border-radius: 10px;
213 font-size: 13.5px;
214 border: 1px solid var(--border);
215 background: rgba(255,255,255,0.025);
216 color: var(--text);
217 }
218 .envs-banner.is-ok {
219 border-color: rgba(52,211,153,0.40);
220 background: rgba(52,211,153,0.08);
221 color: #bbf7d0;
222 }
223 .envs-banner.is-error {
224 border-color: rgba(248,113,113,0.40);
225 background: rgba(248,113,113,0.08);
226 color: #fecaca;
227 }
228
229 .envs-col-title {
230 margin: 0 0 var(--space-2);
231 font-family: var(--font-mono);
232 font-size: 11px;
233 text-transform: uppercase;
234 letter-spacing: 0.14em;
235 font-weight: 600;
236 color: var(--text-muted);
237 }
238
239 .envs-list {
240 display: flex;
241 flex-direction: column;
242 gap: var(--space-3);
243 margin-bottom: var(--space-5);
244 }
245
246 /* ─── environment item card ─── */
247 .envs-card {
248 position: relative;
249 padding: var(--space-4) var(--space-4);
250 background: var(--bg-elevated);
251 border: 1px solid var(--border);
252 border-radius: 14px;
253 overflow: hidden;
254 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
255 }
256 .envs-card::before {
257 content: '';
258 position: absolute;
259 top: 0; left: 14px; right: 14px;
260 height: 1px;
261 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
262 opacity: 0;
263 transition: opacity 160ms ease;
264 }
265 .envs-card:hover {
266 transform: translateY(-1px);
267 border-color: rgba(140,109,255,0.32);
268 box-shadow: 0 8px 22px -10px rgba(0,0,0,0.40);
269 }
270 .envs-card:hover::before { opacity: 1; }
271
272 .envs-card-head {
273 display: flex;
274 align-items: center;
275 justify-content: space-between;
276 gap: var(--space-3);
277 flex-wrap: wrap;
278 margin-bottom: var(--space-3);
279 }
280 .envs-card-titles { flex: 1; min-width: 200px; }
281 .envs-card-title {
282 margin: 0;
283 font-family: var(--font-display);
284 font-size: 17px;
285 font-weight: 700;
286 letter-spacing: -0.018em;
287 color: var(--text-strong);
288 }
289 .envs-card-url {
290 margin-top: 4px;
291 font-family: var(--font-mono);
292 font-size: 12px;
293 color: var(--text-muted);
294 overflow: hidden;
295 text-overflow: ellipsis;
296 white-space: nowrap;
297 }
298 .envs-card-url a { color: var(--text-muted); text-decoration: none; }
299 .envs-card-url a:hover { color: var(--accent); text-decoration: underline; }
300 .envs-card-meta {
301 margin-top: 8px;
302 display: flex;
303 flex-wrap: wrap;
304 gap: 6px;
305 font-size: 11.5px;
306 color: var(--text-muted);
307 font-variant-numeric: tabular-nums;
308 }
309
310 /* ─── pills ─── */
311 .envs-pill {
312 display: inline-flex;
313 align-items: center;
314 gap: 5px;
315 padding: 2px 9px;
316 border-radius: 9999px;
317 font-size: 10.5px;
318 font-weight: 600;
319 letter-spacing: 0.05em;
320 text-transform: uppercase;
321 font-family: var(--font-mono);
322 background: rgba(255,255,255,0.04);
323 color: var(--text-muted);
324 box-shadow: inset 0 0 0 1px var(--border);
325 }
326 .envs-pill.is-active {
327 background: rgba(52,211,153,0.10);
328 color: #6ee7b7;
329 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
330 }
331 .envs-pill.is-protected {
332 background: rgba(140,109,255,0.12);
333 color: #c4b5fd;
334 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
335 }
336 .envs-pill.is-warn {
337 background: rgba(251,191,36,0.10);
338 color: #fde68a;
339 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
340 }
341 .envs-pill-dot {
342 width: 6px; height: 6px;
343 border-radius: 9999px;
344 background: currentColor;
345 }
346
347 .envs-pillrow {
348 display: flex;
349 gap: 6px;
350 align-items: center;
351 flex-wrap: wrap;
352 }
353
354 /* ─── form rows inside an environment card ─── */
355 .envs-fields {
356 display: grid;
357 grid-template-columns: repeat(2, minmax(0, 1fr));
358 gap: var(--space-3);
359 }
360 @media (max-width: 720px) {
361 .envs-fields { grid-template-columns: 1fr; }
362 }
363 .envs-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
364 .envs-field.is-wide { grid-column: 1 / -1; }
365 .envs-field label {
366 font-family: var(--font-mono);
367 font-size: 11.5px;
368 font-weight: 600;
369 letter-spacing: 0.01em;
370 color: var(--text-strong);
371 }
372 .envs-field .hint {
373 font-size: 11px;
374 color: var(--text-muted);
375 line-height: 1.4;
376 }
377 .envs-input, .envs-textarea {
378 width: 100%;
379 padding: 8px 11px;
380 font-size: 13px;
381 color: var(--text);
382 background: var(--bg);
383 border: 1px solid var(--border-strong, var(--border));
384 border-radius: 8px;
385 outline: none;
386 font-family: var(--font-mono);
387 transition: border-color 120ms ease, box-shadow 120ms ease;
388 box-sizing: border-box;
389 }
390 .envs-input:focus, .envs-textarea:focus {
391 border-color: rgba(140,109,255,0.50);
392 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
393 }
394 .envs-check {
395 display: inline-flex;
396 align-items: center;
397 gap: 8px;
398 font-size: 13px;
399 color: var(--text);
400 cursor: pointer;
401 user-select: none;
402 }
403 .envs-check input { margin: 0; }
404
405 .envs-card-foot {
406 margin-top: var(--space-3);
407 padding-top: var(--space-3);
408 border-top: 1px solid var(--border);
409 display: flex;
410 justify-content: space-between;
411 align-items: center;
412 gap: var(--space-2);
413 flex-wrap: wrap;
414 }
415 .envs-card-foot-left {
416 font-size: 11.5px;
417 color: var(--text-muted);
418 font-variant-numeric: tabular-nums;
419 }
420
421 /* ─── ghost buttons (page-local) ─── */
422 .envs-btn {
423 display: inline-flex;
424 align-items: center;
425 justify-content: center;
426 padding: 6px 12px;
427 font-size: 12.5px;
428 font-weight: 600;
429 line-height: 1;
430 color: var(--text);
431 background: transparent;
432 border: 1px solid var(--border-strong, var(--border));
433 border-radius: 8px;
434 cursor: pointer;
435 text-decoration: none;
436 transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
437 font-family: inherit;
438 }
439 .envs-btn:hover {
440 background: rgba(140,109,255,0.07);
441 border-color: rgba(140,109,255,0.45);
442 color: var(--text-strong);
443 text-decoration: none;
444 }
445 .envs-btn.is-primary {
446 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
447 color: #fff;
448 border-color: transparent;
449 box-shadow: 0 4px 12px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.14);
450 }
451 .envs-btn.is-primary:hover { transform: translateY(-1px); color: #fff; }
452 .envs-btn.is-danger {
453 color: #fecaca;
454 border-color: rgba(248,113,113,0.35);
455 }
456 .envs-btn.is-danger:hover {
457 background: rgba(248,113,113,0.10);
458 border-color: rgba(248,113,113,0.55);
459 color: #fecaca;
460 }
461
462 /* ─── empty state — dashed card with orb ─── */
463 .envs-empty {
464 position: relative;
465 padding: var(--space-6) var(--space-5);
466 background: var(--bg-elevated);
467 border: 1px dashed var(--border-strong, var(--border));
468 border-radius: 14px;
469 text-align: center;
470 overflow: hidden;
471 margin-bottom: var(--space-5);
472 }
473 .envs-empty-orb {
474 position: absolute;
475 inset: auto auto -40% 50%;
476 transform: translateX(-50%);
477 width: 320px; height: 320px;
478 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
479 filter: blur(70px);
480 opacity: 0.7;
481 pointer-events: none;
482 }
483 .envs-empty-inner { position: relative; z-index: 1; max-width: 460px; margin: 0 auto; }
484 .envs-empty-icon {
485 width: 44px; height: 44px;
486 margin: 0 auto var(--space-3);
487 border-radius: 14px;
488 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
489 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
490 display: flex; align-items: center; justify-content: center;
491 color: #b69dff;
492 }
493 .envs-empty-title {
494 font-family: var(--font-display);
495 font-size: 16px;
496 font-weight: 700;
497 color: var(--text-strong);
498 margin: 0 0 6px;
499 letter-spacing: -0.01em;
500 }
501 .envs-empty-body {
502 font-size: 13.5px;
503 color: var(--text-muted);
504 line-height: 1.5;
505 margin: 0 0 var(--space-3);
506 }
507
508 /* ─── create form (lives below the list) ─── */
509 .envs-create {
510 position: relative;
511 padding: var(--space-4) var(--space-4);
512 background: var(--bg-elevated);
513 border: 1px solid var(--border);
514 border-radius: 14px;
515 overflow: hidden;
516 }
517 .envs-create::before {
518 content: '';
519 position: absolute;
520 top: 0; left: 14px; right: 14px;
521 height: 1px;
522 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.40) 30%, rgba(54,197,214,0.40) 70%, transparent 100%);
523 opacity: 0.7;
524 }
525 .envs-create-title {
526 margin: 0 0 4px;
527 font-family: var(--font-display);
528 font-size: 16px;
529 font-weight: 700;
530 letter-spacing: -0.014em;
531 color: var(--text-strong);
532 }
533 .envs-create-sub {
534 margin: 0 0 var(--space-3);
535 font-size: 12.5px;
536 color: var(--text-muted);
537 }
538`;
539
25a91a6Claude540// ---------------------------------------------------------------------------
541// GET /:owner/:repo/settings/environments
542// ---------------------------------------------------------------------------
543
544r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
545 const user = c.get("user")!;
546 const { owner, repo } = c.req.param();
547 const repoRow = await loadRepo(owner, repo);
548 if (!repoRow) return c.notFound();
549 if (repoRow.ownerId !== user.id) {
550 return c.redirect(`/${owner}/${repo}`);
551 }
552
553 const envs = await listEnvironments(repoRow.id);
554 const unread = await getUnreadCount(user.id);
555 const success = c.req.query("success");
556 const err = c.req.query("error");
557
558 // Resolve reviewer IDs → usernames per env for display.
559 const envUsernames: Record<string, string[]> = {};
560 for (const env of envs) {
561 envUsernames[env.id] = await idsToUsernames(reviewerIdsOf(env));
562 }
563
564 return c.html(
565 <Layout
566 title={`Environments — ${owner}/${repo}`}
567 user={user}
568 notificationCount={unread}
569 >
570 <RepoHeader
571 owner={owner}
572 repo={repo}
573 starCount={repoRow.starCount}
574 forkCount={repoRow.forkCount}
575 currentUser={user.username}
576 />
577 <RepoNav owner={owner} repo={repo} active="code" />
578
9ec3d15Claude579 <div class="envs-wrap">
580 <section class="envs-head">
581 <div class="envs-head-orb" aria-hidden="true" />
582 <div class="envs-head-inner">
583 <div class="envs-head-text">
584 <div class="envs-eyebrow">
585 <span class="envs-eyebrow-dot" aria-hidden="true" />
586 Deploy environments · {owner}/{repo}
587 </div>
588 <h2 class="envs-title">
589 <span class="envs-title-grad">Environments.</span>
590 </h2>
591 <p class="envs-sub">
592 Gate every deploy with reviewers, wait timers, and branch
593 allowlists — the same way GitHub Environments protects
594 production rollouts.
595 </p>
596 </div>
597 <div class="envs-head-actions">
598 <a
599 href={`/${owner}/${repo}/deployments`}
600 class="envs-btn"
601 >
602 Back to deployments
603 </a>
604 </div>
605 </div>
606 </section>
607
608 {success && (
609 <div class="envs-banner is-ok">{decodeURIComponent(success)}</div>
610 )}
611 {err && (
612 <div class="envs-banner is-error">{decodeURIComponent(err)}</div>
613 )}
614
615 <h4 class="envs-col-title">
616 {envs.length === 0
617 ? "No environments yet"
618 : `${envs.length} environment${envs.length === 1 ? "" : "s"}`}
619 </h4>
25a91a6Claude620
621 {envs.length === 0 ? (
9ec3d15Claude622 <div class="envs-empty">
623 <div class="envs-empty-orb" aria-hidden="true" />
624 <div class="envs-empty-inner">
625 <div class="envs-empty-icon" aria-hidden="true">
626 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
627 <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
628 <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
629 <line x1="12" y1="22.08" x2="12" y2="12" />
630 </svg>
631 </div>
632 <h3 class="envs-empty-title">Add your first environment</h3>
633 <p class="envs-empty-body">
634 Create a named environment like <strong>production</strong> or{" "}
635 <strong>staging</strong>, then require approval, wait timers,
636 or branch allowlists before any deploy may target it.
637 </p>
638 </div>
639 </div>
25a91a6Claude640 ) : (
9ec3d15Claude641 <div class="envs-list">
642 {envs.map((env) => {
643 const reviewers = envUsernames[env.id] || [];
644 const branches = allowedBranchesOf(env);
645 const protections = envProtectionCount(env);
646 const updatedAt = env.updatedAt
647 ? new Date(env.updatedAt)
648 : null;
649 const updatedLabel = updatedAt
650 ? updatedAt.toLocaleString()
651 : "—";
652 const envUrl = `/${owner}/${repo}/deployments?environment=${encodeURIComponent(env.name)}`;
653 return (
654 <form
655 method="post"
656 action={`/${owner}/${repo}/settings/environments/${env.id}`}
657 class="envs-card"
25a91a6Claude658 >
9ec3d15Claude659 <div class="envs-card-head">
660 <div class="envs-card-titles">
661 <h3 class="envs-card-title">{env.name}</h3>
662 <div class="envs-card-url">
663 <a href={envUrl}>{envUrl}</a>
664 </div>
665 <div class="envs-card-meta">
666 <span title={updatedAt ? updatedAt.toISOString() : ""}>
667 last deploy target · {updatedLabel}
668 </span>
669 </div>
670 </div>
671 <div class="envs-pillrow">
672 <span class="envs-pill is-active">
673 <span class="envs-pill-dot" aria-hidden="true" />
674 Active
675 </span>
676 <span
677 class={protections > 0 ? "envs-pill is-protected" : "envs-pill"}
678 title={`${protections} protection rule${protections === 1 ? "" : "s"}`}
679 >
680 <span class="envs-pill-dot" aria-hidden="true" />
681 {protections} rule{protections === 1 ? "" : "s"}
682 </span>
683 {env.requireApproval && (
684 <span class="envs-pill is-warn">
685 <span class="envs-pill-dot" aria-hidden="true" />
686 Approval
687 </span>
688 )}
689 </div>
25a91a6Claude690 </div>
9ec3d15Claude691
692 <div class="envs-fields">
693 <div class="envs-field is-wide">
694 <label class="envs-check">
695 <input
696 type="checkbox"
697 name="requireApproval"
698 value="1"
699 checked={env.requireApproval}
700 />
701 Require approval before deploy
702 </label>
703 </div>
704 <div class="envs-field is-wide">
705 <label for={`env-rev-${env.id}`}>Reviewers</label>
706 <input
707 id={`env-rev-${env.id}`}
708 type="text"
709 name="reviewers"
710 value={reviewers.join(", ")}
711 placeholder="alice, bob"
712 aria-label="Reviewers"
713 class="envs-input"
714 />
715 <span class="hint">Comma-separated usernames.</span>
716 </div>
717 <div class="envs-field">
718 <label for={`env-wait-${env.id}`}>Wait timer (minutes)</label>
719 <input
720 id={`env-wait-${env.id}`}
721 type="number"
722 name="waitTimerMinutes"
723 min="0"
724 max="1440"
725 value={String(env.waitTimerMinutes)}
726 aria-label="Wait timer in minutes"
727 class="envs-input"
728 />
729 <span class="hint">0 disables the timer. Max 1440 (24h).</span>
730 </div>
731 <div class="envs-field">
732 <label for={`env-br-${env.id}`}>Allowed branches</label>
733 <input
734 id={`env-br-${env.id}`}
735 type="text"
736 name="allowedBranches"
737 value={branches.join(", ")}
738 placeholder="main, release/*"
739 aria-label="Allowed branches"
740 class="envs-input"
741 />
742 <span class="hint">Comma-separated glob patterns.</span>
743 </div>
744 </div>
745
746 <div class="envs-card-foot">
747 <div class="envs-card-foot-left">
748 {reviewers.length > 0
749 ? `${reviewers.length} reviewer${reviewers.length === 1 ? "" : "s"}`
750 : "No reviewers"}
751 {" · "}
752 {branches.length > 0
753 ? `${branches.length} branch pattern${branches.length === 1 ? "" : "s"}`
754 : "any branch"}
755 </div>
756 <div style="display:flex;gap:6px;flex-wrap:wrap">
757 <a href={envUrl} class="envs-btn">View runs</a>
758 <button
759 type="submit"
760 formaction={`/${owner}/${repo}/settings/environments/${env.id}/delete`}
761 class="envs-btn is-danger"
762 onclick="return confirm('Delete this environment?')"
763 >
764 Delete
765 </button>
766 <button type="submit" class="envs-btn is-primary">
767 Save
768 </button>
769 </div>
770 </div>
771 </form>
772 );
773 })}
774 </div>
25a91a6Claude775 )}
9ec3d15Claude776
777 <form
778 method="post"
779 action={`/${owner}/${repo}/settings/environments`}
780 class="envs-create"
781 >
782 <h3 class="envs-create-title">New environment</h3>
783 <p class="envs-create-sub">
784 Pick a short, lowercase name like <strong>production</strong>,{" "}
785 <strong>staging</strong>, or <strong>preview</strong>.
786 </p>
787 <div class="envs-fields">
788 <div class="envs-field is-wide">
789 <label for="envs-new-name">Name</label>
790 <input
791 id="envs-new-name"
792 type="text"
793 name="name"
794 required
795 placeholder="production"
796 aria-label="Environment name"
797 class="envs-input"
798 />
799 </div>
800 <div class="envs-field is-wide">
801 <label class="envs-check">
802 <input
803 type="checkbox"
804 name="requireApproval"
805 value="1"
806 checked
807 />
808 Require approval
809 </label>
810 </div>
811 <div class="envs-field is-wide">
812 <label for="envs-new-reviewers">Reviewers</label>
813 <input
814 id="envs-new-reviewers"
815 type="text"
816 name="reviewers"
817 placeholder="alice, bob"
818 aria-label="Reviewers"
819 class="envs-input"
820 />
821 <span class="hint">Comma-separated usernames.</span>
822 </div>
823 <div class="envs-field">
824 <label for="envs-new-wait">Wait timer (minutes)</label>
825 <input
826 id="envs-new-wait"
827 type="number"
828 name="waitTimerMinutes"
829 min="0"
830 max="1440"
831 value="0"
832 aria-label="Wait timer in minutes"
833 class="envs-input"
834 />
835 </div>
836 <div class="envs-field">
837 <label for="envs-new-branches">Allowed branches</label>
838 <input
839 id="envs-new-branches"
840 type="text"
841 name="allowedBranches"
842 placeholder="main, release/*"
843 aria-label="Allowed branches"
844 class="envs-input"
845 />
846 <span class="hint">Comma-separated glob patterns.</span>
847 </div>
848 </div>
849 <div style="display:flex;justify-content:flex-end;margin-top:var(--space-3)">
850 <button type="submit" class="envs-btn is-primary">
851 Create environment
852 </button>
853 </div>
854 </form>
25a91a6Claude855 </div>
856
9ec3d15Claude857 <style dangerouslySetInnerHTML={{ __html: envsStyles }} />
25a91a6Claude858 </Layout>
859 );
860});
861
862// ---------------------------------------------------------------------------
863// POST /:owner/:repo/settings/environments (create)
864// ---------------------------------------------------------------------------
865
866r.post("/:owner/:repo/settings/environments", requireAuth, async (c) => {
867 const user = c.get("user")!;
868 const { owner, repo } = c.req.param();
869 const repoRow = await loadRepo(owner, repo);
870 if (!repoRow) return c.notFound();
871 if (repoRow.ownerId !== user.id) {
872 return c.redirect(`/${owner}/${repo}`);
873 }
874
875 const body = await c.req.parseBody();
876 const name = String(body.name || "").trim();
877 if (!name) {
878 return c.redirect(
879 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
880 "Name required"
881 )}`
882 );
883 }
884 const requireApproval = body.requireApproval === "1" || body.requireApproval === "on";
885 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
886 const waitTimerMinutes = Math.max(
887 0,
888 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
889 );
890 const allowedBranches = splitCsv(body.allowedBranches);
891
892 try {
893 await db.insert(environments).values({
894 repositoryId: repoRow.id,
895 name,
896 requireApproval,
897 reviewers: JSON.stringify(reviewers),
898 waitTimerMinutes,
899 allowedBranches: JSON.stringify(allowedBranches),
900 });
901 } catch (err) {
902 console.error("[environments] create failed:", err);
903 return c.redirect(
904 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
905 "Could not create (duplicate name?)"
906 )}`
907 );
908 }
909
910 await audit({
911 userId: user.id,
912 repositoryId: repoRow.id,
913 action: "environment.create",
914 targetType: "environment",
915 metadata: { name, requireApproval, reviewers, allowedBranches },
916 });
917
918 return c.redirect(
919 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
920 "Environment created"
921 )}`
922 );
923});
924
925// ---------------------------------------------------------------------------
926// POST /:owner/:repo/settings/environments/:envId (update)
927// ---------------------------------------------------------------------------
928
929r.post("/:owner/:repo/settings/environments/:envId", requireAuth, async (c) => {
930 const user = c.get("user")!;
931 const { owner, repo, envId } = c.req.param();
932 const repoRow = await loadRepo(owner, repo);
933 if (!repoRow) return c.notFound();
934 if (repoRow.ownerId !== user.id) {
935 return c.redirect(`/${owner}/${repo}`);
936 }
937
938 const env = await getEnvironmentById(repoRow.id, envId);
939 if (!env) return c.notFound();
940
941 const body = await c.req.parseBody();
942 const requireApproval =
943 body.requireApproval === "1" || body.requireApproval === "on";
944 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
945 const waitTimerMinutes = Math.max(
946 0,
947 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
948 );
949 const allowedBranches = splitCsv(body.allowedBranches);
950
951 try {
952 await db
953 .update(environments)
954 .set({
955 requireApproval,
956 reviewers: JSON.stringify(reviewers),
957 waitTimerMinutes,
958 allowedBranches: JSON.stringify(allowedBranches),
959 updatedAt: new Date(),
960 })
961 .where(
962 and(
963 eq(environments.id, envId),
964 eq(environments.repositoryId, repoRow.id)
965 )
966 );
967 } catch (err) {
968 console.error("[environments] update failed:", err);
969 }
970
971 await audit({
972 userId: user.id,
973 repositoryId: repoRow.id,
974 action: "environment.update",
975 targetType: "environment",
976 targetId: envId,
977 metadata: { requireApproval, reviewers, allowedBranches, waitTimerMinutes },
978 });
979
980 return c.redirect(
981 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
982 "Environment updated"
983 )}`
984 );
985});
986
987// ---------------------------------------------------------------------------
988// POST /:owner/:repo/settings/environments/:envId/delete
989// ---------------------------------------------------------------------------
990
991r.post(
992 "/:owner/:repo/settings/environments/:envId/delete",
993 requireAuth,
994 async (c) => {
995 const user = c.get("user")!;
996 const { owner, repo, envId } = c.req.param();
997 const repoRow = await loadRepo(owner, repo);
998 if (!repoRow) return c.notFound();
999 if (repoRow.ownerId !== user.id) {
1000 return c.redirect(`/${owner}/${repo}`);
1001 }
1002
1003 try {
1004 await db
1005 .delete(environments)
1006 .where(
1007 and(
1008 eq(environments.id, envId),
1009 eq(environments.repositoryId, repoRow.id)
1010 )
1011 );
1012 } catch (err) {
1013 console.error("[environments] delete failed:", err);
1014 }
1015
1016 await audit({
1017 userId: user.id,
1018 repositoryId: repoRow.id,
1019 action: "environment.delete",
1020 targetType: "environment",
1021 targetId: envId,
1022 });
1023
1024 return c.redirect(
1025 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
1026 "Environment removed"
1027 )}`
1028 );
1029 }
1030);
1031
1032// ---------------------------------------------------------------------------
1033// Approve/reject a pending deployment
1034// ---------------------------------------------------------------------------
1035
1036async function loadDeployment(repositoryId: string, deploymentId: string) {
1037 try {
1038 const [row] = await db
1039 .select()
1040 .from(deployments)
1041 .where(
1042 and(
1043 eq(deployments.id, deploymentId),
1044 eq(deployments.repositoryId, repositoryId)
1045 )
1046 )
1047 .limit(1);
1048 return row || null;
1049 } catch (err) {
1050 console.error("[environments] loadDeployment failed:", err);
1051 return null;
1052 }
1053}
1054
1055async function decide(
1056 c: Context<AuthEnv>,
1057 decision: "approved" | "rejected"
1058) {
1059 const user = c.get("user")!;
1060 const { owner, repo, deploymentId } = c.req.param();
1061 const repoRow = await loadRepo(owner, repo);
1062 if (!repoRow) return c.notFound();
1063
1064 const deployment = await loadDeployment(repoRow.id, deploymentId);
1065 if (!deployment) return c.notFound();
1066
1067 const envName = deployment.environment;
1068 const env = await getEnvironmentByName(repoRow.id, envName);
1069 if (!env) {
1070 // No env configured — nothing to approve. Treat as 404 for safety.
1071 return c.notFound();
1072 }
1073
1074 const allowed = await isReviewer(env, user.id);
1075 if (!allowed) {
1076 return c.redirect(
1077 `/${owner}/${repo}/deployments/${deploymentId}?error=${encodeURIComponent(
1078 "Not a reviewer"
1079 )}`
1080 );
1081 }
1082
1083 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
1084 const comment = typeof body.comment === "string" ? body.comment : undefined;
1085
1086 const inserted = await recordApproval({
1087 deploymentId,
1088 userId: user.id,
1089 decision,
1090 comment,
1091 });
1092
a76d984Claude1093 // Re-read state and flip the deployment row accordingly. When the env
1094 // carries a wait timer and the timer hasn't elapsed yet, we hold the
1095 // deploy in status="waiting_timer" with `readyAfter` populated; the
1096 // autopilot ticker (`releaseExpiredWaitTimers`) flips it later.
25a91a6Claude1097 const state = await computeApprovalState(deploymentId, env);
1098 let newStatus: string | null = null;
a76d984Claude1099 let readyAfter: Date | null = null;
1100 let blockedReason: string | null = null;
25a91a6Claude1101 if (state.rejected) {
1102 newStatus = "rejected";
a76d984Claude1103 blockedReason = "rejected by reviewer";
25a91a6Claude1104 } else if (state.approved && deployment.status === "pending_approval") {
a76d984Claude1105 const now = new Date();
1106 if (state.readyAfter && state.readyAfter.getTime() > now.getTime()) {
1107 newStatus = "waiting_timer";
1108 readyAfter = state.readyAfter;
1109 blockedReason = `wait_timer until ${state.readyAfter.toISOString()}`;
1110 } else {
1111 newStatus = "pending"; // hand off to existing deployer
1112 }
25a91a6Claude1113 }
1114
1115 if (newStatus) {
1116 try {
1117 await db
1118 .update(deployments)
1119 .set({
1120 status: newStatus,
a76d984Claude1121 blockedReason,
1122 // Always overwrite readyAfter — clears any prior value when the
1123 // status flips to anything other than waiting_timer.
1124 readyAfter,
25a91a6Claude1125 })
1126 .where(eq(deployments.id, deploymentId));
1127 } catch (err) {
1128 console.error("[environments] deployment status flip failed:", err);
1129 }
1130 }
1131
1132 await audit({
1133 userId: user.id,
1134 repositoryId: repoRow.id,
1135 action: decision === "approved" ? "deployment.approve" : "deployment.reject",
1136 targetType: "deployment",
1137 targetId: deploymentId,
1138 metadata: { recorded: !!inserted, newStatus },
1139 });
1140
1141 if (deployment.triggeredBy && deployment.triggeredBy !== user.id) {
1142 try {
1143 await notify(deployment.triggeredBy, {
1144 kind: "deployment_approval",
1145 title:
1146 decision === "approved"
1147 ? `Deploy to ${envName} approved`
1148 : `Deploy to ${envName} rejected`,
1149 body:
1150 decision === "approved"
1151 ? `${user.username} approved the deploy of ${deployment.commitSha.slice(0, 7)}.`
1152 : `${user.username} rejected the deploy of ${deployment.commitSha.slice(0, 7)}.`,
1153 url: `/${owner}/${repo}/deployments/${deploymentId}`,
1154 repositoryId: repoRow.id,
1155 });
1156 } catch (err) {
1157 console.error("[environments] notify triggeredBy failed:", err);
1158 }
1159 }
1160
1161 return c.redirect(`/${owner}/${repo}/deployments/${deploymentId}`);
1162}
1163
1164r.post(
1165 "/:owner/:repo/deployments/:deploymentId/approve",
1166 requireAuth,
1167 async (c) => decide(c, "approved")
1168);
1169
1170r.post(
1171 "/:owner/:repo/deployments/:deploymentId/reject",
1172 requireAuth,
1173 async (c) => decide(c, "rejected")
1174);
1175
1176export default r;