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.tsxBlame597 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.
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
170 method="POST"
171 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"
203 />
204 </div>
205 <div class="form-group" style="margin: 0">
206 <label>Wait timer (minutes)</label>
207 <input
208 type="number"
209 name="waitTimerMinutes"
210 min="0"
211 max="1440"
212 value={String(env.waitTimerMinutes)}
213 style="width: 120px"
214 />
215 </div>
216 <div class="form-group" style="margin: 0">
217 <label>Allowed branches (comma-separated glob patterns)</label>
218 <input
219 type="text"
220 name="allowedBranches"
221 value={branches.join(", ")}
222 placeholder="main, release/*"
223 />
224 </div>
225 <div style="display: flex; justify-content: flex-end">
226 <button
227 type="submit"
228 formaction={`/${owner}/${repo}/settings/environments/${env.id}/delete`}
229 class="btn btn-sm btn-danger"
230 onclick="return confirm('Delete this environment?')"
231 >
232 Delete
233 </button>
234 </div>
235 </form>
236 );
237 })
238 )}
239 </div>
240
241 <h3 style="margin-top: 24px; margin-bottom: 12px">New environment</h3>
242 <form
243 method="POST"
244 action={`/${owner}/${repo}/settings/environments`}
245 class="panel"
246 style="padding: 16px"
247 >
248 <div class="form-group">
249 <label>Name</label>
250 <input
251 type="text"
252 name="name"
253 required
254 placeholder="production"
255 />
256 </div>
257 <div class="form-group">
258 <label style="display: flex; align-items: center; gap: 6px">
259 <input
260 type="checkbox"
261 name="requireApproval"
262 value="1"
263 checked
264 />
265 Require approval
266 </label>
267 </div>
268 <div class="form-group">
269 <label>Reviewers (comma-separated usernames)</label>
270 <input type="text" name="reviewers" placeholder="alice, bob" />
271 </div>
272 <div class="form-group">
273 <label>Wait timer (minutes)</label>
274 <input
275 type="number"
276 name="waitTimerMinutes"
277 min="0"
278 max="1440"
279 value="0"
280 style="width: 120px"
281 />
282 </div>
283 <div class="form-group">
284 <label>Allowed branches (comma-separated glob patterns)</label>
285 <input
286 type="text"
287 name="allowedBranches"
288 placeholder="main, release/*"
289 />
290 </div>
291 <button type="submit" class="btn btn-primary">
292 Create environment
293 </button>
294 </form>
295 </Layout>
296 );
297});
298
299// ---------------------------------------------------------------------------
300// POST /:owner/:repo/settings/environments (create)
301// ---------------------------------------------------------------------------
302
303r.post("/:owner/:repo/settings/environments", requireAuth, async (c) => {
304 const user = c.get("user")!;
305 const { owner, repo } = c.req.param();
306 const repoRow = await loadRepo(owner, repo);
307 if (!repoRow) return c.notFound();
308 if (repoRow.ownerId !== user.id) {
309 return c.redirect(`/${owner}/${repo}`);
310 }
311
312 const body = await c.req.parseBody();
313 const name = String(body.name || "").trim();
314 if (!name) {
315 return c.redirect(
316 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
317 "Name required"
318 )}`
319 );
320 }
321 const requireApproval = body.requireApproval === "1" || body.requireApproval === "on";
322 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
323 const waitTimerMinutes = Math.max(
324 0,
325 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
326 );
327 const allowedBranches = splitCsv(body.allowedBranches);
328
329 try {
330 await db.insert(environments).values({
331 repositoryId: repoRow.id,
332 name,
333 requireApproval,
334 reviewers: JSON.stringify(reviewers),
335 waitTimerMinutes,
336 allowedBranches: JSON.stringify(allowedBranches),
337 });
338 } catch (err) {
339 console.error("[environments] create failed:", err);
340 return c.redirect(
341 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
342 "Could not create (duplicate name?)"
343 )}`
344 );
345 }
346
347 await audit({
348 userId: user.id,
349 repositoryId: repoRow.id,
350 action: "environment.create",
351 targetType: "environment",
352 metadata: { name, requireApproval, reviewers, allowedBranches },
353 });
354
355 return c.redirect(
356 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
357 "Environment created"
358 )}`
359 );
360});
361
362// ---------------------------------------------------------------------------
363// POST /:owner/:repo/settings/environments/:envId (update)
364// ---------------------------------------------------------------------------
365
366r.post("/:owner/:repo/settings/environments/:envId", requireAuth, async (c) => {
367 const user = c.get("user")!;
368 const { owner, repo, envId } = c.req.param();
369 const repoRow = await loadRepo(owner, repo);
370 if (!repoRow) return c.notFound();
371 if (repoRow.ownerId !== user.id) {
372 return c.redirect(`/${owner}/${repo}`);
373 }
374
375 const env = await getEnvironmentById(repoRow.id, envId);
376 if (!env) return c.notFound();
377
378 const body = await c.req.parseBody();
379 const requireApproval =
380 body.requireApproval === "1" || body.requireApproval === "on";
381 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
382 const waitTimerMinutes = Math.max(
383 0,
384 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
385 );
386 const allowedBranches = splitCsv(body.allowedBranches);
387
388 try {
389 await db
390 .update(environments)
391 .set({
392 requireApproval,
393 reviewers: JSON.stringify(reviewers),
394 waitTimerMinutes,
395 allowedBranches: JSON.stringify(allowedBranches),
396 updatedAt: new Date(),
397 })
398 .where(
399 and(
400 eq(environments.id, envId),
401 eq(environments.repositoryId, repoRow.id)
402 )
403 );
404 } catch (err) {
405 console.error("[environments] update failed:", err);
406 }
407
408 await audit({
409 userId: user.id,
410 repositoryId: repoRow.id,
411 action: "environment.update",
412 targetType: "environment",
413 targetId: envId,
414 metadata: { requireApproval, reviewers, allowedBranches, waitTimerMinutes },
415 });
416
417 return c.redirect(
418 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
419 "Environment updated"
420 )}`
421 );
422});
423
424// ---------------------------------------------------------------------------
425// POST /:owner/:repo/settings/environments/:envId/delete
426// ---------------------------------------------------------------------------
427
428r.post(
429 "/:owner/:repo/settings/environments/:envId/delete",
430 requireAuth,
431 async (c) => {
432 const user = c.get("user")!;
433 const { owner, repo, envId } = c.req.param();
434 const repoRow = await loadRepo(owner, repo);
435 if (!repoRow) return c.notFound();
436 if (repoRow.ownerId !== user.id) {
437 return c.redirect(`/${owner}/${repo}`);
438 }
439
440 try {
441 await db
442 .delete(environments)
443 .where(
444 and(
445 eq(environments.id, envId),
446 eq(environments.repositoryId, repoRow.id)
447 )
448 );
449 } catch (err) {
450 console.error("[environments] delete failed:", err);
451 }
452
453 await audit({
454 userId: user.id,
455 repositoryId: repoRow.id,
456 action: "environment.delete",
457 targetType: "environment",
458 targetId: envId,
459 });
460
461 return c.redirect(
462 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
463 "Environment removed"
464 )}`
465 );
466 }
467);
468
469// ---------------------------------------------------------------------------
470// Approve/reject a pending deployment
471// ---------------------------------------------------------------------------
472
473async function loadDeployment(repositoryId: string, deploymentId: string) {
474 try {
475 const [row] = await db
476 .select()
477 .from(deployments)
478 .where(
479 and(
480 eq(deployments.id, deploymentId),
481 eq(deployments.repositoryId, repositoryId)
482 )
483 )
484 .limit(1);
485 return row || null;
486 } catch (err) {
487 console.error("[environments] loadDeployment failed:", err);
488 return null;
489 }
490}
491
492async function decide(
493 c: Context<AuthEnv>,
494 decision: "approved" | "rejected"
495) {
496 const user = c.get("user")!;
497 const { owner, repo, deploymentId } = c.req.param();
498 const repoRow = await loadRepo(owner, repo);
499 if (!repoRow) return c.notFound();
500
501 const deployment = await loadDeployment(repoRow.id, deploymentId);
502 if (!deployment) return c.notFound();
503
504 const envName = deployment.environment;
505 const env = await getEnvironmentByName(repoRow.id, envName);
506 if (!env) {
507 // No env configured — nothing to approve. Treat as 404 for safety.
508 return c.notFound();
509 }
510
511 const allowed = await isReviewer(env, user.id);
512 if (!allowed) {
513 return c.redirect(
514 `/${owner}/${repo}/deployments/${deploymentId}?error=${encodeURIComponent(
515 "Not a reviewer"
516 )}`
517 );
518 }
519
520 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
521 const comment = typeof body.comment === "string" ? body.comment : undefined;
522
523 const inserted = await recordApproval({
524 deploymentId,
525 userId: user.id,
526 decision,
527 comment,
528 });
529
530 // Re-read state and flip the deployment row accordingly.
531 const state = await computeApprovalState(deploymentId, env);
532 let newStatus: string | null = null;
533 if (state.rejected) {
534 newStatus = "rejected";
535 } else if (state.approved && deployment.status === "pending_approval") {
536 newStatus = "pending"; // hand off to existing deployer
537 }
538
539 if (newStatus) {
540 try {
541 await db
542 .update(deployments)
543 .set({
544 status: newStatus,
545 blockedReason: newStatus === "rejected" ? "rejected by reviewer" : null,
546 })
547 .where(eq(deployments.id, deploymentId));
548 } catch (err) {
549 console.error("[environments] deployment status flip failed:", err);
550 }
551 }
552
553 await audit({
554 userId: user.id,
555 repositoryId: repoRow.id,
556 action: decision === "approved" ? "deployment.approve" : "deployment.reject",
557 targetType: "deployment",
558 targetId: deploymentId,
559 metadata: { recorded: !!inserted, newStatus },
560 });
561
562 if (deployment.triggeredBy && deployment.triggeredBy !== user.id) {
563 try {
564 await notify(deployment.triggeredBy, {
565 kind: "deployment_approval",
566 title:
567 decision === "approved"
568 ? `Deploy to ${envName} approved`
569 : `Deploy to ${envName} rejected`,
570 body:
571 decision === "approved"
572 ? `${user.username} approved the deploy of ${deployment.commitSha.slice(0, 7)}.`
573 : `${user.username} rejected the deploy of ${deployment.commitSha.slice(0, 7)}.`,
574 url: `/${owner}/${repo}/deployments/${deploymentId}`,
575 repositoryId: repoRow.id,
576 });
577 } catch (err) {
578 console.error("[environments] notify triggeredBy failed:", err);
579 }
580 }
581
582 return c.redirect(`/${owner}/${repo}/deployments/${deploymentId}`);
583}
584
585r.post(
586 "/:owner/:repo/deployments/:deploymentId/approve",
587 requireAuth,
588 async (c) => decide(c, "approved")
589);
590
591r.post(
592 "/:owner/:repo/deployments/:deploymentId/reject",
593 requireAuth,
594 async (c) => decide(c, "rejected")
595);
596
597export default r;