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.
| 4bbacbe | 1 | /** |
| 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 | ||
| 26 | import { and, eq, lt } from "drizzle-orm"; | |
| 27 | import { db } from "../db"; | |
| 28 | import { branchPreviews, type BranchPreview } from "../db/schema"; | |
| 29 | ||
| 30 | /** TTL since the last push to the branch. */ | |
| 31 | const 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 | */ | |
| 43 | export 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 | */ | |
| 56 | export 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 | ||
| 70 | export 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 | */ | |
| 93 | export 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 | */ | |
| 144 | export 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 | */ | |
| 171 | export 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 | */ | |
| 199 | export 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 | */ | |
| 228 | export 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 | */ | |
| 269 | export 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 | */ | |
| 296 | export 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. */ | |
| 312 | export 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 | } |