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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
preview-builder.ts9.6 KB · 312 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
/**
 * PR preview builder (migration 0077).
 *
 * When a PR is opened or updated and the repo has `preview_build_command`
 * configured, this module:
 *   1. Clones the PR's head branch into /tmp/previews/<prId>-<shortSha>
 *   2. Runs the configured build command with a 2-minute timeout
 *   3. Captures stdout + stderr as the build log
 *   4. On success: marks the pr_previews row as 'ready' and posts a PR comment
 *   5. On failure: marks it 'failed' and stores the log
 *
 * Feature flag: preview builds only run when PREVIEW_DOMAIN env var is set
 * AND the repo has preview_build_command configured.
 *
 * Philosophy: never throw — every DB + subprocess call is wrapped in
 * try/catch so a failure cannot disrupt the PR creation path. Callers use
 * `buildPreview(...).catch(() => {})` fire-and-forget style.
 */

import { eq, and } from "drizzle-orm";
import { db } from "../db";
import {
  repositories,
  pullRequests,
  prPreviews,
  prComments,
  users,
} from "../db/schema";
import { config } from "./config";

const PREVIEW_BUILD_DIR = process.env.PREVIEW_BUILD_DIR || "/tmp/previews";
const BUILD_TIMEOUT_MS = 2 * 60 * 1_000; // 2 minutes

// ─── helpers ────────────────────────────────────────────────────────────────

/** Slugify a string for use in a URL path segment. */
function slug(s: string): string {
  return (s || "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 60);
}

/** Compute the public URL for a built preview. */
export function previewBuilderUrl(
  ownerName: string,
  repoName: string,
  branchName: string
): string {
  const domain = config.previewDomain || config.appBaseUrl;
  return `${domain}/previews/${ownerName}/${repoName}/${slug(branchName)}/`;
}

/** The on-disk directory where built output lives. */
export function previewBuildPath(
  prId: string,
  headSha: string,
  outputDir: string
): string {
  const shortSha = headSha.slice(0, 8);
  return `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}/${outputDir}`;
}

// ─── core builder ───────────────────────────────────────────────────────────

/**
 * Build a preview for the given PR. Fire-and-forget by callers:
 *   `buildPreview(prId, repoId, headSha).catch(() => {})`
 */
export async function buildPreview(
  prId: string,
  repoId: string,
  headSha: string
): Promise<void> {
  // Feature flag: only run when PREVIEW_DOMAIN is set
  if (!config.previewDomain) return;

  // ── look up the repo for build config ──
  let repo: {
    id: string;
    name: string;
    ownerId: string;
    previewBuildCommand: string | null;
    previewOutputDir: string | null;
    diskPath: string;
  } | null = null;

  let ownerUsername = "";

  try {
    const [row] = await db
      .select({
        id: repositories.id,
        name: repositories.name,
        ownerId: repositories.ownerId,
        previewBuildCommand: repositories.previewBuildCommand,
        previewOutputDir: repositories.previewOutputDir,
        diskPath: repositories.diskPath,
      })
      .from(repositories)
      .where(eq(repositories.id, repoId))
      .limit(1);
    if (!row) return;
    repo = row;

    // Resolve owner username for URL construction
    const [ownerRow] = await db
      .select({ username: users.username })
      .from(users)
      .where(eq(users.id, repo.ownerId))
      .limit(1);
    ownerUsername = ownerRow?.username ?? "";
  } catch (err) {
    console.warn("[preview-builder] repo lookup failed:", err instanceof Error ? err.message : err);
    return;
  }

  // Opt-in gate: skip if no build command configured
  if (!repo.previewBuildCommand) return;

  // ── look up the PR for branch name ──
  let pr: { id: string; headBranch: string; number: number } | null = null;
  try {
    const [row] = await db
      .select({ id: pullRequests.id, headBranch: pullRequests.headBranch, number: pullRequests.number })
      .from(pullRequests)
      .where(eq(pullRequests.id, prId))
      .limit(1);
    if (!row) return;
    pr = row;
  } catch (err) {
    console.warn("[preview-builder] pr lookup failed:", err instanceof Error ? err.message : err);
    return;
  }

  const buildCommand = repo.previewBuildCommand;
  const outputDir = repo.previewOutputDir || "dist";
  const shortSha = headSha.slice(0, 8);
  const previewUrl = previewBuilderUrl(ownerUsername, repo.name, pr.headBranch);
  const buildDir = `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}`;

  // ── upsert preview row to 'building' ──
  let previewRowId: number | null = null;
  try {
    const [row] = await db
      .insert(prPreviews)
      .values({
        repoId,
        prId,
        branchName: pr.headBranch,
        headSha,
        status: "building",
        previewUrl,
        buildCommand,
        outputDir,
        createdAt: new Date(),
        updatedAt: new Date(),
      })
      .onConflictDoNothing()
      .returning({ id: prPreviews.id });
    // If there's already a row (same prId+headSha isn't unique, but let's handle it)
    if (row) {
      previewRowId = row.id;
    } else {
      // Find existing
      const [existing] = await db
        .select({ id: prPreviews.id })
        .from(prPreviews)
        .where(and(eq(prPreviews.prId, prId), eq(prPreviews.headSha, headSha)))
        .limit(1);
      previewRowId = existing?.id ?? null;
    }
  } catch (err) {
    console.warn("[preview-builder] insert failed:", err instanceof Error ? err.message : err);
    return;
  }

  // ── clone + build ──
  const buildStart = Date.now();
  let buildLog = "";
  let buildOk = false;

  try {
    // Step 1: clone the bare repo and checkout the head branch
    const cloneProc = Bun.spawn(
      ["git", "clone", "--branch", pr.headBranch, "--depth", "1", repo.diskPath, buildDir],
      { stderr: "pipe", stdout: "pipe" }
    );

    const cloneStdout = await new Response(cloneProc.stdout).text();
    const cloneStderr = await new Response(cloneProc.stderr).text();
    await cloneProc.exited;

    buildLog += `=== clone ===\n${cloneStdout}${cloneStderr}\n`;

    if (cloneProc.exitCode !== 0) {
      throw new Error(`git clone failed (exit ${cloneProc.exitCode}): ${cloneStderr.slice(0, 500)}`);
    }

    // Step 2: run the build command with timeout
    const buildProc = Bun.spawn(
      ["sh", "-c", buildCommand],
      {
        cwd: buildDir,
        stderr: "pipe",
        stdout: "pipe",
        env: { ...process.env, CI: "1" },
      }
    );

    // Apply 2-minute timeout
    const timeoutHandle = setTimeout(() => {
      try { buildProc.kill(); } catch {}
    }, BUILD_TIMEOUT_MS);

    const buildStdout = await new Response(buildProc.stdout).text();
    const buildStderr = await new Response(buildProc.stderr).text();
    await buildProc.exited;
    clearTimeout(timeoutHandle);

    buildLog += `\n=== build: ${buildCommand} ===\n${buildStdout}${buildStderr}\n`;

    if (buildProc.exitCode !== 0) {
      throw new Error(`build command failed (exit ${buildProc.exitCode})`);
    }

    buildOk = true;
  } catch (err) {
    buildLog += `\n=== error ===\n${err instanceof Error ? err.message : String(err)}\n`;
  }

  const durationMs = Date.now() - buildStart;

  // ── update preview row ──
  try {
    if (previewRowId !== null) {
      await db
        .update(prPreviews)
        .set({
          status: buildOk ? "ready" : "failed",
          buildLog: buildLog.slice(0, 50_000),
          previewUrl: buildOk ? previewUrl : null,
          buildDurationMs: durationMs,
          updatedAt: new Date(),
        })
        .where(eq(prPreviews.id, previewRowId));
    }
  } catch (err) {
    console.warn("[preview-builder] update failed:", err instanceof Error ? err.message : err);
  }

  // ── post a PR comment with the result ──
  try {
    // Use the system bot user (first admin user, or fall back to the PR author)
    const [authorRow] = await db
      .select({ authorId: pullRequests.authorId })
      .from(pullRequests)
      .where(eq(pullRequests.id, prId))
      .limit(1);

    if (authorRow) {
      const buildTimeS = Math.round(durationMs / 1000);
      const body = buildOk
        ? `🚀 **Preview deployed**\nURL: ${previewUrl}\nBuilt from: \`${shortSha}\`\nBuild time: ${buildTimeS}s`
        : `❌ **Preview build failed**\nCommit: \`${shortSha}\`\nBuild time: ${buildTimeS}s\n\nSee build log in the [Previews tab](/${ownerUsername}/${repo.name}/previews).`;

      await db.insert(prComments).values({
        pullRequestId: prId,
        authorId: authorRow.authorId,
        body,
        isAiReview: false,
        moderationStatus: "approved",
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    }
  } catch (err) {
    console.warn("[preview-builder] comment failed:", err instanceof Error ? err.message : err);
  }
}

/**
 * Look up the most recent pr_previews row for a given PR.
 * Returns null if none exists or the table doesn't exist yet.
 */
export async function getPreviewForPr(prId: string): Promise<{
  id: number;
  status: string;
  previewUrl: string | null;
  headSha: string;
  buildDurationMs: number | null;
} | null> {
  if (!prId) return null;
  try {
    const [row] = await db
      .select({
        id: prPreviews.id,
        status: prPreviews.status,
        previewUrl: prPreviews.previewUrl,
        headSha: prPreviews.headSha,
        buildDurationMs: prPreviews.buildDurationMs,
      })
      .from(prPreviews)
      .where(eq(prPreviews.prId, prId))
      .orderBy(prPreviews.id)
      .limit(1);
    return row ?? null;
  } catch {
    return null;
  }
}