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

preview-builder.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.

preview-builder.tsBlame312 lines · 1 contributor
1df50d5Claude1/**
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}