Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

post-receive.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.

post-receive.tsBlame871 lines · 3 contributors
fc1817aClaude1/**
2 * Post-receive hook logic.
2c34075Claude3 *
4 * Called after every successful git push. This is gluecron's intelligence layer:
5 * 1. Auto-repair — fix common issues and commit automatically
6 * 2. Push analysis — detect breaking changes, security issues
7 * 3. Health score — recompute repo health
8 * 4. GateTest scan — external security scanning
9ecf5a4Claude9 * 5. Vapron deploy — auto-deploy on push to main
2c34075Claude10 * 6. Webhooks — fire registered webhook URLs
fc1817aClaude11 */
12
ba93444Claude13import { createHmac } from "crypto";
3ef4c9dClaude14import { and, eq } from "drizzle-orm";
fc1817aClaude15import { config } from "../lib/config";
2c34075Claude16import { autoRepair } from "../lib/autorepair";
64aa989Claude17import { getAutomationSettings, isAutomationOn } from "../lib/automation-settings";
170ddb2Claude18import { notifyGateTestOfPush } from "../lib/gate";
2c34075Claude19import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude20import { db } from "../db";
21import { deployments, repositories, users } from "../db/schema";
22import { onDeployFailure } from "../lib/ai-incident";
a686079Claude23import {
24 commitsBetween,
25 getDefaultBranch,
26 getRepoPath,
27} from "../git/repository";
28import { indexChangedFiles } from "../lib/semantic-index";
6efae38Claude29import { scanDiffForIssues } from "../lib/ai-auto-issues";
4bbacbeClaude30import { enqueuePreviewBuild } from "../lib/branch-previews";
da3fc18Claude31import { scanDependencies } from "../lib/dependency-scanner";
d199847Claude32import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
783dd46Claude33import {
34 findTargetsForPush,
35 finishDeployRow,
36 resolveEnv,
37 startDeployRow,
38} from "../lib/server-target-store";
39import { deployToTarget } from "../lib/server-targets";
7f992cdClaude40import { fireCloudDeploys } from "../lib/cloud-deploy";
9c5223fClaude41import { ensureRepoOnboarding } from "../lib/repo-onboarding";
fc1817aClaude42
43interface PushRef {
44 oldSha: string;
45 newSha: string;
46 refName: string;
47}
48
49export async function onPostReceive(
50 owner: string,
51 repo: string,
6efae38Claude52 refs: PushRef[],
53 pusherUserId: string = ""
fc1817aClaude54): Promise<void> {
64aa989Claude55 // Resolve repo ID once for automation settings lookups.
56 let repoId = "";
57 try {
58 const [repoRow] = await db
59 .select({ id: repositories.id })
60 .from(repositories)
61 .innerJoin(users, eq(repositories.ownerId, users.id))
62 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
63 .limit(1);
64 repoId = repoRow?.id ?? "";
65 } catch { /* non-blocking */ }
66
67 const automationSettings = repoId
68 ? await getAutomationSettings(repoId).catch(() => null)
69 : null;
70
2c34075Claude71 for (const ref of refs) {
72 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
73 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude74
64aa989Claude75 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
76 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
77 try {
78 const repair = await autoRepair(owner, repo, branchName);
79 if (repair.repaired) {
80 console.log(
81 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
82 );
83 }
84 } catch (err) {
85 console.error(`[autorepair] error:`, err);
2c34075Claude86 }
87 }
fc1817aClaude88
2c34075Claude89 // 2. Push analysis
90 try {
91 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
92 console.log(
93 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
94 );
95 if (analysis.riskScore > 50) {
96 console.warn(
97 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
98 );
99 }
100 if (analysis.breakingChangeSignals.length > 0) {
101 console.warn(
102 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
103 );
104 }
105 } catch (err) {
106 console.error(`[push-analysis] error:`, err);
107 }
108
109 // 3. Health score (async, don't block)
110 computeHealthScore(owner, repo).then((report) => {
111 console.log(
112 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
113 );
114 }).catch((err) => {
115 console.error(`[health] error:`, err);
116 });
117 }
118
170ddb2Claude119 // 4. GateTest scan — fire-and-forget notification on every push. The
120 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
121 // deployments pay no overhead. Results flow back via the inbound
122 // webhook at POST /api/hooks/gatetest.
123 for (const ref of refs) {
124 if (ref.newSha.startsWith("0000")) continue;
125 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
126 console.warn("[gatetest] notify error:", err)
127 );
128 }
2c34075Claude129
a686079Claude130 // 4b. Continuous semantic index — embed changed files on every push so
131 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
132 // all failures are swallowed inside semantic-index.ts so a missing
133 // pgvector extension or absent embeddings API key never breaks the
134 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
135 for (const ref of refs) {
136 if (ref.newSha.startsWith("0000")) continue;
137 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
138 console.warn("[semantic-index] dispatch error:", err)
139 );
140 }
141
4bbacbeClaude142 // 4c. Per-branch preview URLs (migration 0062). Every push to a
143 // non-default branch enqueues a preview-build row. Gated on
144 // repositories.preview_builds_enabled (default on) so owners can
145 // opt out per-repo via repo-settings. Fire-and-forget; failures
146 // never break the push path.
147 void firePreviewBuilds(owner, repo, refs).catch((err) =>
148 console.warn("[branch-previews] dispatch error:", err)
149 );
150
6efae38Claude151 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
152 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
153 // and debug console.log calls. Opens one issue per finding type per
154 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1.
155 // Fire-and-forget; never blocks the push path.
156 for (const ref of refs) {
157 if (ref.newSha.startsWith("0000")) continue;
158 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
159 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
160 );
161 }
162
da3fc18Claude163 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
164 // every push that touches a recognized manifest file (package.json,
165 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
166 // for critical/high findings and updates a weekly digest for
167 // medium/low. Fire-and-forget; never blocks the push path.
168 if (config.dependencyScanEnabled) {
169 void fireDependencyScan(owner, repo, refs).catch((err) =>
170 console.warn("[dependency-scanner] dispatch error:", err)
171 );
172 }
173
d199847Claude174 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
175 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
176 // regions, hashes the referenced source, and opens a PR labelled
177 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
178 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
179 // or empty doc_tracking table never breaks the push.
180 void fireDocDriftCheck(owner, repo).catch((err) =>
181 console.warn("[ai-doc-updater] dispatch error:", err)
182 );
183
9c5223fClaude184 // 4g. Smart empty states — repo onboarding. On the very first push to a
185 // repo's default branch (oldSha all-zeros), generate a README draft,
186 // suggested labels, and gates.yml starter using Claude Sonnet.
187 // Idempotent: ensureRepoOnboarding skips if a row already exists.
188 // Fire-and-forget; never blocks the push path.
189 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
190 console.warn("[repo-onboarding] dispatch error:", err)
191 );
192
9ecf5a4Claude193 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
194 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
195 // on a push to its
ba93444Claude196 // default branch. The branch case (`Main` vs `main`) is determined by
197 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude198 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude199 let defaultBranch =
200 (await getDefaultBranch(owner, repo).catch((err) => {
201 console.warn(
202 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
203 err instanceof Error ? err.message : err
204 );
205 return null;
206 })) || "main";
ba93444Claude207 const targetRef = `refs/heads/${defaultBranch}`;
208 const deployPush = refs.find(
209 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
210 );
211 if (deployPush) {
212 let repositoryId = "";
213 try {
214 const [row] = await db
215 .select({ id: repositories.id })
216 .from(repositories)
217 .innerJoin(users, eq(repositories.ownerId, users.id))
218 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
219 .limit(1);
220 repositoryId = row?.id || "";
221 } catch {
222 /* ignore */
223 }
224 if (repositoryId) {
9ecf5a4Claude225 triggerVapronDeploy({
ba93444Claude226 owner,
227 repo,
228 before: deployPush.oldSha,
229 after: deployPush.newSha,
230 ref: targetRef,
231 branch: defaultBranch,
232 repositoryId,
9ecf5a4Claude233 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude234 }
0316dbbClaude235 }
fc1817aClaude236 }
f2c00b4CC LABS App237
783dd46Claude238 // 5b. Block ST — Server targets. After core post-receive work, fire
239 // deploys against any `server_targets` row whose
240 // (watched_repository_id, watched_branch) matches a pushed ref.
241 // Fire-and-forget; deploy results land in `server_target_deployments`
242 // and the UI at /admin/servers/:id surfaces them.
243 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
244 console.warn("[server-targets] dispatch error:", err)
245 );
246
c9ed210Claude247 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
248 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
249 // webhook for any cloud_deploy_configs rows matching the pushed branch.
250 // Fire-and-forget; all failures are swallowed so the push path is
251 // never blocked.
252 void fireCloudDeploys(owner, repo, refs).catch((err) =>
253 console.warn("[cloud-deploy] dispatch error:", err)
254 );
255
f2c00b4CC LABS App256 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
257 // main, fire the local deploy via scripts/self-deploy.sh. The script
258 // forks into the background, so this call returns immediately (git
259 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
260 // avoid firing on customer repos that happen to be named "Gluecron.com".
261 const selfHostRepo = process.env.SELF_HOST_REPO;
262 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
263 const mainRef = refs.find(
264 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
265 );
266 if (mainRef) {
267 const scriptPath =
268 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
269 "/opt/gluecron/scripts/self-deploy.sh";
270 try {
271 const child = __selfHostSpawn(
272 [scriptPath, mainRef.oldSha, mainRef.newSha],
273 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
274 );
275 try {
276 (child as any)?.unref?.();
277 } catch {
278 /* unref optional */
279 }
280 console.log(
281 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
282 );
283 } catch (err) {
284 console.error(`[self-host] failed to spawn:`, err);
285 }
286 }
287 }
288}
289
290// BLOCK W — DI seam so the test suite can capture the spawn call without
291// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
292// callers go straight to Bun.spawn.
bf19c50Test User293const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App294 Bun.spawn(cmd, opts);
bf19c50Test User295let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
296/**
297 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
298 */
299export function __setSelfHostSpawnForTests(
300 fn: typeof __selfHostSpawn | null
301): void {
302 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude303}
304
43cf9b0Claude305/**
9ecf5a4Claude306 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude307 *
9ecf5a4Claude308 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude309 *
9ecf5a4Claude310 * POST https://vapron.ai/api/webhooks/gluecron-push
43cf9b0Claude311 * Content-Type: application/json
ba93444Claude312 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude313 *
314 * {
ba93444Claude315 * "event": "push",
9ecf5a4Claude316 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude317 * "ref": "refs/heads/Main",
318 * "after": "<40-hex commit SHA>",
319 * "before": "<40-hex previous SHA>",
320 * "pusher": { "name": "<author>", "email": "<email>" },
321 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude322 * }
323 *
ba93444Claude324 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude325 *
ba93444Claude326 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
327 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude328 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude329 * we still record the deploy row as failed.
43cf9b0Claude330 */
ba93444Claude331const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
332
333interface TriggerArgs {
334 owner: string;
335 repo: string;
336 before: string;
337 after: string;
338 ref: string;
339 branch: string;
340 repositoryId: string;
341}
342
343interface TriggerOptions {
344 fetchImpl?: typeof fetch;
345 sleep?: (ms: number) => Promise<void>;
346 retryDelaysMs?: number[];
347 now?: () => Date;
348}
349
350function signBody(body: string, secret: string): string | null {
351 if (!secret) return null;
352 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
353}
354
355async function buildPayload(args: TriggerArgs, now: Date): Promise<{
356 payload: Record<string, unknown>;
357 pusherName: string;
358 pusherEmail: string;
359}> {
360 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
361 // `before` may be all-zeros for a first push to the branch — commitsBetween
362 // handles that by treating null `from` as "everything reachable from `to`".
363 const fromSha = /^0+$/.test(args.before) ? null : args.before;
364 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
365 let pusherName = "gluecron";
366 let pusherEmail = "noreply@gluecron.local";
367 try {
368 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
369 commits = list.slice(0, 50).map((c) => ({
370 id: c.sha,
371 message: c.message,
372 timestamp: c.date,
373 }));
374 if (list[0]) {
375 pusherName = list[0].author || pusherName;
376 pusherEmail = list[0].authorEmail || pusherEmail;
377 }
378 } catch {
379 /* ignore — payload still valid with empty commits[] */
380 }
381 return {
382 payload: {
383 event: "push",
384 repository: { full_name: `${args.owner}/${args.repo}` },
385 ref: args.ref,
386 after: args.after,
387 before: args.before,
388 pusher: { name: pusherName, email: pusherEmail },
389 commits,
390 // Ancillary fields — receiver may ignore but they're useful for logs:
391 sent_at: now.toISOString(),
392 source: "gluecron",
393 },
394 pusherName,
395 pusherEmail,
396 };
397}
398
9ecf5a4Claude399async function triggerVapronDeploy(
ba93444Claude400 args: TriggerArgs,
401 opts: TriggerOptions = {}
fc1817aClaude402): Promise<void> {
ba93444Claude403 const fetchImpl = opts.fetchImpl ?? fetch;
404 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
405 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
406 const now = opts.now ?? (() => new Date());
407
3ef4c9dClaude408 let deployId = "";
fc1817aClaude409 try {
3ef4c9dClaude410 const [row] = await db
411 .insert(deployments)
412 .values({
ba93444Claude413 repositoryId: args.repositoryId,
3ef4c9dClaude414 environment: "production",
ba93444Claude415 commitSha: args.after,
416 ref: args.ref,
3ef4c9dClaude417 status: "pending",
9ecf5a4Claude418 target: "vapron",
3ef4c9dClaude419 })
420 .returning();
421 deployId = row?.id || "";
422 } catch {
423 /* ignore */
fc1817aClaude424 }
425
ba93444Claude426 const { payload } = await buildPayload(args, now());
427 const body = JSON.stringify(payload);
9ecf5a4Claude428 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude429
430 const headers: Record<string, string> = {
431 "Content-Type": "application/json",
432 "User-Agent": "gluecron-webhook/1",
433 "X-Gluecron-Event": "push",
434 "X-Gluecron-Delivery": cryptoRandomId(),
435 };
436 if (signature) headers["X-Gluecron-Signature"] = signature;
437
438 let lastStatus = 0;
439 let lastError = "";
440 let success = false;
441
442 // Up to delays.length + 1 attempts (initial try + each delay).
443 const totalAttempts = delays.length + 1;
444 for (let attempt = 0; attempt < totalAttempts; attempt++) {
445 try {
9ecf5a4Claude446 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude447 method: "POST",
448 headers,
449 body,
450 });
451 lastStatus = response.status;
452 console.log(
9ecf5a4Claude453 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude454 );
455 if (response.ok) {
456 success = true;
457 break;
458 }
459 // 4xx (except 408/429) is unrecoverable — stop retrying.
460 if (response.status >= 400 && response.status < 500 &&
461 response.status !== 408 && response.status !== 429) {
462 break;
463 }
464 } catch (err) {
465 lastError = err instanceof Error ? err.message : String(err);
466 console.error(
9ecf5a4Claude467 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude468 );
3ef4c9dClaude469 }
ba93444Claude470 const nextDelay = delays[attempt];
471 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
472 await sleep(nextDelay);
1e162a8Claude473 }
ba93444Claude474 }
475
476 if (deployId) {
477 try {
3ef4c9dClaude478 await db
479 .update(deployments)
480 .set({
ba93444Claude481 status: success ? "success" : "failed",
482 blockedReason: success
483 ? null
484 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude485 completedAt: new Date(),
486 })
487 .where(eq(deployments.id, deployId));
ba93444Claude488 } catch {
489 /* ignore */
3ef4c9dClaude490 }
491 }
492
ba93444Claude493 if (!success && deployId) {
494 void onDeployFailure({
495 repositoryId: args.repositoryId,
496 deploymentId: deployId,
497 ref: args.ref,
498 commitSha: args.after,
9ecf5a4Claude499 target: "vapron",
ba93444Claude500 errorMessage: lastError || `HTTP ${lastStatus}`,
501 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude502 }
503}
43cf9b0Claude504
ba93444Claude505function cryptoRandomId(): string {
506 // Short opaque delivery ID for log correlation. Not security-sensitive.
507 const bytes = new Uint8Array(8);
508 crypto.getRandomValues(bytes);
509 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
510}
511
a686079Claude512/**
513 * Resolve `owner/repo` to its DB repository.id and dispatch the
514 * semantic-index update. Pulls the list of changed paths via
515 * `git diff --name-only`, dropping any deletions (handled implicitly
516 * because deleted blobs simply don't resolve in `indexChangedFiles`).
517 *
518 * Never throws — exhaust every external call inside a try/catch so the
519 * push completes even if Postgres or the embedding API is down.
520 */
521async function fireSemanticIndex(
522 owner: string,
523 repo: string,
524 oldSha: string,
525 newSha: string
526): Promise<void> {
527 let repositoryId = "";
528 try {
529 const [row] = await db
530 .select({ id: repositories.id })
531 .from(repositories)
532 .innerJoin(users, eq(repositories.ownerId, users.id))
533 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
534 .limit(1);
535 repositoryId = row?.id || "";
536 } catch {
537 return;
538 }
539 if (!repositoryId) return;
540
541 let changedPaths: string[] = [];
542 try {
543 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
544 } catch {
545 return;
546 }
547 if (!changedPaths.length) return;
548
549 try {
550 const out = await indexChangedFiles({
551 repositoryId,
552 ownerName: owner,
553 repoName: repo,
554 commitSha: newSha,
555 changedPaths,
556 });
557 if (out.indexed > 0) {
558 console.log(
559 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
560 );
561 }
562 } catch (err) {
563 console.warn("[semantic-index] indexChangedFiles error:", err);
564 }
565}
566
567/**
568 * Returns the list of files touched between `oldSha` and `newSha`. For
569 * the initial push on a branch (oldSha all-zero) we walk every file in
570 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
571 */
572async function listChangedPaths(
573 owner: string,
574 repo: string,
575 oldSha: string,
576 newSha: string
577): Promise<string[]> {
578 const cwd = getRepoPath(owner, repo);
579 const allZero = /^0+$/.test(oldSha);
580 const cmd = allZero
581 ? ["git", "ls-tree", "-r", "--name-only", newSha]
582 : ["git", "diff", "--name-only", oldSha, newSha];
583 try {
584 const proc = Bun.spawn(cmd, {
585 cwd,
586 stdout: "pipe",
587 stderr: "pipe",
588 });
589 const text = await new Response(proc.stdout).text();
590 await proc.exited;
591 return text
592 .split("\n")
593 .map((s) => s.trim())
594 .filter((s) => s.length > 0);
595 } catch {
596 return [];
597 }
598}
599
4bbacbeClaude600/**
601 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
602 * non-default branch on a `preview_builds_enabled` repo.
603 *
604 * Resolves the repo row once, fetches the default branch + opt-out flag,
605 * then upserts one preview row per pushed ref that isn't the default.
606 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
607 * are skipped — they shouldn't create preview rows. Never throws.
608 */
609async function firePreviewBuilds(
610 owner: string,
611 repo: string,
612 refs: PushRef[]
613): Promise<void> {
614 // Filter to live branch pushes only.
615 const branchRefs = refs.filter(
616 (r) =>
617 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
618 );
619 if (branchRefs.length === 0) return;
620
621 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
622 try {
623 const [row] = await db
624 .select({
625 id: repositories.id,
626 previewBuildsEnabled: repositories.previewBuildsEnabled,
627 defaultBranch: repositories.defaultBranch,
628 })
629 .from(repositories)
630 .innerJoin(users, eq(repositories.ownerId, users.id))
631 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
632 .limit(1);
633 repoRow = row || null;
634 } catch {
635 return;
636 }
637 if (!repoRow) return;
638 if (!repoRow.previewBuildsEnabled) return;
639
640 for (const ref of branchRefs) {
641 const branchName = ref.refName.replace("refs/heads/", "");
642 if (branchName === repoRow.defaultBranch) continue;
643 try {
644 await enqueuePreviewBuild({
645 repositoryId: repoRow.id,
646 ownerName: owner,
647 repoName: repo,
648 branchName,
649 commitSha: ref.newSha,
650 });
651 } catch (err) {
652 console.warn(
653 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
654 err instanceof Error ? err.message : err
655 );
656 }
657 }
658}
659
d199847Claude660/**
661 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
662 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
663 * on missing repo or DB error — pushes never block. Never throws.
664 */
665async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
666 let repositoryId = "";
667 try {
668 const [row] = await db
669 .select({ id: repositories.id })
670 .from(repositories)
671 .innerJoin(users, eq(repositories.ownerId, users.id))
672 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
673 .limit(1);
674 repositoryId = row?.id || "";
675 } catch {
676 return;
677 }
678 if (!repositoryId) return;
679 try {
680 const out = await runDocDriftCheckForRepo(repositoryId);
681 if (out.docs > 0 || out.proposed > 0) {
682 console.log(
683 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
684 );
685 }
686 } catch (err) {
687 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
688 }
689}
690
783dd46Claude691/**
692 * Block ST — fan out the push to any server targets that watch this
693 * (repo, branch). One sequential deploy per target so a slow box can't
694 * stall the next push, but multiple matching targets run in parallel.
695 * Every failure is contained to its own deploy row + console warn —
696 * nothing here can break the push path.
697 */
698async function fireServerTargetDeploys(
699 owner: string,
700 repo: string,
701 refs: PushRef[]
702): Promise<void> {
703 const liveRefs = refs.filter(
704 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
705 );
706 if (liveRefs.length === 0) return;
707
708 let repositoryId = "";
709 try {
710 const [row] = await db
711 .select({ id: repositories.id })
712 .from(repositories)
713 .innerJoin(users, eq(repositories.ownerId, users.id))
714 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
715 .limit(1);
716 repositoryId = row?.id || "";
717 } catch {
718 return;
719 }
720 if (!repositoryId) return;
721
722 await Promise.all(
723 liveRefs.map(async (ref) => {
724 const branch = ref.refName.replace("refs/heads/", "");
725 let targets;
726 try {
727 targets = await findTargetsForPush({ repositoryId, branch });
728 } catch {
729 return;
730 }
731 if (!targets.length) return;
732
733 await Promise.all(
734 targets.map(async (target) => {
735 try {
736 const env = await resolveEnv(target.id);
737 const deployId = await startDeployRow({
738 targetId: target.id,
739 commitSha: ref.newSha,
740 ref: ref.refName,
741 triggerSource: "push",
742 });
743 const result = await deployToTarget(target, {
744 commitSha: ref.newSha,
745 ref: ref.refName,
746 env,
747 });
748 if (deployId) {
749 await finishDeployRow({
750 id: deployId,
751 exitCode: result.exitCode,
752 stdout: result.stdout,
753 stderr: result.stderr,
754 });
755 }
756 console.log(
757 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
758 );
759 } catch (err) {
760 console.warn(
761 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
762 );
763 }
764 })
765 );
766 })
767 );
768}
769
da3fc18Claude770/**
771 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
772 * Resolves the repo DB row once, then runs the scanner on each live push.
773 * Swallows all errors so a scanner failure never affects the push path.
774 */
775async function fireDependencyScan(
776 owner: string,
777 repo: string,
778 refs: PushRef[]
779): Promise<void> {
780 const liveRefs = refs.filter(
781 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
782 );
783 if (liveRefs.length === 0) return;
784
785 let repoRow: { id: string; ownerId: string } | null = null;
786 try {
787 const [row] = await db
788 .select({ id: repositories.id, ownerId: repositories.ownerId })
789 .from(repositories)
790 .innerJoin(users, eq(repositories.ownerId, users.id))
791 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
792 .limit(1);
793 repoRow = row || null;
794 } catch {
795 return;
796 }
797 if (!repoRow) return;
798
799 for (const ref of liveRefs) {
800 try {
801 const findings = await scanDependencies(
802 repoRow.id,
803 owner,
804 repo,
805 ref.newSha,
806 ref.oldSha,
807 repoRow.ownerId
808 );
809 if (findings.length > 0) {
810 console.log(
811 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
812 );
813 }
814 } catch (err) {
815 console.warn(
816 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
817 err instanceof Error ? err.message : err
818 );
819 }
820 }
821}
822
9c5223fClaude823/**
824 * Migration 0088 — trigger repo onboarding on first push to the default branch.
825 * Detects "first push" by checking whether oldSha is all-zeros on a push to
826 * the repo's default branch (or any branch for a repo with no prior history).
827 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
828 * Never throws.
829 */
830async function fireRepoOnboarding(
831 owner: string,
832 repo: string,
833 refs: PushRef[]
834): Promise<void> {
835 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
836 const firstPushRefs = refs.filter(
837 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
838 );
839 if (firstPushRefs.length === 0) return;
840
841 let repositoryId = "";
842 try {
843 const [row] = await db
844 .select({ id: repositories.id })
845 .from(repositories)
846 .innerJoin(users, eq(repositories.ownerId, users.id))
847 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
848 .limit(1);
849 repositoryId = row?.id || "";
850 } catch {
851 return;
852 }
853 if (!repositoryId) return;
854
855 await ensureRepoOnboarding(repositoryId, owner, repo);
856}
857
43cf9b0Claude858/** Test-only access to internal helpers. */
a686079Claude859export const __test = {
9ecf5a4Claude860 triggerVapronDeploy,
a686079Claude861 signBody,
862 buildPayload,
863 RETRY_DELAYS_MS,
864 listChangedPaths,
865 fireSemanticIndex,
4bbacbeClaude866 firePreviewBuilds,
d199847Claude867 fireDocDriftCheck,
783dd46Claude868 fireServerTargetDeploys,
da3fc18Claude869 fireDependencyScan,
9c5223fClaude870 fireRepoOnboarding,
a686079Claude871};