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

feat(previews): per-branch preview URLs + auto-expire + PR pill + /previews list

Claude committed on May 25, 2026Parent: 39193f6
9 files changed+158704bbacbe8b5e91f1742525740e63adacbfda514b6
9 changed files+1587−0
Addeddrizzle/0062_branch_previews.sql+73−0View fileUnifiedSplit
1-- Gluecron migration 0062: per-branch preview URLs.
2--
3-- Every push to a non-default branch enqueues a build row that, once
4-- finished, holds the canonical "preview URL" that links to a fresh
5-- copy of the branch. The row is the unit of dedupe (one row per
6-- repo/branch pair) — pushing the branch again replaces commit_sha,
7-- bumps build_started_at, and resets status to 'building'.
8--
9-- TTL: previews automatically transition to status='expired' 24h after
10-- the last push to that branch. The autopilot expireOldPreviews() task
11-- sweeps the table hourly.
12--
13-- Wrapped in DO blocks so the migration is safe to re-run and gracefully
14-- ignores duplicates / missing parents on partial replays. The whole
15-- feature degrades to "no rows" when the table is missing, which is
16-- exactly what we want for environments without preview hosting.
17
18--> statement-breakpoint
19DO $$
20BEGIN
21 CREATE TABLE IF NOT EXISTS "branch_previews" (
22 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
23 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
24 "branch_name" text NOT NULL,
25 "commit_sha" text NOT NULL,
26 "preview_url" text NOT NULL,
27 "status" text NOT NULL DEFAULT 'building',
28 "build_started_at" timestamptz NOT NULL DEFAULT now(),
29 "build_completed_at" timestamptz,
30 "expires_at" timestamptz NOT NULL DEFAULT (now() + interval '24 hours'),
31 "error_message" text,
32 "created_at" timestamptz NOT NULL DEFAULT now()
33 );
34EXCEPTION WHEN OTHERS THEN
35 RAISE NOTICE 'branch_previews create failed (%); preview URLs will be unavailable', SQLERRM;
36END $$;
37
38--> statement-breakpoint
39DO $$
40BEGIN
41 CREATE UNIQUE INDEX IF NOT EXISTS "branch_previews_repo_branch"
42 ON "branch_previews" ("repository_id", "branch_name");
43EXCEPTION WHEN OTHERS THEN
44 RAISE NOTICE 'branch_previews_repo_branch index failed (%)', SQLERRM;
45END $$;
46
47--> statement-breakpoint
48DO $$
49BEGIN
50 CREATE INDEX IF NOT EXISTS "branch_previews_repo_status"
51 ON "branch_previews" ("repository_id", "status");
52EXCEPTION WHEN OTHERS THEN
53 RAISE NOTICE 'branch_previews_repo_status index failed (%)', SQLERRM;
54END $$;
55
56--> statement-breakpoint
57DO $$
58BEGIN
59 CREATE INDEX IF NOT EXISTS "branch_previews_expires"
60 ON "branch_previews" ("expires_at")
61 WHERE "status" IN ('building', 'ready');
62EXCEPTION WHEN OTHERS THEN
63 RAISE NOTICE 'branch_previews_expires index failed (%)', SQLERRM;
64END $$;
65
66--> statement-breakpoint
67DO $$
68BEGIN
69 ALTER TABLE "repositories"
70 ADD COLUMN IF NOT EXISTS "preview_builds_enabled" boolean NOT NULL DEFAULT true;
71EXCEPTION WHEN OTHERS THEN
72 RAISE NOTICE 'repositories.preview_builds_enabled add failed (%)', SQLERRM;
73END $$;
Addedsrc/__tests__/branch-previews.test.ts+409−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/branch-previews.ts — per-branch preview URLs.
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — URL slugging, expires-in formatting, status
7 * label mapping. No DB, no network. Always run.
8 *
9 * 2. DB-backed pipeline — gated on HAS_DB so the suite stays green
10 * on machines without Postgres. Covers:
11 * - enqueue creates a row
12 * - re-push to the same branch DEDUPES (replaces commit_sha + URL,
13 * resets status to 'building')
14 * - markPreviewReady / markPreviewFailed flip status as expected
15 * - expireOldPreviews flips expired rows to 'expired'
16 * - listPreviewsForRepo / getPreviewForBranch round-trip
17 */
18
19import { afterEach, describe, expect, it } from "bun:test";
20import {
21 buildPreviewUrl,
22 enqueuePreviewBuild,
23 expireOldPreviews,
24 formatExpiresIn,
25 getPreviewForBranch,
26 listPreviewsForRepo,
27 markPreviewFailed,
28 markPreviewReady,
29 previewStatusLabel,
30 slugifyForUrl,
31} from "../lib/branch-previews";
32import { db } from "../db";
33import { branchPreviews, repositories, users } from "../db/schema";
34import { eq } from "drizzle-orm";
35
36const HAS_DB = Boolean(process.env.DATABASE_URL);
37
38// ---------------------------------------------------------------------------
39// 1. Pure helpers
40// ---------------------------------------------------------------------------
41
42describe("branch-previews — slugifyForUrl", () => {
43 it("lowercases and replaces non-alphanumerics with dashes", () => {
44 expect(slugifyForUrl("Feature/Branch_42")).toBe("feature-branch-42");
45 });
46
47 it("strips leading + trailing dashes", () => {
48 expect(slugifyForUrl("--foo--")).toBe("foo");
49 });
50
51 it("clips to 50 characters", () => {
52 const long = "a".repeat(200);
53 expect(slugifyForUrl(long).length).toBe(50);
54 });
55
56 it("returns empty string for empty input", () => {
57 expect(slugifyForUrl("")).toBe("");
58 });
59});
60
61describe("branch-previews — buildPreviewUrl", () => {
62 it("produces a wildcard subdomain URL with default domain", () => {
63 const prev = process.env.PREVIEW_DOMAIN;
64 delete process.env.PREVIEW_DOMAIN;
65 try {
66 const url = buildPreviewUrl("alice", "site", "feat/new");
67 expect(url).toBe("https://feat-new-alice-site.preview.gluecron.com");
68 } finally {
69 if (prev !== undefined) process.env.PREVIEW_DOMAIN = prev;
70 }
71 });
72
73 it("honours a custom PREVIEW_DOMAIN env var", () => {
74 const prev = process.env.PREVIEW_DOMAIN;
75 process.env.PREVIEW_DOMAIN = "preview.acme.dev";
76 try {
77 const url = buildPreviewUrl("a", "b", "main-thing");
78 expect(url).toBe("https://main-thing-a-b.preview.acme.dev");
79 } finally {
80 if (prev === undefined) delete process.env.PREVIEW_DOMAIN;
81 else process.env.PREVIEW_DOMAIN = prev;
82 }
83 });
84
85 it("falls back to 'branch' when branch slug is empty", () => {
86 const url = buildPreviewUrl("a", "b", "///");
87 expect(url).toContain("branch-");
88 });
89
90 it("strips scheme from PREVIEW_DOMAIN if accidentally included", () => {
91 const prev = process.env.PREVIEW_DOMAIN;
92 process.env.PREVIEW_DOMAIN = "https://preview.foo.com";
93 try {
94 const url = buildPreviewUrl("a", "b", "c");
95 expect(url).toBe("https://c-a-b.preview.foo.com");
96 } finally {
97 if (prev === undefined) delete process.env.PREVIEW_DOMAIN;
98 else process.env.PREVIEW_DOMAIN = prev;
99 }
100 });
101});
102
103describe("branch-previews — formatExpiresIn", () => {
104 it("returns 'expired' for past timestamps", () => {
105 const now = new Date("2026-01-01T12:00:00Z");
106 const past = new Date("2026-01-01T11:00:00Z");
107 expect(formatExpiresIn(past, now)).toBe("expired");
108 });
109
110 it("returns 'less than a minute' for sub-minute futures", () => {
111 const now = new Date("2026-01-01T12:00:00Z");
112 const soon = new Date("2026-01-01T12:00:30Z");
113 expect(formatExpiresIn(soon, now)).toBe("less than a minute");
114 });
115
116 it("returns just minutes when under an hour away", () => {
117 const now = new Date("2026-01-01T12:00:00Z");
118 const future = new Date("2026-01-01T12:42:00Z");
119 expect(formatExpiresIn(future, now)).toBe("42m");
120 });
121
122 it("returns hours + minutes when over an hour away", () => {
123 const now = new Date("2026-01-01T12:00:00Z");
124 const future = new Date("2026-01-02T08:30:00Z");
125 expect(formatExpiresIn(future, now)).toBe("20h 30m");
126 });
127
128 it("returns em-dash for null", () => {
129 expect(formatExpiresIn(null)).toBe("—");
130 });
131});
132
133describe("branch-previews — previewStatusLabel", () => {
134 it("maps known statuses to human labels", () => {
135 expect(previewStatusLabel("building")).toBe("Building");
136 expect(previewStatusLabel("ready")).toBe("Ready");
137 expect(previewStatusLabel("failed")).toBe("Failed");
138 expect(previewStatusLabel("expired")).toBe("Expired");
139 });
140
141 it("passes through unknown statuses unchanged", () => {
142 expect(previewStatusLabel("weird")).toBe("weird");
143 });
144});
145
146// ---------------------------------------------------------------------------
147// 2. Graceful no-ops without DB — must not throw on empty/missing inputs
148// ---------------------------------------------------------------------------
149
150describe("branch-previews — graceful no-ops", () => {
151 it("enqueuePreviewBuild returns null for missing required fields", async () => {
152 expect(
153 await enqueuePreviewBuild({
154 repositoryId: "",
155 ownerName: "a",
156 repoName: "b",
157 branchName: "c",
158 commitSha: "d",
159 })
160 ).toBeNull();
161 expect(
162 await enqueuePreviewBuild({
163 repositoryId: "x",
164 ownerName: "a",
165 repoName: "b",
166 branchName: "",
167 commitSha: "d",
168 })
169 ).toBeNull();
170 expect(
171 await enqueuePreviewBuild({
172 repositoryId: "x",
173 ownerName: "a",
174 repoName: "b",
175 branchName: "c",
176 commitSha: "",
177 })
178 ).toBeNull();
179 });
180
181 it("getPreviewForBranch returns null for empty inputs", async () => {
182 expect(await getPreviewForBranch("", "main")).toBeNull();
183 expect(await getPreviewForBranch("repo-id", "")).toBeNull();
184 });
185
186 it("listPreviewsForRepo returns [] for empty repository id", async () => {
187 expect(await listPreviewsForRepo("")).toEqual([]);
188 });
189
190 it("markPreviewReady / markPreviewFailed swallow empty ids", async () => {
191 await markPreviewReady("");
192 await markPreviewFailed("", "err");
193 // No throw = pass.
194 expect(true).toBe(true);
195 });
196});
197
198// ---------------------------------------------------------------------------
199// 3. DB-backed pipeline
200// ---------------------------------------------------------------------------
201
202const TEST_USER_PREFIX = "previewtest_";
203
204async function seedRepo(): Promise<{ userId: string; repoId: string }> {
205 const username =
206 TEST_USER_PREFIX + Math.random().toString(36).slice(2, 10);
207 const [user] = await db
208 .insert(users)
209 .values({
210 username,
211 email: `${username}@previewtest.local`,
212 passwordHash: "$2b$10$" + "x".repeat(53),
213 })
214 .returning();
215 const [repo] = await db
216 .insert(repositories)
217 .values({
218 name: "previews-test-" + Math.random().toString(36).slice(2, 8),
219 ownerId: user!.id,
220 diskPath: "/tmp/previews-test-" + Math.random().toString(36).slice(2, 8),
221 })
222 .returning();
223 return { userId: user!.id, repoId: repo!.id };
224}
225
226async function cleanupRepo(userId: string): Promise<void> {
227 try {
228 await db.delete(repositories).where(eq(repositories.ownerId, userId));
229 await db.delete(users).where(eq(users.id, userId));
230 } catch {
231 /* best effort */
232 }
233}
234
235describe.skipIf(!HAS_DB)("branch-previews — DB pipeline", () => {
236 let userId = "";
237 let repoId = "";
238
239 afterEach(async () => {
240 if (userId) await cleanupRepo(userId);
241 userId = "";
242 repoId = "";
243 });
244
245 it("enqueue creates a building row with a computed preview URL", async () => {
246 ({ userId, repoId } = await seedRepo());
247
248 const row = await enqueuePreviewBuild({
249 repositoryId: repoId,
250 ownerName: "alice",
251 repoName: "site",
252 branchName: "feat/a",
253 commitSha: "abc1234567890",
254 });
255 expect(row).not.toBeNull();
256 expect(row!.status).toBe("building");
257 expect(row!.branchName).toBe("feat/a");
258 expect(row!.commitSha).toBe("abc1234567890");
259 expect(row!.previewUrl).toContain("feat-a-");
260 expect(row!.previewUrl.startsWith("https://")).toBe(true);
261 expect(row!.expiresAt instanceof Date).toBe(true);
262 expect(row!.expiresAt.getTime()).toBeGreaterThan(Date.now());
263 });
264
265 it("re-pushing the same branch DEDUPES — same id, new SHA, status='building'", async () => {
266 ({ userId, repoId } = await seedRepo());
267
268 const first = await enqueuePreviewBuild({
269 repositoryId: repoId,
270 ownerName: "alice",
271 repoName: "site",
272 branchName: "feat/b",
273 commitSha: "sha-one",
274 });
275 expect(first).not.toBeNull();
276
277 // Flip to ready so we can verify the upsert resets it.
278 await markPreviewReady(first!.id);
279 let row = await getPreviewForBranch(repoId, "feat/b");
280 expect(row!.status).toBe("ready");
281
282 // Second push to the same branch → upsert path.
283 const second = await enqueuePreviewBuild({
284 repositoryId: repoId,
285 ownerName: "alice",
286 repoName: "site",
287 branchName: "feat/b",
288 commitSha: "sha-two",
289 });
290 expect(second).not.toBeNull();
291 expect(second!.id).toBe(first!.id); // same row
292 expect(second!.commitSha).toBe("sha-two");
293 expect(second!.status).toBe("building");
294 expect(second!.buildCompletedAt).toBeNull();
295
296 // And only ONE row exists for the (repo, branch) pair.
297 const all = await listPreviewsForRepo(repoId);
298 const matching = all.filter((p) => p.branchName === "feat/b");
299 expect(matching.length).toBe(1);
300 });
301
302 it("markPreviewReady flips status to 'ready' with completed_at", async () => {
303 ({ userId, repoId } = await seedRepo());
304
305 const row = await enqueuePreviewBuild({
306 repositoryId: repoId,
307 ownerName: "a",
308 repoName: "b",
309 branchName: "x",
310 commitSha: "abc",
311 });
312 await markPreviewReady(row!.id);
313 const after = await getPreviewForBranch(repoId, "x");
314 expect(after!.status).toBe("ready");
315 expect(after!.buildCompletedAt).not.toBeNull();
316 });
317
318 it("markPreviewFailed records error_message + truncates", async () => {
319 ({ userId, repoId } = await seedRepo());
320
321 const row = await enqueuePreviewBuild({
322 repositoryId: repoId,
323 ownerName: "a",
324 repoName: "b",
325 branchName: "y",
326 commitSha: "def",
327 });
328 const longError = "boom\n".repeat(10_000);
329 await markPreviewFailed(row!.id, longError);
330 const after = await getPreviewForBranch(repoId, "y");
331 expect(after!.status).toBe("failed");
332 expect(after!.errorMessage).not.toBeNull();
333 expect(after!.errorMessage!.length).toBeLessThanOrEqual(2_000);
334 });
335
336 it("expireOldPreviews transitions ready rows past expires_at to 'expired'", async () => {
337 ({ userId, repoId } = await seedRepo());
338
339 const row = await enqueuePreviewBuild({
340 repositoryId: repoId,
341 ownerName: "a",
342 repoName: "b",
343 branchName: "stale",
344 commitSha: "old",
345 });
346 expect(row).not.toBeNull();
347 await markPreviewReady(row!.id);
348
349 // Force expires_at into the past.
350 await db
351 .update(branchPreviews)
352 .set({ expiresAt: new Date(Date.now() - 60_000) })
353 .where(eq(branchPreviews.id, row!.id));
354
355 const flipped = await expireOldPreviews();
356 expect(flipped).toBeGreaterThanOrEqual(1);
357
358 const after = await getPreviewForBranch(repoId, "stale");
359 expect(after!.status).toBe("expired");
360 });
361
362 it("expireOldPreviews leaves fresh rows alone", async () => {
363 ({ userId, repoId } = await seedRepo());
364
365 const row = await enqueuePreviewBuild({
366 repositoryId: repoId,
367 ownerName: "a",
368 repoName: "b",
369 branchName: "fresh",
370 commitSha: "new",
371 });
372 expect(row).not.toBeNull();
373 await markPreviewReady(row!.id);
374
375 // Default expiresAt is now+24h → expire pass should ignore it.
376 await expireOldPreviews();
377 const after = await getPreviewForBranch(repoId, "fresh");
378 expect(after!.status).toBe("ready");
379 });
380
381 it("listPreviewsForRepo orders by build_started_at descending", async () => {
382 ({ userId, repoId } = await seedRepo());
383
384 const oldNow = () => new Date(Date.now() - 60_000);
385 const newNow = () => new Date();
386
387 await enqueuePreviewBuild({
388 repositoryId: repoId,
389 ownerName: "a",
390 repoName: "b",
391 branchName: "old-branch",
392 commitSha: "1",
393 now: oldNow,
394 });
395 await enqueuePreviewBuild({
396 repositoryId: repoId,
397 ownerName: "a",
398 repoName: "b",
399 branchName: "new-branch",
400 commitSha: "2",
401 now: newNow,
402 });
403
404 const list = await listPreviewsForRepo(repoId);
405 expect(list.length).toBe(2);
406 expect(list[0]!.branchName).toBe("new-branch");
407 expect(list[1]!.branchName).toBe("old-branch");
408 });
409});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
100100import depsRoutes from "./routes/deps";
101101import discussionsRoutes from "./routes/discussions";
102102import environmentsRoutes from "./routes/environments";
103import previewsRoutes from "./routes/previews";
103104import followsRoutes from "./routes/follows";
104105import gatesRoutes from "./routes/gates";
105106import gistsRoutes from "./routes/gists";
549550app.route("/", depsRoutes);
550551app.route("/", discussionsRoutes);
551552app.route("/", environmentsRoutes);
553app.route("/", previewsRoutes);
552554app.route("/", followsRoutes);
553555app.route("/", gatesRoutes);
554556app.route("/", gistsRoutes);
Modifiedsrc/db/schema.ts+48−0View fileUnifiedSplit
157157 autoCloseStaleIssues: boolean("auto_close_stale_issues")
158158 .default(true)
159159 .notNull(),
160 // Migration 0062 — opt-out flag for per-branch preview URLs. Default on;
161 // owners can disable via repo-settings. When false, post-receive skips
162 // the enqueuePreviewBuild call entirely.
163 previewBuildsEnabled: boolean("preview_builds_enabled")
164 .default(true)
165 .notNull(),
160166 },
161167 (table) => [
162168 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
31523158export type PrLiveSession = typeof prLiveSessions.$inferSelect;
31533159export type NewPrLiveSession = typeof prLiveSessions.$inferInsert;
31543160
3161// ---------------------------------------------------------------------------
3162// Migration 0062 — per-branch preview URLs.
3163// ---------------------------------------------------------------------------
3164// One row per (repo, branch) pair. A push to a non-default branch
3165// upserts the row, replacing commit_sha + resetting status to 'building'.
3166// `preview_url` is computed at enqueue time so the row can be rendered
3167// immediately even while the build is still running.
3168
3169export const branchPreviews = pgTable(
3170 "branch_previews",
3171 {
3172 id: uuid("id").primaryKey().defaultRandom(),
3173 repositoryId: uuid("repository_id")
3174 .notNull()
3175 .references(() => repositories.id, { onDelete: "cascade" }),
3176 branchName: text("branch_name").notNull(),
3177 commitSha: text("commit_sha").notNull(),
3178 previewUrl: text("preview_url").notNull(),
3179 // 'building' | 'ready' | 'failed' | 'expired'
3180 status: text("status").default("building").notNull(),
3181 buildStartedAt: timestamp("build_started_at", { withTimezone: true })
3182 .defaultNow()
3183 .notNull(),
3184 buildCompletedAt: timestamp("build_completed_at", { withTimezone: true }),
3185 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3186 errorMessage: text("error_message"),
3187 createdAt: timestamp("created_at", { withTimezone: true })
3188 .defaultNow()
3189 .notNull(),
3190 },
3191 (table) => [
3192 uniqueIndex("branch_previews_repo_branch").on(
3193 table.repositoryId,
3194 table.branchName
3195 ),
3196 index("branch_previews_repo_status").on(table.repositoryId, table.status),
3197 ]
3198);
3199
3200export type BranchPreview = typeof branchPreviews.$inferSelect;
3201export type NewBranchPreview = typeof branchPreviews.$inferInsert;
3202
Modifiedsrc/hooks/post-receive.ts+71−0View fileUnifiedSplit
2525 getRepoPath,
2626} from "../git/repository";
2727import { indexChangedFiles } from "../lib/semantic-index";
28import { enqueuePreviewBuild } from "../lib/branch-previews";
2829
2930interface PushRef {
3031 oldSha: string;
106107 );
107108 }
108109
110 // 4c. Per-branch preview URLs (migration 0062). Every push to a
111 // non-default branch enqueues a preview-build row. Gated on
112 // repositories.preview_builds_enabled (default on) so owners can
113 // opt out per-repo via repo-settings. Fire-and-forget; failures
114 // never break the push path.
115 void firePreviewBuilds(owner, repo, refs).catch((err) =>
116 console.warn("[branch-previews] dispatch error:", err)
117 );
118
109119 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
110120 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
111121 // default branch. The branch case (`Main` vs `main`) is determined by
494504 }
495505}
496506
507/**
508 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
509 * non-default branch on a `preview_builds_enabled` repo.
510 *
511 * Resolves the repo row once, fetches the default branch + opt-out flag,
512 * then upserts one preview row per pushed ref that isn't the default.
513 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
514 * are skipped — they shouldn't create preview rows. Never throws.
515 */
516async function firePreviewBuilds(
517 owner: string,
518 repo: string,
519 refs: PushRef[]
520): Promise<void> {
521 // Filter to live branch pushes only.
522 const branchRefs = refs.filter(
523 (r) =>
524 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
525 );
526 if (branchRefs.length === 0) return;
527
528 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
529 try {
530 const [row] = await db
531 .select({
532 id: repositories.id,
533 previewBuildsEnabled: repositories.previewBuildsEnabled,
534 defaultBranch: repositories.defaultBranch,
535 })
536 .from(repositories)
537 .innerJoin(users, eq(repositories.ownerId, users.id))
538 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
539 .limit(1);
540 repoRow = row || null;
541 } catch {
542 return;
543 }
544 if (!repoRow) return;
545 if (!repoRow.previewBuildsEnabled) return;
546
547 for (const ref of branchRefs) {
548 const branchName = ref.refName.replace("refs/heads/", "");
549 if (branchName === repoRow.defaultBranch) continue;
550 try {
551 await enqueuePreviewBuild({
552 repositoryId: repoRow.id,
553 ownerName: owner,
554 repoName: repo,
555 branchName,
556 commitSha: ref.newSha,
557 });
558 } catch (err) {
559 console.warn(
560 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
561 err instanceof Error ? err.message : err
562 );
563 }
564 }
565}
566
497567/** Test-only access to internal helpers. */
498568export const __test = {
499569 triggerCrontechDeploy,
502572 RETRY_DELAYS_MS,
503573 listChangedPaths,
504574 fireSemanticIndex,
575 firePreviewBuilds,
505576};
Addedsrc/lib/branch-previews.ts+325−0View fileUnifiedSplit
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
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}
Modifiedsrc/routes/api-v2.ts+87−0View fileUnifiedSplit
2929 workflowJobs,
3030} from "../db/schema";
3131import { enqueueRun } from "../lib/workflow-runner";
32import {
33 enqueuePreviewBuild,
34 listPreviewsForRepo,
35} from "../lib/branch-previews";
3236import {
3337 listBranches,
3438 getDefaultBranch,
11021106 });
11031107});
11041108
1109// ─── Branch previews (migration 0062) ──────────────────────────────────────
1110//
1111// GET /api/v2/repos/:owner/:repo/previews
1112// → list every active preview row, newest first.
1113//
1114// POST /api/v2/repos/:owner/:repo/previews/:branch/refresh
1115// → force a fresh build for the given branch. The branch must already
1116// exist and not be the default. The current HEAD of the branch is
1117// used as the commit_sha.
1118
1119apiv2.get("/repos/:owner/:repo/previews", async (c) => {
1120 const { owner, repo } = c.req.param();
1121 const resolved = await resolveRepo(owner, repo);
1122 if (!resolved) return c.json({ error: "Not found" }, 404);
1123 const rows = await listPreviewsForRepo((resolved.repo as { id: string }).id);
1124 return c.json({
1125 previews: rows.map((p) => ({
1126 id: p.id,
1127 branchName: p.branchName,
1128 commitSha: p.commitSha,
1129 previewUrl: p.previewUrl,
1130 status: p.status,
1131 buildStartedAt: p.buildStartedAt,
1132 buildCompletedAt: p.buildCompletedAt,
1133 expiresAt: p.expiresAt,
1134 errorMessage: p.errorMessage,
1135 })),
1136 });
1137});
1138
1139apiv2.post(
1140 "/repos/:owner/:repo/previews/:branch/refresh",
1141 requireApiAuth,
1142 requireScope("repo"),
1143 async (c) => {
1144 const { owner, repo } = c.req.param();
1145 const branch = c.req.param("branch");
1146 if (!branch) return c.json({ error: "branch is required" }, 400);
1147
1148 const resolved = await resolveRepo(owner, repo);
1149 if (!resolved) return c.json({ error: "Not found" }, 404);
1150
1151 // Refuse to "preview" the default branch — by design previews exist
1152 // only for non-default branches.
1153 if ((resolved.repo as { defaultBranch: string }).defaultBranch === branch) {
1154 return c.json(
1155 {
1156 error: "Default branch has no preview — previews are for non-default branches",
1157 },
1158 400
1159 );
1160 }
1161
1162 // Resolve the branch to its current SHA so the new row points at HEAD.
1163 let sha: string | null = null;
1164 try {
1165 sha = await resolveRef(owner, repo, branch);
1166 } catch {
1167 sha = null;
1168 }
1169 if (!sha) return c.json({ error: "Branch not found" }, 404);
1170
1171 const row = await enqueuePreviewBuild({
1172 repositoryId: (resolved.repo as { id: string }).id,
1173 ownerName: owner,
1174 repoName: repo,
1175 branchName: branch,
1176 commitSha: sha,
1177 });
1178 if (!row) {
1179 return c.json({ error: "Could not enqueue preview build" }, 503);
1180 }
1181 return c.json({
1182 id: row.id,
1183 branchName: row.branchName,
1184 commitSha: row.commitSha,
1185 previewUrl: row.previewUrl,
1186 status: row.status,
1187 expiresAt: row.expiresAt,
1188 }, 202);
1189 }
1190);
1191
11051192// ─── PR Comments (GateTest integration) ────────────────────────────────────
11061193
11071194apiv2.post(
Addedsrc/routes/previews.tsx+512−0View fileUnifiedSplit
1/**
2 * Per-branch preview URLs — list view (migration 0062).
3 *
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.
10 *
11 * All page-local CSS is scoped under `.preview-*` so it can't bleed
12 * into the shared layout (per CLAUDE.md: do NOT modify shared
13 * layout/components/ui). Mirrors the gradient hairline + orb pattern
14 * used by environments.tsx / admin-integrations.tsx.
15 *
16 * The corresponding JSON API + force-rebuild endpoints live in
17 * src/routes/api-v2.ts.
18 */
19
20import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users } from "../db/schema";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { Layout } from "../views/layout";
27import { RepoHeader, RepoNav } from "../views/components";
28import { getUnreadCount } from "../lib/unread";
29import {
30 formatExpiresIn,
31 listPreviewsForRepo,
32 previewStatusLabel,
33} from "../lib/branch-previews";
34
35const r = new Hono<AuthEnv>();
36r.use("*", softAuth);
37
38async function loadRepo(owner: string, repo: string) {
39 try {
40 const [row] = await db
41 .select({
42 id: repositories.id,
43 name: repositories.name,
44 defaultBranch: repositories.defaultBranch,
45 ownerId: repositories.ownerId,
46 starCount: repositories.starCount,
47 forkCount: repositories.forkCount,
48 previewBuildsEnabled: repositories.previewBuildsEnabled,
49 })
50 .from(repositories)
51 .innerJoin(users, eq(repositories.ownerId, users.id))
52 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
53 .limit(1);
54 return row || null;
55 } catch (err) {
56 console.error("[previews] loadRepo failed:", err);
57 return null;
58 }
59}
60
61/* ─────────────────────────────────────────────────────────────────────────
62 * Scoped CSS — every class prefixed `.preview-*` so this page can't bleed
63 * into the layout. Same gradient hairline + orb language as environments.
64 * ───────────────────────────────────────────────────────────────────── */
65const previewStyles = `
66 .preview-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
67
68 .preview-head {
69 position: relative;
70 margin-bottom: var(--space-5);
71 padding: var(--space-4) var(--space-5);
72 background: var(--bg-elevated);
73 border: 1px solid var(--border);
74 border-radius: 14px;
75 overflow: hidden;
76 }
77 .preview-head::before {
78 content: '';
79 position: absolute;
80 top: 0; left: 0; right: 0;
81 height: 2px;
82 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
83 opacity: 0.7;
84 pointer-events: none;
85 }
86 .preview-head-orb {
87 position: absolute;
88 inset: -30% -10% auto auto;
89 width: 320px; height: 320px;
90 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
91 filter: blur(70px);
92 opacity: 0.7;
93 pointer-events: none;
94 z-index: 0;
95 }
96 .preview-head-inner {
97 position: relative;
98 z-index: 1;
99 display: flex;
100 align-items: flex-end;
101 justify-content: space-between;
102 gap: var(--space-4);
103 flex-wrap: wrap;
104 }
105 .preview-head-text { flex: 1; min-width: 240px; max-width: 720px; }
106 .preview-eyebrow {
107 display: inline-flex;
108 align-items: center;
109 gap: 8px;
110 font-family: var(--font-mono);
111 font-size: 11px;
112 letter-spacing: 0.14em;
113 text-transform: uppercase;
114 font-weight: 600;
115 color: var(--text-muted);
116 margin-bottom: 10px;
117 }
118 .preview-eyebrow-dot {
119 width: 8px; height: 8px;
120 border-radius: 9999px;
121 background: linear-gradient(135deg, #8c6dff, #36c5d6);
122 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
123 }
124 .preview-title {
125 margin: 0 0 6px;
126 font-family: var(--font-display);
127 font-size: clamp(22px, 2.6vw, 30px);
128 font-weight: 800;
129 letter-spacing: -0.022em;
130 line-height: 1.1;
131 color: var(--text-strong);
132 }
133 .preview-title-grad {
134 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
135 -webkit-background-clip: text;
136 background-clip: text;
137 -webkit-text-fill-color: transparent;
138 color: transparent;
139 }
140 .preview-sub {
141 margin: 0;
142 font-size: 13.5px;
143 line-height: 1.5;
144 color: var(--text-muted);
145 }
146
147 .preview-col-title {
148 margin: 0 0 var(--space-2);
149 font-family: var(--font-mono);
150 font-size: 11px;
151 text-transform: uppercase;
152 letter-spacing: 0.14em;
153 font-weight: 600;
154 color: var(--text-muted);
155 }
156
157 .preview-list {
158 display: flex;
159 flex-direction: column;
160 gap: var(--space-3);
161 margin-bottom: var(--space-5);
162 }
163
164 .preview-card {
165 position: relative;
166 padding: var(--space-4) var(--space-4);
167 background: var(--bg-elevated);
168 border: 1px solid var(--border);
169 border-radius: 14px;
170 overflow: hidden;
171 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
172 }
173 .preview-card::before {
174 content: '';
175 position: absolute;
176 top: 0; left: 14px; right: 14px;
177 height: 1px;
178 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
179 opacity: 0;
180 transition: opacity 160ms ease;
181 }
182 .preview-card:hover {
183 transform: translateY(-1px);
184 border-color: rgba(140,109,255,0.32);
185 box-shadow: 0 8px 22px -10px rgba(0,0,0,0.40);
186 }
187 .preview-card:hover::before { opacity: 1; }
188
189 .preview-card-head {
190 display: flex;
191 align-items: center;
192 justify-content: space-between;
193 gap: var(--space-3);
194 flex-wrap: wrap;
195 margin-bottom: var(--space-2);
196 }
197 .preview-card-titles { flex: 1; min-width: 200px; }
198 .preview-card-branch {
199 margin: 0;
200 font-family: var(--font-mono);
201 font-size: 15px;
202 font-weight: 700;
203 color: var(--text-strong);
204 word-break: break-all;
205 }
206 .preview-card-url {
207 margin-top: 4px;
208 font-family: var(--font-mono);
209 font-size: 12.5px;
210 overflow: hidden;
211 text-overflow: ellipsis;
212 white-space: nowrap;
213 }
214 .preview-card-url a { color: var(--accent, #8c6dff); text-decoration: none; }
215 .preview-card-url a:hover { color: var(--accent, #36c5d6); text-decoration: underline; }
216 .preview-card-meta {
217 margin-top: 6px;
218 display: flex;
219 flex-wrap: wrap;
220 gap: 10px;
221 font-size: 11.5px;
222 color: var(--text-muted);
223 font-variant-numeric: tabular-nums;
224 }
225 .preview-card-meta code {
226 font-family: var(--font-mono);
227 font-size: 11.5px;
228 color: var(--text);
229 background: rgba(255,255,255,0.04);
230 padding: 1px 6px;
231 border-radius: 6px;
232 }
233
234 /* ─── status pills ─── */
235 .preview-pill {
236 display: inline-flex;
237 align-items: center;
238 gap: 5px;
239 padding: 2px 9px;
240 border-radius: 9999px;
241 font-size: 10.5px;
242 font-weight: 600;
243 letter-spacing: 0.05em;
244 text-transform: uppercase;
245 font-family: var(--font-mono);
246 background: rgba(255,255,255,0.04);
247 color: var(--text-muted);
248 box-shadow: inset 0 0 0 1px var(--border);
249 }
250 .preview-pill.is-building {
251 background: rgba(251,191,36,0.10);
252 color: #fde68a;
253 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
254 }
255 .preview-pill.is-ready {
256 background: rgba(52,211,153,0.10);
257 color: #6ee7b7;
258 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
259 }
260 .preview-pill.is-failed {
261 background: rgba(248,113,113,0.10);
262 color: #fecaca;
263 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35);
264 }
265 .preview-pill.is-expired {
266 background: rgba(148,163,184,0.10);
267 color: #cbd5e1;
268 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
269 }
270 .preview-pill-dot {
271 width: 6px; height: 6px;
272 border-radius: 9999px;
273 background: currentColor;
274 }
275 .preview-pill.is-building .preview-pill-dot {
276 animation: previewPulse 1.4s ease-in-out infinite;
277 }
278 @keyframes previewPulse {
279 0%, 100% { opacity: 1; }
280 50% { opacity: 0.35; }
281 }
282
283 .preview-error {
284 margin-top: var(--space-2);
285 padding: 8px 12px;
286 border-radius: 8px;
287 font-family: var(--font-mono);
288 font-size: 12px;
289 color: #fecaca;
290 background: rgba(248,113,113,0.06);
291 border: 1px solid rgba(248,113,113,0.30);
292 white-space: pre-wrap;
293 word-break: break-word;
294 }
295
296 /* ─── empty state ─── */
297 .preview-empty {
298 position: relative;
299 padding: var(--space-6) var(--space-5);
300 background: var(--bg-elevated);
301 border: 1px dashed var(--border-strong, var(--border));
302 border-radius: 14px;
303 text-align: center;
304 overflow: hidden;
305 margin-bottom: var(--space-5);
306 }
307 .preview-empty-orb {
308 position: absolute;
309 inset: auto auto -40% 50%;
310 transform: translateX(-50%);
311 width: 320px; height: 320px;
312 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
313 filter: blur(70px);
314 opacity: 0.7;
315 pointer-events: none;
316 }
317 .preview-empty-inner { position: relative; z-index: 1; max-width: 460px; margin: 0 auto; }
318 .preview-empty-icon {
319 width: 44px; height: 44px;
320 margin: 0 auto var(--space-3);
321 border-radius: 14px;
322 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
323 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
324 display: flex; align-items: center; justify-content: center;
325 color: #b69dff;
326 }
327 .preview-empty-title {
328 font-family: var(--font-display);
329 font-size: 16px;
330 font-weight: 700;
331 color: var(--text-strong);
332 margin: 0 0 6px;
333 letter-spacing: -0.01em;
334 }
335 .preview-empty-body {
336 font-size: 13.5px;
337 color: var(--text-muted);
338 line-height: 1.5;
339 margin: 0 0 var(--space-3);
340 }
341 .preview-empty code {
342 font-family: var(--font-mono);
343 font-size: 12px;
344 color: var(--text);
345 background: rgba(255,255,255,0.06);
346 padding: 1px 6px;
347 border-radius: 6px;
348 }
349
350 /* ─── secondary tab strip — only used when RepoNav has no slot ─── */
351 .preview-tabbar {
352 display: flex;
353 gap: 4px;
354 margin-bottom: var(--space-3);
355 border-bottom: 1px solid var(--border);
356 padding-bottom: 0;
357 }
358 .preview-tabbar a {
359 padding: 8px 12px;
360 font-size: 13px;
361 font-weight: 600;
362 color: var(--text-muted);
363 text-decoration: none;
364 border-bottom: 2px solid transparent;
365 margin-bottom: -1px;
366 }
367 .preview-tabbar a.is-active {
368 color: var(--text-strong);
369 border-bottom-color: #8c6dff;
370 }
371 .preview-tabbar a:hover { color: var(--text); }
372`;
373
374r.get("/:owner/:repo/previews", async (c) => {
375 const user = c.get("user");
376 const { owner, repo } = c.req.param();
377 const repoRow = await loadRepo(owner, repo);
378 if (!repoRow) return c.notFound();
379
380 const previews = await listPreviewsForRepo(repoRow.id);
381 const unread = user ? await getUnreadCount(user.id) : 0;
382 const now = new Date();
383
384 return c.html(
385 <Layout
386 title={`Previews — ${owner}/${repo}`}
387 user={user}
388 notificationCount={unread}
389 >
390 <RepoHeader
391 owner={owner}
392 repo={repo}
393 starCount={repoRow.starCount}
394 forkCount={repoRow.forkCount}
395 currentUser={user?.username}
396 />
397 <RepoNav owner={owner} repo={repo} active="code" />
398
399 <div class="preview-wrap">
400 <section class="preview-head">
401 <div class="preview-head-orb" aria-hidden="true" />
402 <div class="preview-head-inner">
403 <div class="preview-head-text">
404 <div class="preview-eyebrow">
405 <span class="preview-eyebrow-dot" aria-hidden="true" />
406 Branch previews · {owner}/{repo}
407 </div>
408 <h2 class="preview-title">
409 <span class="preview-title-grad">Previews.</span>
410 </h2>
411 <p class="preview-sub">
412 Every push to a non-default branch gets a unique preview
413 URL. Open the URL to see the branch as if it were live —
414 no merge required. Previews auto-expire 24 hours after
415 the last push.
416 </p>
417 </div>
418 </div>
419 </section>
420
421 <nav class="preview-tabbar" aria-label="Repository previews navigation">
422 <a href={`/${owner}/${repo}`}>Code</a>
423 <a class="is-active" href={`/${owner}/${repo}/previews`}>Previews</a>
424 <a href={`/${owner}/${repo}/deployments`}>Deployments</a>
425 </nav>
426
427 <h4 class="preview-col-title">
428 {previews.length === 0
429 ? "No previews yet"
430 : `${previews.length} preview${previews.length === 1 ? "" : "s"}`}
431 </h4>
432
433 {previews.length === 0 ? (
434 <div class="preview-empty">
435 <div class="preview-empty-orb" aria-hidden="true" />
436 <div class="preview-empty-inner">
437 <div class="preview-empty-icon" aria-hidden="true">
438 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
439 <circle cx="12" cy="12" r="9" />
440 <path d="M12 7v5l3 2" />
441 </svg>
442 </div>
443 <h3 class="preview-empty-title">Push to a branch to get a preview URL</h3>
444 <p class="preview-empty-body">
445 Create a branch other than{" "}
446 <code>{repoRow.defaultBranch}</code>, push some commits,
447 and a unique preview URL will land here within seconds.
448 Each preview lasts 24 hours after the last push.
449 </p>
450 </div>
451 </div>
452 ) : (
453 <div class="preview-list">
454 {previews.map((p) => {
455 const shortSha = (p.commitSha || "").slice(0, 7);
456 const expiresLabel = formatExpiresIn(p.expiresAt, now);
457 const statusKey = p.status as
458 | "building"
459 | "ready"
460 | "failed"
461 | "expired";
462 const pillClass = `preview-pill is-${statusKey}`;
463 return (
464 <div class="preview-card">
465 <div class="preview-card-head">
466 <div class="preview-card-titles">
467 <h3 class="preview-card-branch">{p.branchName}</h3>
468 <div class="preview-card-url">
469 {p.status === "ready" ? (
470 <a href={p.previewUrl} target="_blank" rel="noopener noreferrer">
471 {p.previewUrl}
472 </a>
473 ) : (
474 <span style="color: var(--text-muted)">
475 {p.previewUrl}
476 </span>
477 )}
478 </div>
479 <div class="preview-card-meta">
480 <span>
481 commit <code>{shortSha}</code>
482 </span>
483 <span>
484 {p.status === "expired"
485 ? "expired"
486 : `expires in ${expiresLabel}`}
487 </span>
488 </div>
489 </div>
490 <div>
491 <span class={pillClass}>
492 <span class="preview-pill-dot" aria-hidden="true" />
493 {previewStatusLabel(p.status)}
494 </span>
495 </div>
496 </div>
497 {p.status === "failed" && p.errorMessage && (
498 <div class="preview-error">{p.errorMessage}</div>
499 )}
500 </div>
501 );
502 })}
503 </div>
504 )}
505 </div>
506
507 <style dangerouslySetInnerHTML={{ __html: previewStyles }} />
508 </Layout>
509 );
510});
511
512export default r;
Modifiedsrc/routes/pulls.tsx+60−0View fileUnifiedSplit
6767} from "../git/repository";
6868import type { GitDiffFile } from "../git/repository";
6969import { html } from "hono/html";
70import {
71 getPreviewForBranch,
72 previewStatusLabel,
73} from "../lib/branch-previews";
7074import {
7175 Flex,
7276 Container,
917921 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
918922 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
919923 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
924
925 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
926 .preview-prpill {
927 display: inline-flex; align-items: center; gap: 6px;
928 padding: 3px 10px;
929 border-radius: 9999px;
930 font-family: var(--font-mono);
931 font-size: 11.5px;
932 font-weight: 600;
933 background: rgba(255,255,255,0.04);
934 color: var(--text-muted);
935 text-decoration: none;
936 border: 1px solid var(--border);
937 }
938 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
939 .preview-prpill .preview-prpill-dot {
940 width: 7px; height: 7px;
941 border-radius: 9999px;
942 background: currentColor;
943 }
944 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
945 .preview-prpill.is-building .preview-prpill-dot {
946 animation: previewPrPulse 1.4s ease-in-out infinite;
947 }
948 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
949 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
950 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
951 @keyframes previewPrPulse {
952 0%, 100% { opacity: 1; }
953 50% { opacity: 0.4; }
954 }
920955`;
921956
922957/**
17941829 }
17951830 }
17961831
1832 // Migration 0062 — per-branch preview URL. The head branch always
1833 // has a preview row (unless it's the default branch, which never
1834 // happens for an open PR) once it has been pushed at least once.
1835 const preview = await getPreviewForBranch(
1836 (resolved.repo as { id: string }).id,
1837 pr.headBranch
1838 );
1839
17971840 // Get diff for "Files changed" tab
17981841 let diffRaw = "";
17991842 let diffFiles: GitDiffFile[] = [];
19321975 </span>
19331976 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
19341977 </span>
1978 {preview && (
1979 <a
1980 class={`preview-prpill is-${preview.status}`}
1981 href={
1982 preview.status === "ready"
1983 ? preview.previewUrl
1984 : `/${ownerName}/${repoName}/previews`
1985 }
1986 target={preview.status === "ready" ? "_blank" : undefined}
1987 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
1988 title={`Preview · ${previewStatusLabel(preview.status)}`}
1989 >
1990 <span class="preview-prpill-dot" aria-hidden="true"></span>
1991 <span>Preview: </span>
1992 <span>{previewStatusLabel(preview.status)}</span>
1993 </a>
1994 )}
19351995 {canManage && pr.state === "open" && pr.isDraft && (
19361996 <form
19371997 method="post"
19381998