Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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.tsBlame852 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";
170ddb2Claude17import { notifyGateTestOfPush } from "../lib/gate";
2c34075Claude18import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude19import { db } from "../db";
20import { deployments, repositories, users } from "../db/schema";
21import { onDeployFailure } from "../lib/ai-incident";
a686079Claude22import {
23 commitsBetween,
24 getDefaultBranch,
25 getRepoPath,
26} from "../git/repository";
27import { indexChangedFiles } from "../lib/semantic-index";
6efae38Claude28import { scanDiffForIssues } from "../lib/ai-auto-issues";
4bbacbeClaude29import { enqueuePreviewBuild } from "../lib/branch-previews";
da3fc18Claude30import { scanDependencies } from "../lib/dependency-scanner";
d199847Claude31import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
783dd46Claude32import {
33 findTargetsForPush,
34 finishDeployRow,
35 resolveEnv,
36 startDeployRow,
37} from "../lib/server-target-store";
38import { deployToTarget } from "../lib/server-targets";
7f992cdClaude39import { fireCloudDeploys } from "../lib/cloud-deploy";
9c5223fClaude40import { ensureRepoOnboarding } from "../lib/repo-onboarding";
fc1817aClaude41
42interface PushRef {
43 oldSha: string;
44 newSha: string;
45 refName: string;
46}
47
48export async function onPostReceive(
49 owner: string,
50 repo: string,
6efae38Claude51 refs: PushRef[],
52 pusherUserId: string = ""
fc1817aClaude53): Promise<void> {
2c34075Claude54 for (const ref of refs) {
55 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
56 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude57
2c34075Claude58 // 1. Auto-repair (runs first, may create a new commit)
59 try {
60 const repair = await autoRepair(owner, repo, branchName);
61 if (repair.repaired) {
62 console.log(
63 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
64 );
65 }
66 } catch (err) {
67 console.error(`[autorepair] error:`, err);
68 }
fc1817aClaude69
2c34075Claude70 // 2. Push analysis
71 try {
72 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
73 console.log(
74 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
75 );
76 if (analysis.riskScore > 50) {
77 console.warn(
78 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
79 );
80 }
81 if (analysis.breakingChangeSignals.length > 0) {
82 console.warn(
83 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
84 );
85 }
86 } catch (err) {
87 console.error(`[push-analysis] error:`, err);
88 }
89
90 // 3. Health score (async, don't block)
91 computeHealthScore(owner, repo).then((report) => {
92 console.log(
93 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
94 );
95 }).catch((err) => {
96 console.error(`[health] error:`, err);
97 });
98 }
99
170ddb2Claude100 // 4. GateTest scan — fire-and-forget notification on every push. The
101 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
102 // deployments pay no overhead. Results flow back via the inbound
103 // webhook at POST /api/hooks/gatetest.
104 for (const ref of refs) {
105 if (ref.newSha.startsWith("0000")) continue;
106 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
107 console.warn("[gatetest] notify error:", err)
108 );
109 }
2c34075Claude110
a686079Claude111 // 4b. Continuous semantic index — embed changed files on every push so
112 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
113 // all failures are swallowed inside semantic-index.ts so a missing
114 // pgvector extension or absent embeddings API key never breaks the
115 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
116 for (const ref of refs) {
117 if (ref.newSha.startsWith("0000")) continue;
118 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
119 console.warn("[semantic-index] dispatch error:", err)
120 );
121 }
122
4bbacbeClaude123 // 4c. Per-branch preview URLs (migration 0062). Every push to a
124 // non-default branch enqueues a preview-build row. Gated on
125 // repositories.preview_builds_enabled (default on) so owners can
126 // opt out per-repo via repo-settings. Fire-and-forget; failures
127 // never break the push path.
128 void firePreviewBuilds(owner, repo, refs).catch((err) =>
129 console.warn("[branch-previews] dispatch error:", err)
130 );
131
6efae38Claude132 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
133 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
134 // and debug console.log calls. Opens one issue per finding type per
135 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1.
136 // Fire-and-forget; never blocks the push path.
137 for (const ref of refs) {
138 if (ref.newSha.startsWith("0000")) continue;
139 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
140 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
141 );
142 }
143
da3fc18Claude144 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
145 // every push that touches a recognized manifest file (package.json,
146 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
147 // for critical/high findings and updates a weekly digest for
148 // medium/low. Fire-and-forget; never blocks the push path.
149 if (config.dependencyScanEnabled) {
150 void fireDependencyScan(owner, repo, refs).catch((err) =>
151 console.warn("[dependency-scanner] dispatch error:", err)
152 );
153 }
154
d199847Claude155 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
156 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
157 // regions, hashes the referenced source, and opens a PR labelled
158 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
159 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
160 // or empty doc_tracking table never breaks the push.
161 void fireDocDriftCheck(owner, repo).catch((err) =>
162 console.warn("[ai-doc-updater] dispatch error:", err)
163 );
164
9c5223fClaude165 // 4g. Smart empty states — repo onboarding. On the very first push to a
166 // repo's default branch (oldSha all-zeros), generate a README draft,
167 // suggested labels, and gates.yml starter using Claude Sonnet.
168 // Idempotent: ensureRepoOnboarding skips if a row already exists.
169 // Fire-and-forget; never blocks the push path.
170 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
171 console.warn("[repo-onboarding] dispatch error:", err)
172 );
173
9ecf5a4Claude174 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
175 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
176 // on a push to its
ba93444Claude177 // default branch. The branch case (`Main` vs `main`) is determined by
178 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude179 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude180 let defaultBranch =
181 (await getDefaultBranch(owner, repo).catch((err) => {
182 console.warn(
183 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
184 err instanceof Error ? err.message : err
185 );
186 return null;
187 })) || "main";
ba93444Claude188 const targetRef = `refs/heads/${defaultBranch}`;
189 const deployPush = refs.find(
190 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
191 );
192 if (deployPush) {
193 let repositoryId = "";
194 try {
195 const [row] = await db
196 .select({ id: repositories.id })
197 .from(repositories)
198 .innerJoin(users, eq(repositories.ownerId, users.id))
199 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
200 .limit(1);
201 repositoryId = row?.id || "";
202 } catch {
203 /* ignore */
204 }
205 if (repositoryId) {
9ecf5a4Claude206 triggerVapronDeploy({
ba93444Claude207 owner,
208 repo,
209 before: deployPush.oldSha,
210 after: deployPush.newSha,
211 ref: targetRef,
212 branch: defaultBranch,
213 repositoryId,
9ecf5a4Claude214 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude215 }
0316dbbClaude216 }
fc1817aClaude217 }
f2c00b4CC LABS App218
783dd46Claude219 // 5b. Block ST — Server targets. After core post-receive work, fire
220 // deploys against any `server_targets` row whose
221 // (watched_repository_id, watched_branch) matches a pushed ref.
222 // Fire-and-forget; deploy results land in `server_target_deployments`
223 // and the UI at /admin/servers/:id surfaces them.
224 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
225 console.warn("[server-targets] dispatch error:", err)
226 );
227
c9ed210Claude228 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
229 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
230 // webhook for any cloud_deploy_configs rows matching the pushed branch.
231 // Fire-and-forget; all failures are swallowed so the push path is
232 // never blocked.
233 void fireCloudDeploys(owner, repo, refs).catch((err) =>
234 console.warn("[cloud-deploy] dispatch error:", err)
235 );
236
f2c00b4CC LABS App237 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
238 // main, fire the local deploy via scripts/self-deploy.sh. The script
239 // forks into the background, so this call returns immediately (git
240 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
241 // avoid firing on customer repos that happen to be named "Gluecron.com".
242 const selfHostRepo = process.env.SELF_HOST_REPO;
243 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
244 const mainRef = refs.find(
245 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
246 );
247 if (mainRef) {
248 const scriptPath =
249 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
250 "/opt/gluecron/scripts/self-deploy.sh";
251 try {
252 const child = __selfHostSpawn(
253 [scriptPath, mainRef.oldSha, mainRef.newSha],
254 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
255 );
256 try {
257 (child as any)?.unref?.();
258 } catch {
259 /* unref optional */
260 }
261 console.log(
262 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
263 );
264 } catch (err) {
265 console.error(`[self-host] failed to spawn:`, err);
266 }
267 }
268 }
269}
270
271// BLOCK W — DI seam so the test suite can capture the spawn call without
272// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
273// callers go straight to Bun.spawn.
bf19c50Test User274const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App275 Bun.spawn(cmd, opts);
bf19c50Test User276let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
277/**
278 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
279 */
280export function __setSelfHostSpawnForTests(
281 fn: typeof __selfHostSpawn | null
282): void {
283 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude284}
285
43cf9b0Claude286/**
9ecf5a4Claude287 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude288 *
9ecf5a4Claude289 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude290 *
9ecf5a4Claude291 * POST https://vapron.ai/api/webhooks/gluecron-push
43cf9b0Claude292 * Content-Type: application/json
ba93444Claude293 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude294 *
295 * {
ba93444Claude296 * "event": "push",
9ecf5a4Claude297 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude298 * "ref": "refs/heads/Main",
299 * "after": "<40-hex commit SHA>",
300 * "before": "<40-hex previous SHA>",
301 * "pusher": { "name": "<author>", "email": "<email>" },
302 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude303 * }
304 *
ba93444Claude305 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude306 *
ba93444Claude307 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
308 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude309 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude310 * we still record the deploy row as failed.
43cf9b0Claude311 */
ba93444Claude312const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
313
314interface TriggerArgs {
315 owner: string;
316 repo: string;
317 before: string;
318 after: string;
319 ref: string;
320 branch: string;
321 repositoryId: string;
322}
323
324interface TriggerOptions {
325 fetchImpl?: typeof fetch;
326 sleep?: (ms: number) => Promise<void>;
327 retryDelaysMs?: number[];
328 now?: () => Date;
329}
330
331function signBody(body: string, secret: string): string | null {
332 if (!secret) return null;
333 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
334}
335
336async function buildPayload(args: TriggerArgs, now: Date): Promise<{
337 payload: Record<string, unknown>;
338 pusherName: string;
339 pusherEmail: string;
340}> {
341 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
342 // `before` may be all-zeros for a first push to the branch — commitsBetween
343 // handles that by treating null `from` as "everything reachable from `to`".
344 const fromSha = /^0+$/.test(args.before) ? null : args.before;
345 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
346 let pusherName = "gluecron";
347 let pusherEmail = "noreply@gluecron.local";
348 try {
349 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
350 commits = list.slice(0, 50).map((c) => ({
351 id: c.sha,
352 message: c.message,
353 timestamp: c.date,
354 }));
355 if (list[0]) {
356 pusherName = list[0].author || pusherName;
357 pusherEmail = list[0].authorEmail || pusherEmail;
358 }
359 } catch {
360 /* ignore — payload still valid with empty commits[] */
361 }
362 return {
363 payload: {
364 event: "push",
365 repository: { full_name: `${args.owner}/${args.repo}` },
366 ref: args.ref,
367 after: args.after,
368 before: args.before,
369 pusher: { name: pusherName, email: pusherEmail },
370 commits,
371 // Ancillary fields — receiver may ignore but they're useful for logs:
372 sent_at: now.toISOString(),
373 source: "gluecron",
374 },
375 pusherName,
376 pusherEmail,
377 };
378}
379
9ecf5a4Claude380async function triggerVapronDeploy(
ba93444Claude381 args: TriggerArgs,
382 opts: TriggerOptions = {}
fc1817aClaude383): Promise<void> {
ba93444Claude384 const fetchImpl = opts.fetchImpl ?? fetch;
385 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
386 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
387 const now = opts.now ?? (() => new Date());
388
3ef4c9dClaude389 let deployId = "";
fc1817aClaude390 try {
3ef4c9dClaude391 const [row] = await db
392 .insert(deployments)
393 .values({
ba93444Claude394 repositoryId: args.repositoryId,
3ef4c9dClaude395 environment: "production",
ba93444Claude396 commitSha: args.after,
397 ref: args.ref,
3ef4c9dClaude398 status: "pending",
9ecf5a4Claude399 target: "vapron",
3ef4c9dClaude400 })
401 .returning();
402 deployId = row?.id || "";
403 } catch {
404 /* ignore */
fc1817aClaude405 }
406
ba93444Claude407 const { payload } = await buildPayload(args, now());
408 const body = JSON.stringify(payload);
9ecf5a4Claude409 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude410
411 const headers: Record<string, string> = {
412 "Content-Type": "application/json",
413 "User-Agent": "gluecron-webhook/1",
414 "X-Gluecron-Event": "push",
415 "X-Gluecron-Delivery": cryptoRandomId(),
416 };
417 if (signature) headers["X-Gluecron-Signature"] = signature;
418
419 let lastStatus = 0;
420 let lastError = "";
421 let success = false;
422
423 // Up to delays.length + 1 attempts (initial try + each delay).
424 const totalAttempts = delays.length + 1;
425 for (let attempt = 0; attempt < totalAttempts; attempt++) {
426 try {
9ecf5a4Claude427 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude428 method: "POST",
429 headers,
430 body,
431 });
432 lastStatus = response.status;
433 console.log(
9ecf5a4Claude434 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude435 );
436 if (response.ok) {
437 success = true;
438 break;
439 }
440 // 4xx (except 408/429) is unrecoverable — stop retrying.
441 if (response.status >= 400 && response.status < 500 &&
442 response.status !== 408 && response.status !== 429) {
443 break;
444 }
445 } catch (err) {
446 lastError = err instanceof Error ? err.message : String(err);
447 console.error(
9ecf5a4Claude448 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude449 );
3ef4c9dClaude450 }
ba93444Claude451 const nextDelay = delays[attempt];
452 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
453 await sleep(nextDelay);
1e162a8Claude454 }
ba93444Claude455 }
456
457 if (deployId) {
458 try {
3ef4c9dClaude459 await db
460 .update(deployments)
461 .set({
ba93444Claude462 status: success ? "success" : "failed",
463 blockedReason: success
464 ? null
465 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude466 completedAt: new Date(),
467 })
468 .where(eq(deployments.id, deployId));
ba93444Claude469 } catch {
470 /* ignore */
3ef4c9dClaude471 }
472 }
473
ba93444Claude474 if (!success && deployId) {
475 void onDeployFailure({
476 repositoryId: args.repositoryId,
477 deploymentId: deployId,
478 ref: args.ref,
479 commitSha: args.after,
9ecf5a4Claude480 target: "vapron",
ba93444Claude481 errorMessage: lastError || `HTTP ${lastStatus}`,
482 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude483 }
484}
43cf9b0Claude485
ba93444Claude486function cryptoRandomId(): string {
487 // Short opaque delivery ID for log correlation. Not security-sensitive.
488 const bytes = new Uint8Array(8);
489 crypto.getRandomValues(bytes);
490 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
491}
492
a686079Claude493/**
494 * Resolve `owner/repo` to its DB repository.id and dispatch the
495 * semantic-index update. Pulls the list of changed paths via
496 * `git diff --name-only`, dropping any deletions (handled implicitly
497 * because deleted blobs simply don't resolve in `indexChangedFiles`).
498 *
499 * Never throws — exhaust every external call inside a try/catch so the
500 * push completes even if Postgres or the embedding API is down.
501 */
502async function fireSemanticIndex(
503 owner: string,
504 repo: string,
505 oldSha: string,
506 newSha: string
507): Promise<void> {
508 let repositoryId = "";
509 try {
510 const [row] = await db
511 .select({ id: repositories.id })
512 .from(repositories)
513 .innerJoin(users, eq(repositories.ownerId, users.id))
514 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
515 .limit(1);
516 repositoryId = row?.id || "";
517 } catch {
518 return;
519 }
520 if (!repositoryId) return;
521
522 let changedPaths: string[] = [];
523 try {
524 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
525 } catch {
526 return;
527 }
528 if (!changedPaths.length) return;
529
530 try {
531 const out = await indexChangedFiles({
532 repositoryId,
533 ownerName: owner,
534 repoName: repo,
535 commitSha: newSha,
536 changedPaths,
537 });
538 if (out.indexed > 0) {
539 console.log(
540 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
541 );
542 }
543 } catch (err) {
544 console.warn("[semantic-index] indexChangedFiles error:", err);
545 }
546}
547
548/**
549 * Returns the list of files touched between `oldSha` and `newSha`. For
550 * the initial push on a branch (oldSha all-zero) we walk every file in
551 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
552 */
553async function listChangedPaths(
554 owner: string,
555 repo: string,
556 oldSha: string,
557 newSha: string
558): Promise<string[]> {
559 const cwd = getRepoPath(owner, repo);
560 const allZero = /^0+$/.test(oldSha);
561 const cmd = allZero
562 ? ["git", "ls-tree", "-r", "--name-only", newSha]
563 : ["git", "diff", "--name-only", oldSha, newSha];
564 try {
565 const proc = Bun.spawn(cmd, {
566 cwd,
567 stdout: "pipe",
568 stderr: "pipe",
569 });
570 const text = await new Response(proc.stdout).text();
571 await proc.exited;
572 return text
573 .split("\n")
574 .map((s) => s.trim())
575 .filter((s) => s.length > 0);
576 } catch {
577 return [];
578 }
579}
580
4bbacbeClaude581/**
582 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
583 * non-default branch on a `preview_builds_enabled` repo.
584 *
585 * Resolves the repo row once, fetches the default branch + opt-out flag,
586 * then upserts one preview row per pushed ref that isn't the default.
587 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
588 * are skipped — they shouldn't create preview rows. Never throws.
589 */
590async function firePreviewBuilds(
591 owner: string,
592 repo: string,
593 refs: PushRef[]
594): Promise<void> {
595 // Filter to live branch pushes only.
596 const branchRefs = refs.filter(
597 (r) =>
598 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
599 );
600 if (branchRefs.length === 0) return;
601
602 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
603 try {
604 const [row] = await db
605 .select({
606 id: repositories.id,
607 previewBuildsEnabled: repositories.previewBuildsEnabled,
608 defaultBranch: repositories.defaultBranch,
609 })
610 .from(repositories)
611 .innerJoin(users, eq(repositories.ownerId, users.id))
612 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
613 .limit(1);
614 repoRow = row || null;
615 } catch {
616 return;
617 }
618 if (!repoRow) return;
619 if (!repoRow.previewBuildsEnabled) return;
620
621 for (const ref of branchRefs) {
622 const branchName = ref.refName.replace("refs/heads/", "");
623 if (branchName === repoRow.defaultBranch) continue;
624 try {
625 await enqueuePreviewBuild({
626 repositoryId: repoRow.id,
627 ownerName: owner,
628 repoName: repo,
629 branchName,
630 commitSha: ref.newSha,
631 });
632 } catch (err) {
633 console.warn(
634 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
635 err instanceof Error ? err.message : err
636 );
637 }
638 }
639}
640
d199847Claude641/**
642 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
643 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
644 * on missing repo or DB error — pushes never block. Never throws.
645 */
646async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
647 let repositoryId = "";
648 try {
649 const [row] = await db
650 .select({ id: repositories.id })
651 .from(repositories)
652 .innerJoin(users, eq(repositories.ownerId, users.id))
653 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
654 .limit(1);
655 repositoryId = row?.id || "";
656 } catch {
657 return;
658 }
659 if (!repositoryId) return;
660 try {
661 const out = await runDocDriftCheckForRepo(repositoryId);
662 if (out.docs > 0 || out.proposed > 0) {
663 console.log(
664 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
665 );
666 }
667 } catch (err) {
668 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
669 }
670}
671
783dd46Claude672/**
673 * Block ST — fan out the push to any server targets that watch this
674 * (repo, branch). One sequential deploy per target so a slow box can't
675 * stall the next push, but multiple matching targets run in parallel.
676 * Every failure is contained to its own deploy row + console warn —
677 * nothing here can break the push path.
678 */
679async function fireServerTargetDeploys(
680 owner: string,
681 repo: string,
682 refs: PushRef[]
683): Promise<void> {
684 const liveRefs = refs.filter(
685 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
686 );
687 if (liveRefs.length === 0) return;
688
689 let repositoryId = "";
690 try {
691 const [row] = await db
692 .select({ id: repositories.id })
693 .from(repositories)
694 .innerJoin(users, eq(repositories.ownerId, users.id))
695 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
696 .limit(1);
697 repositoryId = row?.id || "";
698 } catch {
699 return;
700 }
701 if (!repositoryId) return;
702
703 await Promise.all(
704 liveRefs.map(async (ref) => {
705 const branch = ref.refName.replace("refs/heads/", "");
706 let targets;
707 try {
708 targets = await findTargetsForPush({ repositoryId, branch });
709 } catch {
710 return;
711 }
712 if (!targets.length) return;
713
714 await Promise.all(
715 targets.map(async (target) => {
716 try {
717 const env = await resolveEnv(target.id);
718 const deployId = await startDeployRow({
719 targetId: target.id,
720 commitSha: ref.newSha,
721 ref: ref.refName,
722 triggerSource: "push",
723 });
724 const result = await deployToTarget(target, {
725 commitSha: ref.newSha,
726 ref: ref.refName,
727 env,
728 });
729 if (deployId) {
730 await finishDeployRow({
731 id: deployId,
732 exitCode: result.exitCode,
733 stdout: result.stdout,
734 stderr: result.stderr,
735 });
736 }
737 console.log(
738 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
739 );
740 } catch (err) {
741 console.warn(
742 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
743 );
744 }
745 })
746 );
747 })
748 );
749}
750
da3fc18Claude751/**
752 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
753 * Resolves the repo DB row once, then runs the scanner on each live push.
754 * Swallows all errors so a scanner failure never affects the push path.
755 */
756async function fireDependencyScan(
757 owner: string,
758 repo: string,
759 refs: PushRef[]
760): Promise<void> {
761 const liveRefs = refs.filter(
762 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
763 );
764 if (liveRefs.length === 0) return;
765
766 let repoRow: { id: string; ownerId: string } | null = null;
767 try {
768 const [row] = await db
769 .select({ id: repositories.id, ownerId: repositories.ownerId })
770 .from(repositories)
771 .innerJoin(users, eq(repositories.ownerId, users.id))
772 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
773 .limit(1);
774 repoRow = row || null;
775 } catch {
776 return;
777 }
778 if (!repoRow) return;
779
780 for (const ref of liveRefs) {
781 try {
782 const findings = await scanDependencies(
783 repoRow.id,
784 owner,
785 repo,
786 ref.newSha,
787 ref.oldSha,
788 repoRow.ownerId
789 );
790 if (findings.length > 0) {
791 console.log(
792 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
793 );
794 }
795 } catch (err) {
796 console.warn(
797 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
798 err instanceof Error ? err.message : err
799 );
800 }
801 }
802}
803
9c5223fClaude804/**
805 * Migration 0088 — trigger repo onboarding on first push to the default branch.
806 * Detects "first push" by checking whether oldSha is all-zeros on a push to
807 * the repo's default branch (or any branch for a repo with no prior history).
808 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
809 * Never throws.
810 */
811async function fireRepoOnboarding(
812 owner: string,
813 repo: string,
814 refs: PushRef[]
815): Promise<void> {
816 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
817 const firstPushRefs = refs.filter(
818 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
819 );
820 if (firstPushRefs.length === 0) return;
821
822 let repositoryId = "";
823 try {
824 const [row] = await db
825 .select({ id: repositories.id })
826 .from(repositories)
827 .innerJoin(users, eq(repositories.ownerId, users.id))
828 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
829 .limit(1);
830 repositoryId = row?.id || "";
831 } catch {
832 return;
833 }
834 if (!repositoryId) return;
835
836 await ensureRepoOnboarding(repositoryId, owner, repo);
837}
838
43cf9b0Claude839/** Test-only access to internal helpers. */
a686079Claude840export const __test = {
9ecf5a4Claude841 triggerVapronDeploy,
a686079Claude842 signBody,
843 buildPayload,
844 RETRY_DELAYS_MS,
845 listChangedPaths,
846 fireSemanticIndex,
4bbacbeClaude847 firePreviewBuilds,
d199847Claude848 fireDocDriftCheck,
783dd46Claude849 fireServerTargetDeploys,
da3fc18Claude850 fireDependencyScan,
9c5223fClaude851 fireRepoOnboarding,
a686079Claude852};