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-stage.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-stage.tsBlame516 lines · 1 contributor
09d5f39Claude1/**
2 * /stage slash-command — deploys a per-PR preview environment.
3 *
4 * When a user comments `/stage` on any PR, this module:
5 * 1. Detects the repo's framework (next.js, bun, docker, static, node)
6 * 2. For static/nextjs repos: runs `bun run build` in a temporary worktree
7 * 3. Falls back to a built-in static file server at GET /preview/:stageJobId/*
8 * when no cloud deploy provider is configured
9 * 4. Posts a reply comment with the live URL (or an error)
10 *
11 * All stage jobs are held in memory with a 4-hour TTL. Static previews
12 * additionally write files to ${GIT_REPOS_PATH}/.stage-previews/${id}/ and
13 * are served for 48 hours via src/routes/pulls.tsx (previewRoute).
14 *
15 * No new npm packages — uses only Bun built-ins.
16 */
17
18import { join } from "path";
19import { db } from "../db";
20import { pullRequests, prComments, repositories, users } from "../db/schema";
21import { eq, and } from "drizzle-orm";
22import { config } from "./config";
23import { getRepoPath } from "../git/repository";
24
25// ---------------------------------------------------------------------------
26// Types
27// ---------------------------------------------------------------------------
28
29export interface StageJob {
30 id: string;
31 prId: string;
32 repoId: string;
33 status: "queued" | "detecting" | "deploying" | "live" | "failed";
34 framework?: "nextjs" | "node" | "bun" | "static" | "docker";
35 previewUrl?: string;
36 error?: string;
37 startedAt: string;
38 liveAt?: string;
39}
40
41// ---------------------------------------------------------------------------
42// In-memory store (4 h TTL)
43// ---------------------------------------------------------------------------
44
45const STAGE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
46
47const stageJobs = new Map<
48 string,
49 { job: StageJob; expiresAt: number }
50>();
51
52// Key: prId → jobId (so we can detect existing active jobs per PR)
53const prToJobId = new Map<string, string>();
54
55function getJob(id: string): StageJob | null {
56 const entry = stageJobs.get(id);
57 if (!entry) return null;
58 if (Date.now() > entry.expiresAt) {
59 stageJobs.delete(id);
60 return null;
61 }
62 return entry.job;
63}
64
65function setJob(job: StageJob): void {
66 stageJobs.set(job.id, {
67 job,
68 expiresAt: Date.now() + STAGE_TTL_MS,
69 });
70}
71
72// ---------------------------------------------------------------------------
73// Git helper
74// ---------------------------------------------------------------------------
75
76async function git(
77 args: string[],
78 cwd: string
79): Promise<{ stdout: string; stderr: string; exitCode: number }> {
80 const proc = Bun.spawn(["git", ...args], {
81 cwd,
82 stdout: "pipe",
83 stderr: "pipe",
84 });
85 const [stdout, stderr] = await Promise.all([
86 new Response(proc.stdout).text(),
87 new Response(proc.stderr).text(),
88 ]);
89 const exitCode = await proc.exited;
90 return { stdout, stderr, exitCode };
91}
92
93// ---------------------------------------------------------------------------
94// Framework detection
95// ---------------------------------------------------------------------------
96
97async function detectFramework(
98 ownerName: string,
99 repoName: string
100): Promise<StageJob["framework"]> {
101 const repoDir = getRepoPath(ownerName, repoName);
102 // List all files (HEAD) — bare repo so we use ls-tree
103 const { stdout } = await git(
104 ["ls-tree", "-r", "--name-only", "HEAD"],
105 repoDir
106 );
107 const files = stdout.trim().split("\n").filter(Boolean);
108
109 const hasFile = (name: string) =>
110 files.some((f) => f === name || f.endsWith(`/${name}`));
111 const hasPattern = (re: RegExp) => files.some((f) => re.test(f));
112
113 if (hasPattern(/^next\.config\.(js|ts|mjs|cjs)$/)) return "nextjs";
114
115 // Check package.json for bun engine
116 if (hasFile("package.json")) {
117 try {
118 const { stdout: blob } = await git(
119 ["show", `HEAD:package.json`],
120 repoDir
121 );
122 const pkg = JSON.parse(blob) as Record<string, unknown>;
123 const engines = pkg.engines as Record<string, string> | undefined;
124 if (engines && typeof engines.bun === "string") return "bun";
125 } catch {
126 /* ignore parse errors */
127 }
128 }
129
130 if (hasFile("Dockerfile")) return "docker";
131 if (hasFile("index.html")) return "static";
132
133 return "node";
134}
135
136// ---------------------------------------------------------------------------
137// Static file serving helpers
138// ---------------------------------------------------------------------------
139
140const PREVIEW_SERVE_DIR_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
141const previewExpiry = new Map<string, number>(); // jobId → expiresAt
142
143export function getPreviewDir(jobId: string): string {
144 return join(config.gitReposPath, ".stage-previews", jobId);
145}
146
147export function markPreviewExpiry(jobId: string): void {
148 previewExpiry.set(jobId, Date.now() + PREVIEW_SERVE_DIR_TTL_MS);
149}
150
151export function isPreviewExpired(jobId: string): boolean {
152 const exp = previewExpiry.get(jobId);
153 if (exp === undefined) return true;
154 return Date.now() > exp;
155}
156
157// ---------------------------------------------------------------------------
158// Post a PR comment as the system (bot) user — inserts directly into DB
159// ---------------------------------------------------------------------------
160
161async function postPrComment(prId: string, body: string): Promise<void> {
162 // Find the repo owner to use as the author (best-effort)
163 const [pr] = await db
164 .select({ authorId: pullRequests.authorId })
165 .from(pullRequests)
166 .where(eq(pullRequests.id, prId))
167 .limit(1);
168 if (!pr) return;
169
170 await db.insert(prComments).values({
171 pullRequestId: prId,
172 authorId: pr.authorId,
173 body,
174 moderationStatus: "approved",
175 });
176}
177
178// ---------------------------------------------------------------------------
179// Build a static preview — checkout HEAD into a worktree, optionally build
180// ---------------------------------------------------------------------------
181
182async function buildStaticPreview(
183 ownerName: string,
184 repoName: string,
185 framework: StageJob["framework"],
186 jobId: string
187): Promise<{ ok: boolean; error?: string }> {
188 const repoDir = getRepoPath(ownerName, repoName);
189 const outputDir = getPreviewDir(jobId);
190
191 // Create a temporary worktree
192 const worktreeDir = join(
193 config.gitReposPath,
194 ".stage-worktrees",
195 `${jobId}_${Date.now()}`
196 );
197
198 try {
199 // Create worktree (detached HEAD at HEAD commit)
200 const wt = await git(
201 ["worktree", "add", "--detach", worktreeDir, "HEAD"],
202 repoDir
203 );
204 if (wt.exitCode !== 0) {
205 return { ok: false, error: wt.stderr.trim() || "Failed to create worktree" };
206 }
207
208 // For nextjs/node/bun — try to build
209 if (framework === "nextjs" || framework === "bun" || framework === "node") {
210 // Check if package.json exists before attempting build
211 const hasPackageJson = await Bun.file(
212 join(worktreeDir, "package.json")
213 ).exists();
214 if (hasPackageJson) {
215 const install = Bun.spawn(["bun", "install", "--frozen-lockfile"], {
216 cwd: worktreeDir,
217 stdout: "pipe",
218 stderr: "pipe",
219 });
220 await install.exited; // best-effort
221
222 const build = Bun.spawn(["bun", "run", "build"], {
223 cwd: worktreeDir,
224 stdout: "pipe",
225 stderr: "pipe",
226 });
227 const buildExit = await build.exited;
228 if (buildExit !== 0) {
229 // Build failed — try to serve static files from the worktree as-is
230 // (some projects don't have a build step)
231 }
232 }
233 }
234
235 // Determine what to copy into outputDir
236 // nextjs: .next/static or out/ or build/
237 // others: dist/ or public/ or . (whole worktree)
238 const candidateDirs: string[] = [];
239 if (framework === "nextjs") {
240 candidateDirs.push(
241 join(worktreeDir, "out"),
242 join(worktreeDir, ".next", "static"),
243 join(worktreeDir, "build")
244 );
245 }
246 candidateDirs.push(
247 join(worktreeDir, "dist"),
248 join(worktreeDir, "public"),
249 join(worktreeDir, "_site"),
250 worktreeDir
251 );
252
253 // Find first candidate that has an index.html
254 let sourceDir: string | null = null;
255 for (const candidate of candidateDirs) {
256 try {
257 const indexExists = await Bun.file(
258 join(candidate, "index.html")
259 ).exists();
260 if (indexExists) {
261 sourceDir = candidate;
262 break;
263 }
264 } catch {
265 /* skip */
266 }
267 }
268
269 if (!sourceDir) {
270 // Copy the entire worktree
271 sourceDir = worktreeDir;
272 }
273
274 // Recursively copy sourceDir → outputDir
275 const copyResult = await copyDir(sourceDir, outputDir);
276 if (!copyResult.ok) {
277 return { ok: false, error: copyResult.error };
278 }
279
280 markPreviewExpiry(jobId);
281 return { ok: true };
282 } finally {
283 // Clean up worktree
284 await git(["worktree", "remove", "--force", worktreeDir], repoDir).catch(
285 () => {}
286 );
287 }
288}
289
290// ---------------------------------------------------------------------------
291// Simple recursive directory copy using Bun
292// ---------------------------------------------------------------------------
293
294async function copyDir(
295 src: string,
296 dst: string
297): Promise<{ ok: boolean; error?: string }> {
298 try {
299 // Ensure destination directory exists first
300 const mkdir = Bun.spawn(["mkdir", "-p", dst], {
301 stdout: "pipe",
302 stderr: "pipe",
303 });
304 await mkdir.exited;
305
306 const proc = Bun.spawn(["cp", "-r", src + "/.", dst], {
307 stdout: "pipe",
308 stderr: "pipe",
309 });
310 // Drain stdout to prevent deadlock
311 const [, stderr, exitCode] = await Promise.all([
312 new Response(proc.stdout).text(),
313 new Response(proc.stderr).text(),
314 proc.exited,
315 ]);
316 if (exitCode !== 0) {
317 return {
318 ok: false,
319 error: stderr.trim() || `cp exited ${exitCode}`,
320 };
321 }
322 return { ok: true };
323 } catch (err) {
324 return {
325 ok: false,
326 error: err instanceof Error ? err.message : String(err),
327 };
328 }
329}
330
331// ---------------------------------------------------------------------------
332// Main trigger function
333// ---------------------------------------------------------------------------
334
335export async function triggerStage(
336 prId: string,
337 _triggeredByUserId: string
338): Promise<StageJob> {
339 // Return existing active job if one exists for this PR
340 const existingJobId = prToJobId.get(prId);
341 if (existingJobId) {
342 const existing = getJob(existingJobId);
343 if (
344 existing &&
345 (existing.status === "live" || existing.status === "deploying" || existing.status === "detecting")
346 ) {
347 return existing;
348 }
349 }
350
351 // Create new job
352 const jobId = crypto.randomUUID();
353 const now = new Date().toISOString();
354
355 const job: StageJob = {
356 id: jobId,
357 prId,
358 repoId: "",
359 status: "queued",
360 startedAt: now,
361 };
362
363 setJob(job);
364 prToJobId.set(prId, jobId);
365
366 // Run the pipeline asynchronously (fire-and-forget from caller's perspective)
367 runStagePipeline(job).catch((err) => {
368 job.status = "failed";
369 job.error = err instanceof Error ? err.message : String(err);
370 setJob(job);
371 });
372
373 return job;
374}
375
376async function runStagePipeline(job: StageJob): Promise<void> {
377 const startMs = Date.now();
378
379 // ── 1. Load PR + repo info ──────────────────────────────────────────────
380 const [pr] = await db
381 .select({
382 id: pullRequests.id,
383 repositoryId: pullRequests.repositoryId,
384 })
385 .from(pullRequests)
386 .where(eq(pullRequests.id, job.prId))
387 .limit(1);
388
389 if (!pr) {
390 job.status = "failed";
391 job.error = "PR not found";
392 setJob(job);
393 return;
394 }
395
396 job.repoId = pr.repositoryId;
397
398 const [repoRow] = await db
399 .select({
400 name: repositories.name,
401 ownerId: repositories.ownerId,
402 })
403 .from(repositories)
404 .where(eq(repositories.id, pr.repositoryId))
405 .limit(1);
406
407 if (!repoRow) {
408 job.status = "failed";
409 job.error = "Repository not found";
410 setJob(job);
411 return;
412 }
413
414 const [ownerRow] = await db
415 .select({ username: users.username })
416 .from(users)
417 .where(eq(users.id, repoRow.ownerId))
418 .limit(1);
419
420 if (!ownerRow) {
421 job.status = "failed";
422 job.error = "Repository owner not found";
423 setJob(job);
424 return;
425 }
426
427 const ownerName = ownerRow.username;
428 const repoName = repoRow.name;
429
430 // ── 2. Detect framework ─────────────────────────────────────────────────
431 job.status = "detecting";
432 setJob(job);
433
434 let framework: StageJob["framework"];
435 try {
436 framework = await detectFramework(ownerName, repoName);
437 } catch (err) {
438 framework = "node";
439 }
440 job.framework = framework;
441
442 // ── 3. Deploy ───────────────────────────────────────────────────────────
443 job.status = "deploying";
444 setJob(job);
445
446 // If it's docker, we can't easily build/run it locally — tell the user
447 if (framework === "docker") {
448 await postPrComment(
449 job.prId,
450 "<!-- cmd:stage -->\n\n**Preview not available** — Docker-based projects require a configured deployment provider. " +
451 "Set `CRONTECH_DEPLOY_URL` in repo settings to enable staging."
452 );
453 job.status = "failed";
454 job.error = "Docker projects require an external deploy provider";
455 setJob(job);
456 return;
457 }
458
459 // Use built-in static file server (fallback)
460 const previewBaseUrl = config.previewDomain || config.appBaseUrl;
461 const previewUrl = `${previewBaseUrl}/preview/${job.id}/index.html`;
462
463 const buildResult = await buildStaticPreview(
464 ownerName,
465 repoName,
466 framework,
467 job.id
468 );
469
470 if (!buildResult.ok) {
471 // Post "preview not available" comment
472 await postPrComment(
473 job.prId,
474 `<!-- cmd:stage -->\n\n**Preview not available** — could not build the project: ${buildResult.error}\n\n` +
475 `Configure a deployment provider in repo settings, or add an \`index.html\` for static hosting.`
476 );
477 job.status = "failed";
478 job.error = buildResult.error;
479 setJob(job);
480 return;
481 }
482
483 // ── 4. Reply ─────────────────────────────────────────────────────────────
484 job.status = "live";
485 job.previewUrl = previewUrl;
486 job.liveAt = new Date().toISOString();
487 setJob(job);
488
489 const elapsedSec = Math.round((Date.now() - startMs) / 1000);
490 const frameworkLabel =
491 framework === "nextjs"
492 ? "Next.js"
493 : framework === "bun"
494 ? "Bun"
495 : framework === "static"
496 ? "Static"
497 : framework === "node"
498 ? "Node.js"
499 : framework ?? "Unknown";
500
501 await postPrComment(
502 job.prId,
503 `<!-- cmd:stage -->\n\n` +
504 `**Preview deployed!**\n\n` +
505 `[View preview →](${previewUrl})\n\n` +
506 `Framework detected: **${frameworkLabel}** · Deployed in **${elapsedSec}s**`
507 );
508}
509
510// ---------------------------------------------------------------------------
511// Look up a job by ID (used by the preview route)
512// ---------------------------------------------------------------------------
513
514export function getStageJob(jobId: string): StageJob | null {
515 return getJob(jobId);
516}