Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit1df50d5unknown_key

feat: PR preview environments — auto-build and serve every PR branch

feat: PR preview environments — auto-build and serve every PR branch

Adds native Vercel-style PR preview environments to Gluecron. When a
repo sets a preview_build_command, every PR open/push clones the head
branch, runs the command, serves the output, and posts a PR comment
with the preview URL.

Key changes:
- drizzle/0077_pr_preview_builder.sql: adds preview_build_command +
  preview_output_dir to repositories; creates pr_previews table
- src/db/schema.ts: Drizzle types for new columns + prPreviews table
- src/lib/config.ts: PREVIEW_DOMAIN getter
- src/lib/preview-builder.ts: clone → build → DB update → PR comment
- src/routes/previews.tsx: PR redirect, static file serving, rebuild API
- src/routes/pulls.tsx: fire-and-forget buildPreview on PR creation
- src/routes/repo-settings.tsx: preview build command + output dir form

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: da3fc18
7 files changed+753231df50d5228d77af7552f0ac99fa6ae4eeab186ce
7 changed files+753−23
Addeddrizzle/0087_pr_preview_builder.sql+35−0View fileUnifiedSplit
1-- Migration 0077 — PR preview builder.
2--
3-- Adds per-repo preview build configuration to the repositories table.
4-- When preview_build_command is set, every PR push triggers a build (clone +
5-- run command + serve output). The resulting static files are served from
6-- /previews/:owner/:repo/:branch/* by the preview route handler.
7--
8-- Also creates pr_previews, a PR-scoped sibling to the existing branch_previews
9-- table (migration 0062). branch_previews tracks one row per branch (upserted on
10-- every push); pr_previews tracks one row per PR with richer build metadata
11-- (log, build time, command used) and a status that is updated after each build.
12
13-- ── Per-repo preview build config ──────────────────────────────────────────
14ALTER TABLE repositories ADD COLUMN IF NOT EXISTS preview_build_command text;
15ALTER TABLE repositories ADD COLUMN IF NOT EXISTS preview_output_dir text DEFAULT 'dist';
16
17-- ── pr_previews ─────────────────────────────────────────────────────────────
18CREATE TABLE IF NOT EXISTS pr_previews (
19 id serial PRIMARY KEY,
20 repo_id text NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
21 pr_id text NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
22 branch_name text NOT NULL,
23 head_sha text NOT NULL,
24 status text NOT NULL DEFAULT 'building', -- building | ready | failed
25 build_log text,
26 preview_url text,
27 build_command text,
28 output_dir text DEFAULT 'dist',
29 build_duration_ms integer,
30 created_at timestamp DEFAULT now(),
31 updated_at timestamp DEFAULT now()
32);
33
34CREATE INDEX IF NOT EXISTS pr_previews_pr_id_idx ON pr_previews(pr_id);
35CREATE INDEX IF NOT EXISTS pr_previews_repo_id_idx ON pr_previews(repo_id);
Modifiedsrc/db/schema.ts+36−9View fileUnifiedSplit
233233 // env burns a container until the idle sweep tears it down; owners
234234 // must explicitly enable per-repo via repo-settings.
235235 devEnvsEnabled: boolean("dev_envs_enabled").default(false).notNull(),
236 // Migration 0077 — data residency region. 'us' = US East (default),
237 // 'eu' = Frankfurt (EU). EU data residency requires a Pro plan or
238 // higher. Future values (e.g. 'apac') are additive — no constraint
239 // at the DB level so we can extend without a new migration.
240236 dataRegion: text("data_region").default("us").notNull(),
237 previewBuildCommand: text("preview_build_command"),
238 previewOutputDir: text("preview_output_dir").default("dist"),
241239 },
242240 (table) => [
243241 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
42034201export type NewClaudeWebMessage = typeof claudeWebMessages.$inferInsert;
42044202
42054203// ---------------------------------------------------------------------------
4206// Migration 0077 — Enterprise leads (contact form submissions from /enterprise)
4204// Enterprise leads (contact form submissions from /enterprise)
42074205// ---------------------------------------------------------------------------
42084206
4209/**
4210 * Enterprise leads — one row per /enterprise contact form submission.
4211 * Sales team reads these directly or via a future CRM sync.
4212 */
42134207export const enterpriseLeads = pgTable(
42144208 "enterprise_leads",
42154209 {
42274221
42284222export type EnterpriseLead = typeof enterpriseLeads.$inferSelect;
42294223export type NewEnterpriseLead = typeof enterpriseLeads.$inferInsert;
4224
4225// ---------------------------------------------------------------------------
4226// PR preview builder — one row per (pr_id, head_sha)
4227// ---------------------------------------------------------------------------
4228export const prPreviews = pgTable(
4229 "pr_previews",
4230 {
4231 id: serial("id").primaryKey(),
4232 repoId: uuid("repo_id")
4233 .notNull()
4234 .references(() => repositories.id, { onDelete: "cascade" }),
4235 prId: uuid("pr_id")
4236 .notNull()
4237 .references(() => pullRequests.id, { onDelete: "cascade" }),
4238 branchName: text("branch_name").notNull(),
4239 headSha: text("head_sha").notNull(),
4240 status: text("status").notNull().default("building"),
4241 buildLog: text("build_log"),
4242 previewUrl: text("preview_url"),
4243 buildCommand: text("build_command"),
4244 outputDir: text("output_dir").default("dist"),
4245 buildDurationMs: integer("build_duration_ms"),
4246 createdAt: timestamp("created_at").defaultNow(),
4247 updatedAt: timestamp("updated_at").defaultNow(),
4248 },
4249 (table) => [
4250 index("pr_previews_pr_id_idx").on(table.prId),
4251 index("pr_previews_repo_id_idx").on(table.repoId),
4252 ]
4253);
4254
4255export type PrPreview = typeof prPreviews.$inferSelect;
4256export type NewPrPreview = typeof prPreviews.$inferInsert;
Modifiedsrc/lib/config.ts+10−1View fileUnifiedSplit
9191 get ociStorePath() {
9292 return process.env.OCI_STORE_PATH || join(process.cwd(), "oci-store");
9393 },
94 /**
95 * Base URL used to construct preview URLs for PR builds.
96 * When set, the preview-builder will run and serve static files; when unset,
97 * previews are URL-only (no build runs).
98 *
99 * Production: set to e.g. "https://previews.gluecron.com"
100 */
101 get previewDomain() {
102 return process.env.PREVIEW_DOMAIN || "";
103 },
94104 /**
95105 * WebAuthn relying-party ID (domain only, no scheme/port). Derived from
96106 * appBaseUrl unless overridden. Passkeys issued for one RP ID can't be
113123 return process.env.WEBAUTHN_RP_NAME || "gluecron";
114124 },
115125 /**
116<<<<<<< HEAD
117126 * Redis / Valkey connection URL for cross-instance SSE fan-out.
118127 * When set, `src/lib/sse.ts` uses Redis pub/sub so SSE events reach all
119128 * server instances behind the load balancer. Falls back to in-process
Addedsrc/lib/preview-builder.ts+312−0View fileUnifiedSplit
1/**
2 * PR preview builder (migration 0077).
3 *
4 * When a PR is opened or updated and the repo has `preview_build_command`
5 * configured, this module:
6 * 1. Clones the PR's head branch into /tmp/previews/<prId>-<shortSha>
7 * 2. Runs the configured build command with a 2-minute timeout
8 * 3. Captures stdout + stderr as the build log
9 * 4. On success: marks the pr_previews row as 'ready' and posts a PR comment
10 * 5. On failure: marks it 'failed' and stores the log
11 *
12 * Feature flag: preview builds only run when PREVIEW_DOMAIN env var is set
13 * AND the repo has preview_build_command configured.
14 *
15 * Philosophy: never throw — every DB + subprocess call is wrapped in
16 * try/catch so a failure cannot disrupt the PR creation path. Callers use
17 * `buildPreview(...).catch(() => {})` fire-and-forget style.
18 */
19
20import { eq, and } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repositories,
24 pullRequests,
25 prPreviews,
26 prComments,
27 users,
28} from "../db/schema";
29import { config } from "./config";
30
31const PREVIEW_BUILD_DIR = process.env.PREVIEW_BUILD_DIR || "/tmp/previews";
32const BUILD_TIMEOUT_MS = 2 * 60 * 1_000; // 2 minutes
33
34// ─── helpers ────────────────────────────────────────────────────────────────
35
36/** Slugify a string for use in a URL path segment. */
37function slug(s: string): string {
38 return (s || "")
39 .toLowerCase()
40 .replace(/[^a-z0-9]+/g, "-")
41 .replace(/^-+|-+$/g, "")
42 .slice(0, 60);
43}
44
45/** Compute the public URL for a built preview. */
46export function previewBuilderUrl(
47 ownerName: string,
48 repoName: string,
49 branchName: string
50): string {
51 const domain = config.previewDomain || config.appBaseUrl;
52 return `${domain}/previews/${ownerName}/${repoName}/${slug(branchName)}/`;
53}
54
55/** The on-disk directory where built output lives. */
56export function previewBuildPath(
57 prId: string,
58 headSha: string,
59 outputDir: string
60): string {
61 const shortSha = headSha.slice(0, 8);
62 return `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}/${outputDir}`;
63}
64
65// ─── core builder ───────────────────────────────────────────────────────────
66
67/**
68 * Build a preview for the given PR. Fire-and-forget by callers:
69 * `buildPreview(prId, repoId, headSha).catch(() => {})`
70 */
71export async function buildPreview(
72 prId: string,
73 repoId: string,
74 headSha: string
75): Promise<void> {
76 // Feature flag: only run when PREVIEW_DOMAIN is set
77 if (!config.previewDomain) return;
78
79 // ── look up the repo for build config ──
80 let repo: {
81 id: string;
82 name: string;
83 ownerId: string;
84 previewBuildCommand: string | null;
85 previewOutputDir: string | null;
86 diskPath: string;
87 } | null = null;
88
89 let ownerUsername = "";
90
91 try {
92 const [row] = await db
93 .select({
94 id: repositories.id,
95 name: repositories.name,
96 ownerId: repositories.ownerId,
97 previewBuildCommand: repositories.previewBuildCommand,
98 previewOutputDir: repositories.previewOutputDir,
99 diskPath: repositories.diskPath,
100 })
101 .from(repositories)
102 .where(eq(repositories.id, repoId))
103 .limit(1);
104 if (!row) return;
105 repo = row;
106
107 // Resolve owner username for URL construction
108 const [ownerRow] = await db
109 .select({ username: users.username })
110 .from(users)
111 .where(eq(users.id, repo.ownerId))
112 .limit(1);
113 ownerUsername = ownerRow?.username ?? "";
114 } catch (err) {
115 console.warn("[preview-builder] repo lookup failed:", err instanceof Error ? err.message : err);
116 return;
117 }
118
119 // Opt-in gate: skip if no build command configured
120 if (!repo.previewBuildCommand) return;
121
122 // ── look up the PR for branch name ──
123 let pr: { id: string; headBranch: string; number: number } | null = null;
124 try {
125 const [row] = await db
126 .select({ id: pullRequests.id, headBranch: pullRequests.headBranch, number: pullRequests.number })
127 .from(pullRequests)
128 .where(eq(pullRequests.id, prId))
129 .limit(1);
130 if (!row) return;
131 pr = row;
132 } catch (err) {
133 console.warn("[preview-builder] pr lookup failed:", err instanceof Error ? err.message : err);
134 return;
135 }
136
137 const buildCommand = repo.previewBuildCommand;
138 const outputDir = repo.previewOutputDir || "dist";
139 const shortSha = headSha.slice(0, 8);
140 const previewUrl = previewBuilderUrl(ownerUsername, repo.name, pr.headBranch);
141 const buildDir = `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}`;
142
143 // ── upsert preview row to 'building' ──
144 let previewRowId: number | null = null;
145 try {
146 const [row] = await db
147 .insert(prPreviews)
148 .values({
149 repoId,
150 prId,
151 branchName: pr.headBranch,
152 headSha,
153 status: "building",
154 previewUrl,
155 buildCommand,
156 outputDir,
157 createdAt: new Date(),
158 updatedAt: new Date(),
159 })
160 .onConflictDoNothing()
161 .returning({ id: prPreviews.id });
162 // If there's already a row (same prId+headSha isn't unique, but let's handle it)
163 if (row) {
164 previewRowId = row.id;
165 } else {
166 // Find existing
167 const [existing] = await db
168 .select({ id: prPreviews.id })
169 .from(prPreviews)
170 .where(and(eq(prPreviews.prId, prId), eq(prPreviews.headSha, headSha)))
171 .limit(1);
172 previewRowId = existing?.id ?? null;
173 }
174 } catch (err) {
175 console.warn("[preview-builder] insert failed:", err instanceof Error ? err.message : err);
176 return;
177 }
178
179 // ── clone + build ──
180 const buildStart = Date.now();
181 let buildLog = "";
182 let buildOk = false;
183
184 try {
185 // Step 1: clone the bare repo and checkout the head branch
186 const cloneProc = Bun.spawn(
187 ["git", "clone", "--branch", pr.headBranch, "--depth", "1", repo.diskPath, buildDir],
188 { stderr: "pipe", stdout: "pipe" }
189 );
190
191 const cloneStdout = await new Response(cloneProc.stdout).text();
192 const cloneStderr = await new Response(cloneProc.stderr).text();
193 await cloneProc.exited;
194
195 buildLog += `=== clone ===\n${cloneStdout}${cloneStderr}\n`;
196
197 if (cloneProc.exitCode !== 0) {
198 throw new Error(`git clone failed (exit ${cloneProc.exitCode}): ${cloneStderr.slice(0, 500)}`);
199 }
200
201 // Step 2: run the build command with timeout
202 const buildProc = Bun.spawn(
203 ["sh", "-c", buildCommand],
204 {
205 cwd: buildDir,
206 stderr: "pipe",
207 stdout: "pipe",
208 env: { ...process.env, CI: "1" },
209 }
210 );
211
212 // Apply 2-minute timeout
213 const timeoutHandle = setTimeout(() => {
214 try { buildProc.kill(); } catch {}
215 }, BUILD_TIMEOUT_MS);
216
217 const buildStdout = await new Response(buildProc.stdout).text();
218 const buildStderr = await new Response(buildProc.stderr).text();
219 await buildProc.exited;
220 clearTimeout(timeoutHandle);
221
222 buildLog += `\n=== build: ${buildCommand} ===\n${buildStdout}${buildStderr}\n`;
223
224 if (buildProc.exitCode !== 0) {
225 throw new Error(`build command failed (exit ${buildProc.exitCode})`);
226 }
227
228 buildOk = true;
229 } catch (err) {
230 buildLog += `\n=== error ===\n${err instanceof Error ? err.message : String(err)}\n`;
231 }
232
233 const durationMs = Date.now() - buildStart;
234
235 // ── update preview row ──
236 try {
237 if (previewRowId !== null) {
238 await db
239 .update(prPreviews)
240 .set({
241 status: buildOk ? "ready" : "failed",
242 buildLog: buildLog.slice(0, 50_000),
243 previewUrl: buildOk ? previewUrl : null,
244 buildDurationMs: durationMs,
245 updatedAt: new Date(),
246 })
247 .where(eq(prPreviews.id, previewRowId));
248 }
249 } catch (err) {
250 console.warn("[preview-builder] update failed:", err instanceof Error ? err.message : err);
251 }
252
253 // ── post a PR comment with the result ──
254 try {
255 // Use the system bot user (first admin user, or fall back to the PR author)
256 const [authorRow] = await db
257 .select({ authorId: pullRequests.authorId })
258 .from(pullRequests)
259 .where(eq(pullRequests.id, prId))
260 .limit(1);
261
262 if (authorRow) {
263 const buildTimeS = Math.round(durationMs / 1000);
264 const body = buildOk
265 ? `🚀 **Preview deployed**\nURL: ${previewUrl}\nBuilt from: \`${shortSha}\`\nBuild time: ${buildTimeS}s`
266 : `❌ **Preview build failed**\nCommit: \`${shortSha}\`\nBuild time: ${buildTimeS}s\n\nSee build log in the [Previews tab](/${ownerUsername}/${repo.name}/previews).`;
267
268 await db.insert(prComments).values({
269 pullRequestId: prId,
270 authorId: authorRow.authorId,
271 body,
272 isAiReview: false,
273 moderationStatus: "approved",
274 createdAt: new Date(),
275 updatedAt: new Date(),
276 });
277 }
278 } catch (err) {
279 console.warn("[preview-builder] comment failed:", err instanceof Error ? err.message : err);
280 }
281}
282
283/**
284 * Look up the most recent pr_previews row for a given PR.
285 * Returns null if none exists or the table doesn't exist yet.
286 */
287export async function getPreviewForPr(prId: string): Promise<{
288 id: number;
289 status: string;
290 previewUrl: string | null;
291 headSha: string;
292 buildDurationMs: number | null;
293} | null> {
294 if (!prId) return null;
295 try {
296 const [row] = await db
297 .select({
298 id: prPreviews.id,
299 status: prPreviews.status,
300 previewUrl: prPreviews.previewUrl,
301 headSha: prPreviews.headSha,
302 buildDurationMs: prPreviews.buildDurationMs,
303 })
304 .from(prPreviews)
305 .where(eq(prPreviews.prId, prId))
306 .orderBy(prPreviews.id)
307 .limit(1);
308 return row ?? null;
309 } catch {
310 return null;
311 }
312}
Modifiedsrc/routes/previews.tsx+236−13View fileUnifiedSplit
11/**
2 * Per-branch preview URLs — list view (migration 0062).
2 * Per-branch / per-PR preview URLs (migrations 0062, 0077).
33 *
4 * GET /:owner/:repo/previews
5 *
6 * Lists every live preview row for the repo (one per branch). Status
7 * pills, mono branch names, short SHAs, clickable URLs, expires-in
8 * countdowns. Empty state when no pushes have been made to a
9 * non-default branch yet.
4 * GET /:owner/:repo/previews — list view
5 * GET /:owner/:repo/pull/:prNumber/preview — redirect to live preview for this PR
6 * GET /previews/:owner/:repo/:prBranch/* — serve built static files
7 * POST /api/previews/rebuild/:prId — trigger a rebuild (webhook)
108 *
119 * All page-local CSS is scoped under `.preview-*` so it can't bleed
1210 * into the shared layout (per CLAUDE.md: do NOT modify shared
1311 * layout/components/ui). Mirrors the gradient hairline + orb pattern
1412 * used by environments.tsx / admin-integrations.tsx.
15 *
16 * The corresponding JSON API + force-rebuild endpoints live in
17 * src/routes/api-v2.ts.
1813 */
1914
2015import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
16import { and, eq, desc } from "drizzle-orm";
2217import { db } from "../db";
23import { repositories, users } from "../db/schema";
24import { softAuth } from "../middleware/auth";
18import { repositories, users, pullRequests, prPreviews } from "../db/schema";
19import { softAuth, requireAuth } from "../middleware/auth";
2520import type { AuthEnv } from "../middleware/auth";
2621import { Layout } from "../views/layout";
2722import { RepoHeader, RepoNav } from "../views/components";
3126 listPreviewsForRepo,
3227 previewStatusLabel,
3328} from "../lib/branch-previews";
29import { buildPreview } from "../lib/preview-builder";
30import { join } from "path";
31import { existsSync } from "fs";
32
33/** Minimal MIME type lookup for common web assets. */
34function getMimeType(filePath: string): string {
35 const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
36 const map: Record<string, string> = {
37 html: "text/html; charset=utf-8",
38 htm: "text/html; charset=utf-8",
39 css: "text/css",
40 js: "application/javascript",
41 mjs: "application/javascript",
42 json: "application/json",
43 png: "image/png",
44 jpg: "image/jpeg",
45 jpeg: "image/jpeg",
46 gif: "image/gif",
47 svg: "image/svg+xml",
48 ico: "image/x-icon",
49 woff: "font/woff",
50 woff2: "font/woff2",
51 ttf: "font/ttf",
52 txt: "text/plain",
53 xml: "application/xml",
54 webp: "image/webp",
55 avif: "image/avif",
56 mp4: "video/mp4",
57 webm: "video/webm",
58 pdf: "application/pdf",
59 wasm: "application/wasm",
60 };
61 return map[ext] || "application/octet-stream";
62}
3463
3564const r = new Hono<AuthEnv>();
3665r.use("*", softAuth);
557586 );
558587});
559588
589// ─── PR preview redirect ────────────────────────────────────────────────────
590// GET /:owner/:repo/pull/:prNumber/preview
591// Redirect to the live preview URL for this PR (from pr_previews table).
592// Falls back to the previews list if no ready build exists.
593r.get("/:owner/:repo/pull/:prNumber/preview", softAuth, async (c) => {
594 const { owner, repo, prNumber } = c.req.param();
595 const num = parseInt(prNumber, 10);
596
597 try {
598 // Resolve repo
599 const [repoRow] = await db
600 .select({ id: repositories.id })
601 .from(repositories)
602 .innerJoin(users, eq(repositories.ownerId, users.id))
603 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
604 .limit(1);
605 if (!repoRow) return c.notFound();
606
607 // Find the PR
608 const [pr] = await db
609 .select({ id: pullRequests.id })
610 .from(pullRequests)
611 .where(and(eq(pullRequests.repositoryId, repoRow.id), eq(pullRequests.number, num)))
612 .limit(1);
613 if (!pr) return c.notFound();
614
615 // Find the most recent ready preview for this PR
616 const [preview] = await db
617 .select({ previewUrl: prPreviews.previewUrl, status: prPreviews.status })
618 .from(prPreviews)
619 .where(and(eq(prPreviews.prId, pr.id), eq(prPreviews.status, "ready")))
620 .orderBy(desc(prPreviews.id))
621 .limit(1);
622
623 if (preview?.previewUrl) {
624 return c.redirect(preview.previewUrl, 302);
625 }
626 } catch (err) {
627 console.warn("[previews] PR preview redirect failed:", err instanceof Error ? err.message : err);
628 }
629
630 // Fall back to the previews list page
631 return c.redirect(`/${owner}/${repo}/previews`, 302);
632});
633
634// ─── Static file serving ────────────────────────────────────────────────────
635// GET /previews/:owner/:repo/:prBranch/*
636// Serves built output from the temp build directory created by preview-builder.
637// Only active when PREVIEW_DOMAIN is set (or when the dev fallback applies).
638r.get("/previews/:owner/:repo/:prBranch/*", async (c) => {
639 const { owner, repo, prBranch } = c.req.param();
640 const wildcard = c.req.param("*") || "";
641
642 // Resolve the most recent ready pr_previews row for this branch
643 try {
644 const [repoRow] = await db
645 .select({ id: repositories.id })
646 .from(repositories)
647 .innerJoin(users, eq(repositories.ownerId, users.id))
648 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
649 .limit(1);
650 if (!repoRow) return c.notFound();
651
652 const [preview] = await db
653 .select({
654 prId: prPreviews.prId,
655 headSha: prPreviews.headSha,
656 outputDir: prPreviews.outputDir,
657 status: prPreviews.status,
658 branchName: prPreviews.branchName,
659 })
660 .from(prPreviews)
661 .where(
662 and(
663 eq(prPreviews.repoId, repoRow.id),
664 eq(prPreviews.status, "ready"),
665 eq(prPreviews.branchName, prBranch)
666 )
667 )
668 .orderBy(desc(prPreviews.id))
669 .limit(1);
670
671 if (!preview || preview.status !== "ready") {
672 return c.text("Preview not ready yet", 404);
673 }
674
675 const outputDir = preview.outputDir || "dist";
676 // The build dir pattern from preview-builder.ts:
677 // /tmp/previews/<slug(prId)>-<shortSha>/<outputDir>
678 // We stored prId in the row, so read it back and reconstruct.
679 const PREVIEW_BUILD_DIR = process.env.PREVIEW_BUILD_DIR || "/tmp/previews";
680 const shortSha = preview.headSha.slice(0, 8);
681 const prIdSlug = preview.prId.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60);
682 const buildBase = `${PREVIEW_BUILD_DIR}/${prIdSlug}-${shortSha}/${outputDir}`;
683
684 let filePath = wildcard ? join(buildBase, wildcard) : join(buildBase, "index.html");
685
686 // Prevent directory traversal
687 if (!filePath.startsWith(buildBase)) {
688 return c.text("Forbidden", 403);
689 }
690
691 // Directory → serve index.html
692 if (existsSync(filePath) && !filePath.endsWith("/")) {
693 // Check if it's a directory
694 try {
695 const stat = await Bun.file(filePath).exists();
696 if (!stat && existsSync(join(filePath, "index.html"))) {
697 filePath = join(filePath, "index.html");
698 }
699 } catch {}
700 }
701
702 if (!existsSync(filePath)) {
703 // Try with index.html appended
704 const indexPath = join(filePath, "index.html");
705 if (existsSync(indexPath)) {
706 filePath = indexPath;
707 } else {
708 return c.text("File not found", 404);
709 }
710 }
711
712 const file = Bun.file(filePath);
713 const contentType = getMimeType(filePath);
714
715 return new Response(file, {
716 headers: {
717 "Content-Type": contentType,
718 "Cache-Control": "public, max-age=300",
719 "X-Preview-Sha": preview.headSha.slice(0, 8),
720 },
721 });
722 } catch (err) {
723 console.warn("[previews] static serve failed:", err instanceof Error ? err.message : err);
724 return c.text("Preview unavailable", 500);
725 }
726});
727
728// ─── Rebuild trigger API ─────────────────────────────────────────────────────
729// POST /api/previews/rebuild/:prId
730// Triggers a fresh build for the given PR. Auth required (repo write access).
731r.post("/api/previews/rebuild/:prId", requireAuth, async (c) => {
732 const { prId } = c.req.param();
733 const user = c.get("user");
734
735 try {
736 const [pr] = await db
737 .select({
738 id: pullRequests.id,
739 repositoryId: pullRequests.repositoryId,
740 headBranch: pullRequests.headBranch,
741 authorId: pullRequests.authorId,
742 })
743 .from(pullRequests)
744 .where(eq(pullRequests.id, prId))
745 .limit(1);
746
747 if (!pr) return c.json({ error: "PR not found" }, 404);
748
749 // Only the PR author or repo owner can trigger a rebuild
750 if (user!.id !== pr.authorId) {
751 const [repo] = await db
752 .select({ ownerId: repositories.ownerId })
753 .from(repositories)
754 .where(eq(repositories.id, pr.repositoryId))
755 .limit(1);
756 if (!repo || repo.ownerId !== user!.id) {
757 return c.json({ error: "Unauthorized" }, 403);
758 }
759 }
760
761 // Get the current head SHA from the most recent preview row, or use a sentinel
762 const [existing] = await db
763 .select({ headSha: prPreviews.headSha })
764 .from(prPreviews)
765 .where(eq(prPreviews.prId, prId))
766 .orderBy(desc(prPreviews.id))
767 .limit(1);
768
769 const headSha = existing?.headSha ?? "unknown";
770
771 // Fire-and-forget rebuild
772 buildPreview(prId, pr.repositoryId, headSha).catch((err) =>
773 console.warn("[previews] rebuild failed:", err instanceof Error ? err.message : err)
774 );
775
776 return c.json({ ok: true, message: "Rebuild triggered" });
777 } catch (err) {
778 console.warn("[previews] rebuild endpoint failed:", err instanceof Error ? err.message : err);
779 return c.json({ error: "Internal error" }, 500);
780 }
781});
782
560783export default r;
Modifiedsrc/routes/pulls.tsx+12−0View fileUnifiedSplit
32333233 );
32343234 });
32353235
3236 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3237 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3238 // Resolve head SHA asynchronously so we don't block the redirect.
3239 resolveRef(ownerName, repoName, headBranch)
3240 .then((headSha) => {
3241 if (!headSha) return;
3242 return import("../lib/preview-builder").then((m) =>
3243 m.buildPreview(pr.id, resolved.repo.id, headSha)
3244 );
3245 })
3246 .catch(() => {});
3247
32363248 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
32373249 }
32383250);
Modifiedsrc/routes/repo-settings.tsx+112−0View fileUnifiedSplit
10251025 </form>
10261026 </section>
10271027
1028 {/* ─── PR preview builds (migration 0077) ─── */}
1029 <section class="repo-settings-section">
1030 <div class="repo-settings-section-head">
1031 <div class="repo-settings-section-eyebrow">Preview builds</div>
1032 <h2 class="repo-settings-section-title">PR preview environments</h2>
1033 <p class="repo-settings-section-desc">
1034 When a build command is set, every PR push automatically clones
1035 the branch, runs the command, and serves the built output from a
1036 unique preview URL — like Vercel, but native to Gluecron. Leave
1037 blank to use URL-only previews (no build runs).
1038 </p>
1039 </div>
1040 <form
1041 method="post"
1042 action={`/${ownerName}/${repoName}/settings/previews`}
1043 >
1044 <div class="repo-settings-section-body">
1045 <div class="repo-settings-field">
1046 <label class="repo-settings-field-label" for="preview_build_command">
1047 Build command
1048 </label>
1049 <input
1050 id="preview_build_command"
1051 class="repo-settings-input"
1052 type="text"
1053 name="preview_build_command"
1054 placeholder="npm run build"
1055 value={
1056 (repo as { previewBuildCommand?: string | null }).previewBuildCommand ?? ""
1057 }
1058 />
1059 <p class="repo-settings-field-hint">
1060 e.g. <code>npm run build</code>, <code>bun run build</code>, or{" "}
1061 <code>hugo</code>. Runs inside the cloned branch directory.
1062 </p>
1063 </div>
1064 <div class="repo-settings-field">
1065 <label class="repo-settings-field-label" for="preview_output_dir">
1066 Output directory
1067 </label>
1068 <input
1069 id="preview_output_dir"
1070 class="repo-settings-input"
1071 type="text"
1072 name="preview_output_dir"
1073 placeholder="dist"
1074 value={
1075 (repo as { previewOutputDir?: string | null }).previewOutputDir ?? "dist"
1076 }
1077 />
1078 <p class="repo-settings-field-hint">
1079 The directory your build writes to. Common values:{" "}
1080 <code>dist</code>, <code>public</code>, <code>out</code>,{" "}
1081 <code>build</code>. Served from{" "}
1082 <code>/previews/{ownerName}/{repoName}/{"<branch>"}/</code>
1083 </p>
1084 </div>
1085 </div>
1086 <div class="repo-settings-section-foot">
1087 <button type="submit" class="repo-settings-cta">
1088 Save preview settings <span class="arrow"></span>
1089 </button>
1090 </div>
1091 </form>
1092 </section>
1093
10281094 {/* ─── Danger zone ─── */}
10291095 <section class="repo-settings-danger">
10301096 <div class="repo-settings-danger-head">
14731539 }
14741540);
14751541
1542// Migration 0077 — PR preview build configuration. Owner-only.
1543repoSettings.post(
1544 "/:owner/:repo/settings/previews",
1545 requireAuth,
1546 requireRepoAccess("admin"),
1547 async (c) => {
1548 const { owner: ownerName, repo: repoName } = c.req.param();
1549 const user = c.get("user")!;
1550 const body = await c.req.parseBody();
1551
1552 const [owner] = await db
1553 .select()
1554 .from(users)
1555 .where(eq(users.username, ownerName))
1556 .limit(1);
1557 if (!owner || owner.id !== user.id) {
1558 return c.redirect(`/${ownerName}/${repoName}`);
1559 }
1560
1561 const [repo] = await db
1562 .select()
1563 .from(repositories)
1564 .where(
1565 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1566 )
1567 .limit(1);
1568 if (!repo) return c.notFound();
1569
1570 const buildCommand = String(body.preview_build_command || "").trim() || null;
1571 const outputDir = String(body.preview_output_dir || "").trim() || "dist";
1572
1573 await db
1574 .update(repositories)
1575 .set({
1576 previewBuildCommand: buildCommand,
1577 previewOutputDir: outputDir,
1578 updatedAt: new Date(),
1579 })
1580 .where(eq(repositories.id, repo.id));
1581
1582 return c.redirect(
1583 `/${ownerName}/${repoName}/settings?success=Preview+build+settings+saved`
1584 );
1585 }
1586);
1587
14761588export default repoSettings;
14771589