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.tsxBlame619 lines · 2 contributors
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.
14 */
15
16import { Hono } from "hono";
17import type { Context } from "hono";
18import { and, eq, inArray } from "drizzle-orm";
19import { db } from "../db";
20import {
21 environments,
22 deployments,
23 repositories,
24 users,
25} from "../db/schema";
26import type { Environment } from "../db/schema";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { Layout } from "../views/layout";
30import { RepoHeader, RepoNav } from "../views/components";
31import { getUnreadCount } from "../lib/unread";
32import { audit, notify } from "../lib/notify";
33import {
34 allowedBranchesOf,
35 computeApprovalState,
36 getEnvironmentById,
37 getEnvironmentByName,
38 isReviewer,
39 listEnvironments,
40 recordApproval,
41 reviewerIdsOf,
42} from "../lib/environments";
43
44const r = new Hono<AuthEnv>();
45r.use("*", softAuth);
46
47// ---------------------------------------------------------------------------
48// helpers
49// ---------------------------------------------------------------------------
50
51async function loadRepo(owner: string, repo: string) {
52 try {
53 const [row] = await db
54 .select({
55 id: repositories.id,
56 name: repositories.name,
57 defaultBranch: repositories.defaultBranch,
58 ownerId: repositories.ownerId,
59 starCount: repositories.starCount,
60 forkCount: repositories.forkCount,
61 })
62 .from(repositories)
63 .innerJoin(users, eq(repositories.ownerId, users.id))
64 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
65 .limit(1);
66 return row || null;
67 } catch (err) {
68 console.error("[environments] loadRepo failed:", err);
69 return null;
70 }
71}
72
73function splitCsv(raw: unknown): string[] {
74 if (typeof raw !== "string") return [];
75 return raw
76 .split(",")
77 .map((s) => s.trim())
78 .filter(Boolean);
79}
80
81async function resolveUsernamesToIds(usernames: string[]): Promise<string[]> {
82 if (usernames.length === 0) return [];
83 try {
84 const rows = await db
85 .select({ id: users.id, username: users.username })
86 .from(users)
87 .where(inArray(users.username, usernames));
88 return rows.map((r) => r.id);
89 } catch (err) {
90 console.error("[environments] resolve usernames failed:", err);
91 return [];
92 }
93}
94
95async function idsToUsernames(ids: string[]): Promise<string[]> {
96 if (ids.length === 0) return [];
97 try {
98 const rows = await db
99 .select({ id: users.id, username: users.username })
100 .from(users)
101 .where(inArray(users.id, ids));
102 const map = new Map(rows.map((r) => [r.id, r.username]));
103 return ids.map((id) => map.get(id) || id);
104 } catch {
105 return ids;
106 }
107}
108
109// ---------------------------------------------------------------------------
110// GET /:owner/:repo/settings/environments
111// ---------------------------------------------------------------------------
112
113r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
114 const user = c.get("user")!;
115 const { owner, repo } = c.req.param();
116 const repoRow = await loadRepo(owner, repo);
117 if (!repoRow) return c.notFound();
118 if (repoRow.ownerId !== user.id) {
119 return c.redirect(`/${owner}/${repo}`);
120 }
121
122 const envs = await listEnvironments(repoRow.id);
123 const unread = await getUnreadCount(user.id);
124 const success = c.req.query("success");
125 const err = c.req.query("error");
126
127 // Resolve reviewer IDs → usernames per env for display.
128 const envUsernames: Record<string, string[]> = {};
129 for (const env of envs) {
130 envUsernames[env.id] = await idsToUsernames(reviewerIdsOf(env));
131 }
132
133 return c.html(
134 <Layout
135 title={`Environments — ${owner}/${repo}`}
136 user={user}
137 notificationCount={unread}
138 >
139 <RepoHeader
140 owner={owner}
141 repo={repo}
142 starCount={repoRow.starCount}
143 forkCount={repoRow.forkCount}
144 currentUser={user.username}
145 />
146 <RepoNav owner={owner} repo={repo} active="code" />
147 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
148 <h3>Environments</h3>
149 <a href={`/${owner}/${repo}/deployments`} class="btn btn-sm">
150 Back to deployments
151 </a>
152 </div>
153 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
154 {err && <div class="auth-error">{decodeURIComponent(err)}</div>}
155
156 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px">
157 Require human approval before a deploy to this environment runs.
158 Branch patterns restrict which refs may target the environment.
159 </p>
160
161 <div class="panel" style="margin-bottom: 24px">
162 {envs.length === 0 ? (
163 <div class="panel-empty">No environments yet.</div>
164 ) : (
165 envs.map((env) => {
166 const reviewers = envUsernames[env.id] || [];
167 const branches = allowedBranchesOf(env);
168 return (
169 <form
9e2c6dfClaude170 method="post"
25a91a6Claude171 action={`/${owner}/${repo}/settings/environments/${env.id}`}
172 class="panel-item"
173 style="flex-direction: column; align-items: stretch; gap: 8px"
174 >
175 <div
176 style="display: flex; justify-content: space-between; align-items: center"
177 >
178 <strong style="font-size: 15px">{env.name}</strong>
179 <div style="display: flex; gap: 6px">
180 <button type="submit" class="btn btn-sm btn-primary">
181 Save
182 </button>
183 </div>
184 </div>
185 <div class="form-group" style="margin: 0">
186 <label style="display: flex; align-items: center; gap: 6px">
187 <input
188 type="checkbox"
189 name="requireApproval"
190 value="1"
191 checked={env.requireApproval}
192 />
193 Require approval before deploy
194 </label>
195 </div>
196 <div class="form-group" style="margin: 0">
197 <label>Reviewers (comma-separated usernames)</label>
198 <input
199 type="text"
200 name="reviewers"
201 value={reviewers.join(", ")}
202 placeholder="alice, bob"
2228c49copilot-swe-agent[bot]203 aria-label="Reviewers"
25a91a6Claude204 />
205 </div>
206 <div class="form-group" style="margin: 0">
207 <label>Wait timer (minutes)</label>
208 <input
209 type="number"
210 name="waitTimerMinutes"
211 min="0"
212 max="1440"
213 value={String(env.waitTimerMinutes)}
2228c49copilot-swe-agent[bot]214 aria-label="Wait timer in minutes"
25a91a6Claude215 style="width: 120px"
216 />
217 </div>
218 <div class="form-group" style="margin: 0">
219 <label>Allowed branches (comma-separated glob patterns)</label>
220 <input
221 type="text"
222 name="allowedBranches"
223 value={branches.join(", ")}
224 placeholder="main, release/*"
2228c49copilot-swe-agent[bot]225 aria-label="Allowed branches"
25a91a6Claude226 />
227 </div>
228 <div style="display: flex; justify-content: flex-end">
229 <button
230 type="submit"
231 formaction={`/${owner}/${repo}/settings/environments/${env.id}/delete`}
232 class="btn btn-sm btn-danger"
233 onclick="return confirm('Delete this environment?')"
234 >
235 Delete
236 </button>
237 </div>
238 </form>
239 );
240 })
241 )}
242 </div>
243
244 <h3 style="margin-top: 24px; margin-bottom: 12px">New environment</h3>
245 <form
9e2c6dfClaude246 method="post"
25a91a6Claude247 action={`/${owner}/${repo}/settings/environments`}
248 class="panel"
249 style="padding: 16px"
250 >
251 <div class="form-group">
252 <label>Name</label>
253 <input
254 type="text"
255 name="name"
256 required
257 placeholder="production"
2228c49copilot-swe-agent[bot]258 aria-label="Environment name"
25a91a6Claude259 />
260 </div>
261 <div class="form-group">
262 <label style="display: flex; align-items: center; gap: 6px">
263 <input
264 type="checkbox"
265 name="requireApproval"
266 value="1"
267 checked
268 />
269 Require approval
270 </label>
271 </div>
272 <div class="form-group">
273 <label>Reviewers (comma-separated usernames)</label>
2228c49copilot-swe-agent[bot]274 <input type="text" name="reviewers" placeholder="alice, bob" aria-label="Reviewers" />
25a91a6Claude275 </div>
276 <div class="form-group">
277 <label>Wait timer (minutes)</label>
278 <input
279 type="number"
280 name="waitTimerMinutes"
281 min="0"
282 max="1440"
283 value="0"
2228c49copilot-swe-agent[bot]284 aria-label="Wait timer in minutes"
25a91a6Claude285 style="width: 120px"
286 />
287 </div>
288 <div class="form-group">
289 <label>Allowed branches (comma-separated glob patterns)</label>
290 <input
291 type="text"
292 name="allowedBranches"
293 placeholder="main, release/*"
2228c49copilot-swe-agent[bot]294 aria-label="Allowed branches"
25a91a6Claude295 />
296 </div>
297 <button type="submit" class="btn btn-primary">
298 Create environment
299 </button>
300 </form>
301 </Layout>
302 );
303});
304
305// ---------------------------------------------------------------------------
306// POST /:owner/:repo/settings/environments (create)
307// ---------------------------------------------------------------------------
308
309r.post("/:owner/:repo/settings/environments", requireAuth, async (c) => {
310 const user = c.get("user")!;
311 const { owner, repo } = c.req.param();
312 const repoRow = await loadRepo(owner, repo);
313 if (!repoRow) return c.notFound();
314 if (repoRow.ownerId !== user.id) {
315 return c.redirect(`/${owner}/${repo}`);
316 }
317
318 const body = await c.req.parseBody();
319 const name = String(body.name || "").trim();
320 if (!name) {
321 return c.redirect(
322 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
323 "Name required"
324 )}`
325 );
326 }
327 const requireApproval = body.requireApproval === "1" || body.requireApproval === "on";
328 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
329 const waitTimerMinutes = Math.max(
330 0,
331 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
332 );
333 const allowedBranches = splitCsv(body.allowedBranches);
334
335 try {
336 await db.insert(environments).values({
337 repositoryId: repoRow.id,
338 name,
339 requireApproval,
340 reviewers: JSON.stringify(reviewers),
341 waitTimerMinutes,
342 allowedBranches: JSON.stringify(allowedBranches),
343 });
344 } catch (err) {
345 console.error("[environments] create failed:", err);
346 return c.redirect(
347 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
348 "Could not create (duplicate name?)"
349 )}`
350 );
351 }
352
353 await audit({
354 userId: user.id,
355 repositoryId: repoRow.id,
356 action: "environment.create",
357 targetType: "environment",
358 metadata: { name, requireApproval, reviewers, allowedBranches },
359 });
360
361 return c.redirect(
362 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
363 "Environment created"
364 )}`
365 );
366});
367
368// ---------------------------------------------------------------------------
369// POST /:owner/:repo/settings/environments/:envId (update)
370// ---------------------------------------------------------------------------
371
372r.post("/:owner/:repo/settings/environments/:envId", requireAuth, async (c) => {
373 const user = c.get("user")!;
374 const { owner, repo, envId } = c.req.param();
375 const repoRow = await loadRepo(owner, repo);
376 if (!repoRow) return c.notFound();
377 if (repoRow.ownerId !== user.id) {
378 return c.redirect(`/${owner}/${repo}`);
379 }
380
381 const env = await getEnvironmentById(repoRow.id, envId);
382 if (!env) return c.notFound();
383
384 const body = await c.req.parseBody();
385 const requireApproval =
386 body.requireApproval === "1" || body.requireApproval === "on";
387 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
388 const waitTimerMinutes = Math.max(
389 0,
390 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
391 );
392 const allowedBranches = splitCsv(body.allowedBranches);
393
394 try {
395 await db
396 .update(environments)
397 .set({
398 requireApproval,
399 reviewers: JSON.stringify(reviewers),
400 waitTimerMinutes,
401 allowedBranches: JSON.stringify(allowedBranches),
402 updatedAt: new Date(),
403 })
404 .where(
405 and(
406 eq(environments.id, envId),
407 eq(environments.repositoryId, repoRow.id)
408 )
409 );
410 } catch (err) {
411 console.error("[environments] update failed:", err);
412 }
413
414 await audit({
415 userId: user.id,
416 repositoryId: repoRow.id,
417 action: "environment.update",
418 targetType: "environment",
419 targetId: envId,
420 metadata: { requireApproval, reviewers, allowedBranches, waitTimerMinutes },
421 });
422
423 return c.redirect(
424 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
425 "Environment updated"
426 )}`
427 );
428});
429
430// ---------------------------------------------------------------------------
431// POST /:owner/:repo/settings/environments/:envId/delete
432// ---------------------------------------------------------------------------
433
434r.post(
435 "/:owner/:repo/settings/environments/:envId/delete",
436 requireAuth,
437 async (c) => {
438 const user = c.get("user")!;
439 const { owner, repo, envId } = c.req.param();
440 const repoRow = await loadRepo(owner, repo);
441 if (!repoRow) return c.notFound();
442 if (repoRow.ownerId !== user.id) {
443 return c.redirect(`/${owner}/${repo}`);
444 }
445
446 try {
447 await db
448 .delete(environments)
449 .where(
450 and(
451 eq(environments.id, envId),
452 eq(environments.repositoryId, repoRow.id)
453 )
454 );
455 } catch (err) {
456 console.error("[environments] delete failed:", err);
457 }
458
459 await audit({
460 userId: user.id,
461 repositoryId: repoRow.id,
462 action: "environment.delete",
463 targetType: "environment",
464 targetId: envId,
465 });
466
467 return c.redirect(
468 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
469 "Environment removed"
470 )}`
471 );
472 }
473);
474
475// ---------------------------------------------------------------------------
476// Approve/reject a pending deployment
477// ---------------------------------------------------------------------------
478
479async function loadDeployment(repositoryId: string, deploymentId: string) {
480 try {
481 const [row] = await db
482 .select()
483 .from(deployments)
484 .where(
485 and(
486 eq(deployments.id, deploymentId),
487 eq(deployments.repositoryId, repositoryId)
488 )
489 )
490 .limit(1);
491 return row || null;
492 } catch (err) {
493 console.error("[environments] loadDeployment failed:", err);
494 return null;
495 }
496}
497
498async function decide(
499 c: Context<AuthEnv>,
500 decision: "approved" | "rejected"
501) {
502 const user = c.get("user")!;
503 const { owner, repo, deploymentId } = c.req.param();
504 const repoRow = await loadRepo(owner, repo);
505 if (!repoRow) return c.notFound();
506
507 const deployment = await loadDeployment(repoRow.id, deploymentId);
508 if (!deployment) return c.notFound();
509
510 const envName = deployment.environment;
511 const env = await getEnvironmentByName(repoRow.id, envName);
512 if (!env) {
513 // No env configured — nothing to approve. Treat as 404 for safety.
514 return c.notFound();
515 }
516
517 const allowed = await isReviewer(env, user.id);
518 if (!allowed) {
519 return c.redirect(
520 `/${owner}/${repo}/deployments/${deploymentId}?error=${encodeURIComponent(
521 "Not a reviewer"
522 )}`
523 );
524 }
525
526 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
527 const comment = typeof body.comment === "string" ? body.comment : undefined;
528
529 const inserted = await recordApproval({
530 deploymentId,
531 userId: user.id,
532 decision,
533 comment,
534 });
535
a76d984Claude536 // Re-read state and flip the deployment row accordingly. When the env
537 // carries a wait timer and the timer hasn't elapsed yet, we hold the
538 // deploy in status="waiting_timer" with `readyAfter` populated; the
539 // autopilot ticker (`releaseExpiredWaitTimers`) flips it later.
25a91a6Claude540 const state = await computeApprovalState(deploymentId, env);
541 let newStatus: string | null = null;
a76d984Claude542 let readyAfter: Date | null = null;
543 let blockedReason: string | null = null;
25a91a6Claude544 if (state.rejected) {
545 newStatus = "rejected";
a76d984Claude546 blockedReason = "rejected by reviewer";
25a91a6Claude547 } else if (state.approved && deployment.status === "pending_approval") {
a76d984Claude548 const now = new Date();
549 if (state.readyAfter && state.readyAfter.getTime() > now.getTime()) {
550 newStatus = "waiting_timer";
551 readyAfter = state.readyAfter;
552 blockedReason = `wait_timer until ${state.readyAfter.toISOString()}`;
553 } else {
554 newStatus = "pending"; // hand off to existing deployer
555 }
25a91a6Claude556 }
557
558 if (newStatus) {
559 try {
560 await db
561 .update(deployments)
562 .set({
563 status: newStatus,
a76d984Claude564 blockedReason,
565 // Always overwrite readyAfter — clears any prior value when the
566 // status flips to anything other than waiting_timer.
567 readyAfter,
25a91a6Claude568 })
569 .where(eq(deployments.id, deploymentId));
570 } catch (err) {
571 console.error("[environments] deployment status flip failed:", err);
572 }
573 }
574
575 await audit({
576 userId: user.id,
577 repositoryId: repoRow.id,
578 action: decision === "approved" ? "deployment.approve" : "deployment.reject",
579 targetType: "deployment",
580 targetId: deploymentId,
581 metadata: { recorded: !!inserted, newStatus },
582 });
583
584 if (deployment.triggeredBy && deployment.triggeredBy !== user.id) {
585 try {
586 await notify(deployment.triggeredBy, {
587 kind: "deployment_approval",
588 title:
589 decision === "approved"
590 ? `Deploy to ${envName} approved`
591 : `Deploy to ${envName} rejected`,
592 body:
593 decision === "approved"
594 ? `${user.username} approved the deploy of ${deployment.commitSha.slice(0, 7)}.`
595 : `${user.username} rejected the deploy of ${deployment.commitSha.slice(0, 7)}.`,
596 url: `/${owner}/${repo}/deployments/${deploymentId}`,
597 repositoryId: repoRow.id,
598 });
599 } catch (err) {
600 console.error("[environments] notify triggeredBy failed:", err);
601 }
602 }
603
604 return c.redirect(`/${owner}/${repo}/deployments/${deploymentId}`);
605}
606
607r.post(
608 "/:owner/:repo/deployments/:deploymentId/approve",
609 requireAuth,
610 async (c) => decide(c, "approved")
611);
612
613r.post(
614 "/:owner/:repo/deployments/:deploymentId/reject",
615 requireAuth,
616 async (c) => decide(c, "rejected")
617);
618
619export default r;