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

branch-previews.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.

branch-previews.tsBlame325 lines · 1 contributor
4bbacbeClaude1/**
2 * Per-branch preview URLs (migration 0062).
3 *
4 * Every push to a non-default branch enqueues a "preview build" row.
5 * For v1 the row + URL are the deliverable — actual hosting (Caddy /
6 * nginx vhost provisioning, container spin-up) is a follow-up. The
7 * preview URL is computed deterministically from the branch and repo
8 * names so it can be shown immediately, even while the build is still
9 * in flight or, in the no-hosting case, forever.
10 *
11 * URL pattern:
12 *
13 * https://${branchSlug}-${repoSlug}.preview.gluecron.com
14 *
15 * The domain suffix is configurable via the `PREVIEW_DOMAIN` env var so
16 * self-hosted installs can swap it for their own wildcard subdomain
17 * (e.g. `*.preview.acme.dev`). Owner and repo are slug-encoded so that
18 * branch names containing `/` (e.g. `feat/foo`) collapse safely into a
19 * single hostname label.
20 *
21 * Philosophy (mirrors workflow-runner.ts / post-receive.ts): never
22 * throw — every DB call is wrapped in try/catch so a Postgres outage
23 * cannot break the push path. Callers fire-and-forget.
24 */
25
26import { and, eq, lt } from "drizzle-orm";
27import { db } from "../db";
28import { branchPreviews, type BranchPreview } from "../db/schema";
29
30/** TTL since the last push to the branch. */
31const PREVIEW_TTL_MS = 24 * 60 * 60 * 1000;
32
33/**
34 * Slugify a string into a single DNS label.
35 * - lowercase
36 * - replace any non-alphanumeric run with `-`
37 * - strip leading/trailing `-`
38 * - clip to 50 chars (RFC 1035 says a label is <= 63; we leave headroom
39 * so the joined "${branch}-${repo}" still fits under 63 in most cases)
40 *
41 * Exported for tests + UI helpers.
42 */
43export function slugifyForUrl(value: string): string {
44 return (value || "")
45 .toString()
46 .toLowerCase()
47 .replace(/[^a-z0-9]+/g, "-")
48 .replace(/^-+|-+$/g, "")
49 .slice(0, 50);
50}
51
52/**
53 * Compute the preview URL for `<owner>/<repo>@<branch>`. Pure — exported
54 * so route handlers + the UI can render it without going through the DB.
55 */
56export function buildPreviewUrl(
57 ownerName: string,
58 repoName: string,
59 branchName: string
60): string {
61 const domain = (process.env.PREVIEW_DOMAIN || "preview.gluecron.com").replace(
62 /^https?:\/\//,
63 ""
64 );
65 const repoSlug = slugifyForUrl(`${ownerName}-${repoName}`);
66 const branchSlug = slugifyForUrl(branchName) || "branch";
67 return `https://${branchSlug}-${repoSlug}.${domain}`;
68}
69
70export interface EnqueueArgs {
71 repositoryId: string;
72 ownerName: string;
73 repoName: string;
74 branchName: string;
75 commitSha: string;
76 /** Override the computed URL — only used by tests. */
77 previewUrl?: string;
78 /** Override the "now" clock — only used by tests. */
79 now?: () => Date;
80}
81
82/**
83 * Upsert a preview-build row for the given branch.
84 *
85 * Pushing the same branch again replaces commit_sha, bumps
86 * build_started_at, resets status to 'building', and clears any prior
87 * error_message. The unique index on (repository_id, branch_name)
88 * guarantees there's exactly one row per branch.
89 *
90 * Returns the inserted/updated row, or `null` if the DB is unavailable
91 * or the underlying table is missing (graceful no-op).
92 */
93export async function enqueuePreviewBuild(
94 args: EnqueueArgs
95): Promise<BranchPreview | null> {
96 if (!args.repositoryId || !args.branchName || !args.commitSha) return null;
97 const now = (args.now ?? (() => new Date()))();
98 const expiresAt = new Date(now.getTime() + PREVIEW_TTL_MS);
99 const url =
100 args.previewUrl ??
101 buildPreviewUrl(args.ownerName, args.repoName, args.branchName);
102
103 try {
104 const [row] = await db
105 .insert(branchPreviews)
106 .values({
107 repositoryId: args.repositoryId,
108 branchName: args.branchName,
109 commitSha: args.commitSha,
110 previewUrl: url,
111 status: "building",
112 buildStartedAt: now,
113 buildCompletedAt: null,
114 expiresAt,
115 errorMessage: null,
116 })
117 .onConflictDoUpdate({
118 target: [branchPreviews.repositoryId, branchPreviews.branchName],
119 set: {
120 commitSha: args.commitSha,
121 previewUrl: url,
122 status: "building",
123 buildStartedAt: now,
124 buildCompletedAt: null,
125 expiresAt,
126 errorMessage: null,
127 },
128 })
129 .returning();
130 return row ?? null;
131 } catch (err) {
132 console.warn(
133 "[branch-previews] enqueue failed:",
134 err instanceof Error ? err.message : err
135 );
136 return null;
137 }
138}
139
140/**
141 * Look up the current preview row for `repo/branch`, or null if there
142 * isn't one. Used by the /previews list page + the PR detail pill.
143 */
144export async function getPreviewForBranch(
145 repositoryId: string,
146 branchName: string
147): Promise<BranchPreview | null> {
148 if (!repositoryId || !branchName) return null;
149 try {
150 const [row] = await db
151 .select()
152 .from(branchPreviews)
153 .where(
154 and(
155 eq(branchPreviews.repositoryId, repositoryId),
156 eq(branchPreviews.branchName, branchName)
157 )
158 )
159 .limit(1);
160 return row ?? null;
161 } catch {
162 return null;
163 }
164}
165
166/**
167 * Mark a preview as successfully built. `previewUrl` is optional — the
168 * URL is already recorded at enqueue time, but a hoster can update it
169 * here if it picked a different host (e.g. promoted to a custom domain).
170 */
171export async function markPreviewReady(
172 id: string,
173 previewUrl?: string,
174 now: () => Date = () => new Date()
175): Promise<void> {
176 if (!id) return;
177 try {
178 await db
179 .update(branchPreviews)
180 .set({
181 status: "ready",
182 buildCompletedAt: now(),
183 errorMessage: null,
184 ...(previewUrl ? { previewUrl } : {}),
185 })
186 .where(eq(branchPreviews.id, id));
187 } catch (err) {
188 console.warn(
189 "[branch-previews] markReady failed:",
190 err instanceof Error ? err.message : err
191 );
192 }
193}
194
195/**
196 * Mark a preview as failed and record the error. `error` is truncated
197 * to avoid blowing up the row on very large stack traces.
198 */
199export async function markPreviewFailed(
200 id: string,
201 error: string,
202 now: () => Date = () => new Date()
203): Promise<void> {
204 if (!id) return;
205 try {
206 await db
207 .update(branchPreviews)
208 .set({
209 status: "failed",
210 buildCompletedAt: now(),
211 errorMessage: (error || "").slice(0, 2_000),
212 })
213 .where(eq(branchPreviews.id, id));
214 } catch (err) {
215 console.warn(
216 "[branch-previews] markFailed failed:",
217 err instanceof Error ? err.message : err
218 );
219 }
220}
221
222/**
223 * Autopilot task: flip every active row whose `expires_at` is in the
224 * past to status='expired'. Already-expired/failed rows are not
225 * re-touched so the autopilot loop is cheap to run hourly. Returns the
226 * number of rows transitioned for observability.
227 */
228export async function expireOldPreviews(
229 now: () => Date = () => new Date()
230): Promise<number> {
231 try {
232 const rows = await db
233 .update(branchPreviews)
234 .set({ status: "expired" })
235 .where(
236 and(
237 lt(branchPreviews.expiresAt, now()),
238 // Only flip non-terminal-but-non-expired rows. We keep `failed`
239 // as-is so users still see why the last build failed.
240 eq(branchPreviews.status, "ready")
241 )
242 )
243 .returning({ id: branchPreviews.id });
244 // Also expire still-building rows that have been stuck past the TTL.
245 const stuck = await db
246 .update(branchPreviews)
247 .set({ status: "expired" })
248 .where(
249 and(
250 lt(branchPreviews.expiresAt, now()),
251 eq(branchPreviews.status, "building")
252 )
253 )
254 .returning({ id: branchPreviews.id });
255 return rows.length + stuck.length;
256 } catch (err) {
257 console.warn(
258 "[branch-previews] expireOldPreviews failed:",
259 err instanceof Error ? err.message : err
260 );
261 return 0;
262 }
263}
264
265/**
266 * List every preview row for a repo, newest first by build_started_at.
267 * Used by the /previews list page + the JSON API.
268 */
269export async function listPreviewsForRepo(
270 repositoryId: string,
271 limit = 100
272): Promise<BranchPreview[]> {
273 if (!repositoryId) return [];
274 try {
275 const rows = await db
276 .select()
277 .from(branchPreviews)
278 .where(eq(branchPreviews.repositoryId, repositoryId))
279 .limit(Math.max(1, Math.min(500, limit)));
280 // Sort in JS — the table is tiny per-repo, no need for an extra index.
281 rows.sort((a, b) => {
282 const at = a.buildStartedAt?.getTime?.() ?? 0;
283 const bt = b.buildStartedAt?.getTime?.() ?? 0;
284 return bt - at;
285 });
286 return rows;
287 } catch {
288 return [];
289 }
290}
291
292/**
293 * Compute a human-readable "expires in" label like "23h 14m" / "less
294 * than a minute" / "expired". Pure — used by the list view + API.
295 */
296export function formatExpiresIn(
297 expiresAt: Date | null | undefined,
298 now: Date = new Date()
299): string {
300 if (!expiresAt) return "—";
301 const ms = expiresAt.getTime() - now.getTime();
302 if (ms <= 0) return "expired";
303 const minutes = Math.floor(ms / 60_000);
304 if (minutes < 1) return "less than a minute";
305 const hours = Math.floor(minutes / 60);
306 const mins = minutes % 60;
307 if (hours <= 0) return `${minutes}m`;
308 return `${hours}h ${mins}m`;
309}
310
311/** Visible string for the status pill. */
312export function previewStatusLabel(status: string): string {
313 switch (status) {
314 case "building":
315 return "Building";
316 case "ready":
317 return "Ready";
318 case "failed":
319 return "Failed";
320 case "expired":
321 return "Expired";
322 default:
323 return status;
324 }
325}