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

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.tsBlame165 lines · 1 contributor
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";
23import { and, eq } from "drizzle-orm";
24import { db } from "../db";
25import { pullRequests, repositories, users } from "../db/schema";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { requireRepoAccess } from "../middleware/repo-access";
29import {
30 destroySandbox,
31 getSandboxForPr,
32 provisionSandbox,
33 sandboxStatusLabel,
34} from "../lib/pr-sandbox";
35
36const prSandboxRoutes = new Hono<AuthEnv>();
37
38/**
39 * Resolve `<owner>/<repo>` to a repository row, with an `ownerId`-matched
40 * users.username for permission checks. Mirrors the `resolveRepo` helper
41 * in pulls.tsx but kept private here so the two route files stay
42 * independent.
43 */
44async function resolveRepoRow(ownerName: string, repoName: string) {
45 const [owner] = await db
46 .select()
47 .from(users)
48 .where(eq(users.username, ownerName))
49 .limit(1);
50 if (!owner) return null;
51 const [repo] = await db
52 .select()
53 .from(repositories)
54 .where(
55 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
56 )
57 .limit(1);
58 if (!repo) return null;
59 return { owner, repo };
60}
61
62/** Resolve a PR by `(repoId, number)`. Returns null if not found. */
63async function resolvePr(repoId: string, n: number) {
64 if (!Number.isFinite(n)) return null;
65 const [pr] = await db
66 .select()
67 .from(pullRequests)
68 .where(
69 and(eq(pullRequests.repositoryId, repoId), eq(pullRequests.number, n))
70 )
71 .limit(1);
72 return pr ?? null;
73}
74
75/** Compact JSON shape returned by every endpoint. */
76function jsonShape(
77 row: Awaited<ReturnType<typeof getSandboxForPr>>
78): Record<string, unknown> | null {
79 if (!row) return null;
80 return {
81 id: row.id,
82 status: row.status,
83 statusLabel: sandboxStatusLabel(row.status),
84 sandboxUrl: row.sandboxUrl,
85 expiresAt: row.expiresAt?.toISOString?.() ?? null,
86 provisionedAt: row.provisionedAt?.toISOString?.() ?? null,
87 destroyedAt: row.destroyedAt?.toISOString?.() ?? null,
88 errorMessage: row.errorMessage,
89 };
90}
91
92// ---------------------------------------------------------------------------
93// POST /:owner/:repo/pulls/:number/sandbox/provision
94// ---------------------------------------------------------------------------
95prSandboxRoutes.post(
96 "/:owner/:repo/pulls/:number/sandbox/provision",
97 softAuth,
98 requireAuth,
99 requireRepoAccess("write"),
100 async (c) => {
101 const { owner: ownerName, repo: repoName } = c.req.param();
102 const n = parseInt(c.req.param("number"), 10);
103 const resolved = await resolveRepoRow(ownerName, repoName);
104 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
105 const pr = await resolvePr(resolved.repo.id, n);
106 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
107
108 const row = await provisionSandbox({ prId: pr.id });
109 if (!row) {
110 return c.json(
111 { ok: false, error: "Sandbox provisioning failed (DB unavailable?)" },
112 500
113 );
114 }
115 return c.json({ ok: true, sandbox: jsonShape(row) });
116 }
117);
118
119// ---------------------------------------------------------------------------
120// POST /:owner/:repo/pulls/:number/sandbox/destroy
121// ---------------------------------------------------------------------------
122prSandboxRoutes.post(
123 "/:owner/:repo/pulls/:number/sandbox/destroy",
124 softAuth,
125 requireAuth,
126 requireRepoAccess("write"),
127 async (c) => {
128 const { owner: ownerName, repo: repoName } = c.req.param();
129 const n = parseInt(c.req.param("number"), 10);
130 const resolved = await resolveRepoRow(ownerName, repoName);
131 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
132 const pr = await resolvePr(resolved.repo.id, n);
133 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
134
135 const existing = await getSandboxForPr(pr.id);
136 if (!existing) {
137 return c.json({ ok: true, sandbox: null });
138 }
139 await destroySandbox(existing.id);
140 const after = await getSandboxForPr(pr.id);
141 return c.json({ ok: true, sandbox: jsonShape(after) });
142 }
143);
144
145// ---------------------------------------------------------------------------
146// GET /:owner/:repo/pulls/:number/sandbox — JSON status
147// ---------------------------------------------------------------------------
148prSandboxRoutes.get(
149 "/:owner/:repo/pulls/:number/sandbox",
150 softAuth,
151 requireRepoAccess("read"),
152 async (c) => {
153 const { owner: ownerName, repo: repoName } = c.req.param();
154 const n = parseInt(c.req.param("number"), 10);
155 const resolved = await resolveRepoRow(ownerName, repoName);
156 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
157 const pr = await resolvePr(resolved.repo.id, n);
158 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
159
160 const row = await getSandboxForPr(pr.id);
161 return c.json({ ok: true, sandbox: jsonShape(row) });
162 }
163);
164
165export default prSandboxRoutes;