Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

pr-sandbox.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pr-sandbox.tsBlame166 lines · 2 contributors
79ed944Claude1/**
2 * PR sandbox routes (migration 0067).
3 *
4 * Exposes the runnable per-PR sandbox lifecycle to the web + API:
5 *
6 * POST /:owner/:repo/pulls/:number/sandbox/provision — owner triggers
7 * POST /:owner/:repo/pulls/:number/sandbox/destroy — owner triggers
8 * GET /:owner/:repo/pulls/:number/sandbox — JSON status poll
9 *
10 * Provision returns JSON `{ ok, sandbox }` so the PR detail page can show
11 * an inline status without a hard reload. Destroy flips the row to
12 * 'destroyed' and returns JSON. The GET poll is anonymous-safe (read
13 * access only) so a public PR's sandbox status can be shown to drive-by
14 * viewers without forcing a login.
15 *
16 * No view rendering happens here — the PR detail page lives in pulls.tsx
17 * and reads the same `getSandboxForPr` helper for its server-rendered
18 * card. That keeps this file free of JSX + the locked layout/component
19 * surfaces.
20 */
21
22import { Hono } from "hono";
fc623bfccantynz-alt23import { parseIdNumber } from "../lib/route-params";
79ed944Claude24import { and, eq } from "drizzle-orm";
25import { db } from "../db";
26import { pullRequests, repositories, users } from "../db/schema";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { requireRepoAccess } from "../middleware/repo-access";
30import {
31 destroySandbox,
32 getSandboxForPr,
33 provisionSandbox,
34 sandboxStatusLabel,
35} from "../lib/pr-sandbox";
36
37const prSandboxRoutes = new Hono<AuthEnv>();
38
39/**
40 * Resolve `<owner>/<repo>` to a repository row, with an `ownerId`-matched
41 * users.username for permission checks. Mirrors the `resolveRepo` helper
42 * in pulls.tsx but kept private here so the two route files stay
43 * independent.
44 */
45async function resolveRepoRow(ownerName: string, repoName: string) {
46 const [owner] = await db
47 .select()
48 .from(users)
49 .where(eq(users.username, ownerName))
50 .limit(1);
51 if (!owner) return null;
52 const [repo] = await db
53 .select()
54 .from(repositories)
55 .where(
56 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
57 )
58 .limit(1);
59 if (!repo) return null;
60 return { owner, repo };
61}
62
63/** Resolve a PR by `(repoId, number)`. Returns null if not found. */
64async function resolvePr(repoId: string, n: number) {
65 if (!Number.isFinite(n)) return null;
66 const [pr] = await db
67 .select()
68 .from(pullRequests)
69 .where(
70 and(eq(pullRequests.repositoryId, repoId), eq(pullRequests.number, n))
71 )
72 .limit(1);
73 return pr ?? null;
74}
75
76/** Compact JSON shape returned by every endpoint. */
77function jsonShape(
78 row: Awaited<ReturnType<typeof getSandboxForPr>>
79): Record<string, unknown> | null {
80 if (!row) return null;
81 return {
82 id: row.id,
83 status: row.status,
84 statusLabel: sandboxStatusLabel(row.status),
85 sandboxUrl: row.sandboxUrl,
86 expiresAt: row.expiresAt?.toISOString?.() ?? null,
87 provisionedAt: row.provisionedAt?.toISOString?.() ?? null,
88 destroyedAt: row.destroyedAt?.toISOString?.() ?? null,
89 errorMessage: row.errorMessage,
90 };
91}
92
93// ---------------------------------------------------------------------------
94// POST /:owner/:repo/pulls/:number/sandbox/provision
95// ---------------------------------------------------------------------------
96prSandboxRoutes.post(
97 "/:owner/:repo/pulls/:number/sandbox/provision",
98 softAuth,
99 requireAuth,
100 requireRepoAccess("write"),
101 async (c) => {
102 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt103 const n = (parseIdNumber(c.req.param("number")) ?? -1);
79ed944Claude104 const resolved = await resolveRepoRow(ownerName, repoName);
105 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
106 const pr = await resolvePr(resolved.repo.id, n);
107 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
108
109 const row = await provisionSandbox({ prId: pr.id });
110 if (!row) {
111 return c.json(
112 { ok: false, error: "Sandbox provisioning failed (DB unavailable?)" },
113 500
114 );
115 }
116 return c.json({ ok: true, sandbox: jsonShape(row) });
117 }
118);
119
120// ---------------------------------------------------------------------------
121// POST /:owner/:repo/pulls/:number/sandbox/destroy
122// ---------------------------------------------------------------------------
123prSandboxRoutes.post(
124 "/:owner/:repo/pulls/:number/sandbox/destroy",
125 softAuth,
126 requireAuth,
127 requireRepoAccess("write"),
128 async (c) => {
129 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt130 const n = (parseIdNumber(c.req.param("number")) ?? -1);
79ed944Claude131 const resolved = await resolveRepoRow(ownerName, repoName);
132 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
133 const pr = await resolvePr(resolved.repo.id, n);
134 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
135
136 const existing = await getSandboxForPr(pr.id);
137 if (!existing) {
138 return c.json({ ok: true, sandbox: null });
139 }
140 await destroySandbox(existing.id);
141 const after = await getSandboxForPr(pr.id);
142 return c.json({ ok: true, sandbox: jsonShape(after) });
143 }
144);
145
146// ---------------------------------------------------------------------------
147// GET /:owner/:repo/pulls/:number/sandbox — JSON status
148// ---------------------------------------------------------------------------
149prSandboxRoutes.get(
150 "/:owner/:repo/pulls/:number/sandbox",
151 softAuth,
152 requireRepoAccess("read"),
153 async (c) => {
154 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt155 const n = (parseIdNumber(c.req.param("number")) ?? -1);
79ed944Claude156 const resolved = await resolveRepoRow(ownerName, repoName);
157 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
158 const pr = await resolvePr(resolved.repo.id, n);
159 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
160
161 const row = await getSandboxForPr(pr.id);
162 return c.json({ ok: true, sandbox: jsonShape(row) });
163 }
164);
165
166export default prSandboxRoutes;