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.tsBlame878 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> {
f183fdfClaude55 // Resolve per-repo automation settings once for this push. Fails open —
56 // any DB error leaves automationSettings null and all per-repo gates pass
57 // (unchanged behavior for repos without a settings row).
64aa989Claude58 let repoId = "";
59 try {
60 const [repoRow] = await db
61 .select({ id: repositories.id })
62 .from(repositories)
63 .innerJoin(users, eq(repositories.ownerId, users.id))
64 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
65 .limit(1);
66 repoId = repoRow?.id ?? "";
67 } catch { /* non-blocking */ }
68
69 const automationSettings = repoId
70 ? await getAutomationSettings(repoId).catch(() => null)
71 : null;
72
2c34075Claude73 for (const ref of refs) {
74 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
75 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude76
64aa989Claude77 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
78 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
79 try {
80 const repair = await autoRepair(owner, repo, branchName);
81 if (repair.repaired) {
82 console.log(
83 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
84 );
85 }
86 } catch (err) {
87 console.error(`[autorepair] error:`, err);
2c34075Claude88 }
89 }
fc1817aClaude90
2c34075Claude91 // 2. Push analysis
92 try {
93 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
94 console.log(
95 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
96 );
97 if (analysis.riskScore > 50) {
98 console.warn(
99 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
100 );
101 }
102 if (analysis.breakingChangeSignals.length > 0) {
103 console.warn(
104 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
105 );
106 }
107 } catch (err) {
108 console.error(`[push-analysis] error:`, err);
109 }
110
111 // 3. Health score (async, don't block)
112 computeHealthScore(owner, repo).then((report) => {
113 console.log(
114 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
115 );
116 }).catch((err) => {
117 console.error(`[health] error:`, err);
118 });
119 }
120
170ddb2Claude121 // 4. GateTest scan — fire-and-forget notification on every push. The
122 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
123 // deployments pay no overhead. Results flow back via the inbound
124 // webhook at POST /api/hooks/gatetest.
125 for (const ref of refs) {
126 if (ref.newSha.startsWith("0000")) continue;
127 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
128 console.warn("[gatetest] notify error:", err)
129 );
130 }
2c34075Claude131
a686079Claude132 // 4b. Continuous semantic index — embed changed files on every push so
133 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
134 // all failures are swallowed inside semantic-index.ts so a missing
135 // pgvector extension or absent embeddings API key never breaks the
136 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
137 for (const ref of refs) {
138 if (ref.newSha.startsWith("0000")) continue;
139 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
140 console.warn("[semantic-index] dispatch error:", err)
141 );
142 }
143
4bbacbeClaude144 // 4c. Per-branch preview URLs (migration 0062). Every push to a
145 // non-default branch enqueues a preview-build row. Gated on
146 // repositories.preview_builds_enabled (default on) so owners can
147 // opt out per-repo via repo-settings. Fire-and-forget; failures
148 // never break the push path.
149 void firePreviewBuilds(owner, repo, refs).catch((err) =>
150 console.warn("[branch-previews] dispatch error:", err)
151 );
152
6efae38Claude153 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
154 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
155 // and debug console.log calls. Opens one issue per finding type per
f183fdfClaude156 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1 and
157 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
6efae38Claude158 for (const ref of refs) {
159 if (ref.newSha.startsWith("0000")) continue;
f183fdfClaude160 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
161 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
162 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
163 );
164 }
6efae38Claude165 }
166
da3fc18Claude167 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
168 // every push that touches a recognized manifest file (package.json,
169 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
170 // for critical/high findings and updates a weekly digest for
171 // medium/low. Fire-and-forget; never blocks the push path.
172 if (config.dependencyScanEnabled) {
173 void fireDependencyScan(owner, repo, refs).catch((err) =>
174 console.warn("[dependency-scanner] dispatch error:", err)
175 );
176 }
177
d199847Claude178 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
179 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
180 // regions, hashes the referenced source, and opens a PR labelled
f183fdfClaude181 // `ai:doc-update` when the prose drifts. Gated on per-repo
182 // docDriftMode. Fire-and-forget; failures are swallowed inside
183 // ai-doc-updater.ts so a missing anthropic key or empty
184 // doc_tracking table never breaks the push.
185 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
186 void fireDocDriftCheck(owner, repo).catch((err) =>
187 console.warn("[ai-doc-updater] dispatch error:", err)
188 );
189 }
d199847Claude190
9c5223fClaude191 // 4g. Smart empty states — repo onboarding. On the very first push to a
192 // repo's default branch (oldSha all-zeros), generate a README draft,
193 // suggested labels, and gates.yml starter using Claude Sonnet.
194 // Idempotent: ensureRepoOnboarding skips if a row already exists.
195 // Fire-and-forget; never blocks the push path.
196 void fireRepoOnboarding(owner, repo, refs).catch((err) =>
197 console.warn("[repo-onboarding] dispatch error:", err)
198 );
199
9ecf5a4Claude200 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
201 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
202 // on a push to its
ba93444Claude203 // default branch. The branch case (`Main` vs `main`) is determined by
204 // the bare repo's HEAD, not hardcoded.
9ecf5a4Claude205 if (`${owner}/${repo}` === config.vapronRepo) {
a28cedeClaude206 let defaultBranch =
207 (await getDefaultBranch(owner, repo).catch((err) => {
208 console.warn(
209 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
210 err instanceof Error ? err.message : err
211 );
212 return null;
213 })) || "main";
ba93444Claude214 const targetRef = `refs/heads/${defaultBranch}`;
215 const deployPush = refs.find(
216 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
217 );
218 if (deployPush) {
219 let repositoryId = "";
220 try {
221 const [row] = await db
222 .select({ id: repositories.id })
223 .from(repositories)
224 .innerJoin(users, eq(repositories.ownerId, users.id))
225 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
226 .limit(1);
227 repositoryId = row?.id || "";
228 } catch {
229 /* ignore */
230 }
231 if (repositoryId) {
9ecf5a4Claude232 triggerVapronDeploy({
ba93444Claude233 owner,
234 repo,
235 before: deployPush.oldSha,
236 after: deployPush.newSha,
237 ref: targetRef,
238 branch: defaultBranch,
239 repositoryId,
9ecf5a4Claude240 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
ba93444Claude241 }
0316dbbClaude242 }
fc1817aClaude243 }
f2c00b4CC LABS App244
783dd46Claude245 // 5b. Block ST — Server targets. After core post-receive work, fire
246 // deploys against any `server_targets` row whose
247 // (watched_repository_id, watched_branch) matches a pushed ref.
248 // Fire-and-forget; deploy results land in `server_target_deployments`
249 // and the UI at /admin/servers/:id surfaces them.
250 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
251 console.warn("[server-targets] dispatch error:", err)
252 );
253
c9ed210Claude254 // 5c. Cloud deploy integrations (migration 0077). Fire push-triggered
255 // deploys to Fly.io, Railway, Render, Vercel, Netlify, or a generic
256 // webhook for any cloud_deploy_configs rows matching the pushed branch.
257 // Fire-and-forget; all failures are swallowed so the push path is
258 // never blocked.
259 void fireCloudDeploys(owner, repo, refs).catch((err) =>
260 console.warn("[cloud-deploy] dispatch error:", err)
261 );
262
f2c00b4CC LABS App263 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
264 // main, fire the local deploy via scripts/self-deploy.sh. The script
265 // forks into the background, so this call returns immediately (git
266 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
267 // avoid firing on customer repos that happen to be named "Gluecron.com".
268 const selfHostRepo = process.env.SELF_HOST_REPO;
269 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
270 const mainRef = refs.find(
271 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
272 );
273 if (mainRef) {
274 const scriptPath =
275 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
276 "/opt/gluecron/scripts/self-deploy.sh";
277 try {
278 const child = __selfHostSpawn(
279 [scriptPath, mainRef.oldSha, mainRef.newSha],
280 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
281 );
282 try {
283 (child as any)?.unref?.();
284 } catch {
285 /* unref optional */
286 }
287 console.log(
288 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
289 );
290 } catch (err) {
291 console.error(`[self-host] failed to spawn:`, err);
292 }
293 }
294 }
295}
296
297// BLOCK W — DI seam so the test suite can capture the spawn call without
298// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
299// callers go straight to Bun.spawn.
bf19c50Test User300const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App301 Bun.spawn(cmd, opts);
bf19c50Test User302let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
303/**
304 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
305 */
306export function __setSelfHostSpawnForTests(
307 fn: typeof __selfHostSpawn | null
308): void {
309 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude310}
311
43cf9b0Claude312/**
9ecf5a4Claude313 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
43cf9b0Claude314 *
9ecf5a4Claude315 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude316 *
9ecf5a4Claude317 * POST https://vapron.ai/api/webhooks/gluecron-push
43cf9b0Claude318 * Content-Type: application/json
ba93444Claude319 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude320 *
321 * {
ba93444Claude322 * "event": "push",
9ecf5a4Claude323 * "repository": { "full_name": "ccantynz-alt/vapron" },
ba93444Claude324 * "ref": "refs/heads/Main",
325 * "after": "<40-hex commit SHA>",
326 * "before": "<40-hex previous SHA>",
327 * "pusher": { "name": "<author>", "email": "<email>" },
328 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude329 * }
330 *
ba93444Claude331 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude332 *
ba93444Claude333 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
334 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
9ecf5a4Claude335 * is unset the signature header is omitted and Vapron is expected to reject —
ba93444Claude336 * we still record the deploy row as failed.
43cf9b0Claude337 */
ba93444Claude338const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
339
340interface TriggerArgs {
341 owner: string;
342 repo: string;
343 before: string;
344 after: string;
345 ref: string;
346 branch: string;
347 repositoryId: string;
348}
349
350interface TriggerOptions {
351 fetchImpl?: typeof fetch;
352 sleep?: (ms: number) => Promise<void>;
353 retryDelaysMs?: number[];
354 now?: () => Date;
355}
356
357function signBody(body: string, secret: string): string | null {
358 if (!secret) return null;
359 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
360}
361
362async function buildPayload(args: TriggerArgs, now: Date): Promise<{
363 payload: Record<string, unknown>;
364 pusherName: string;
365 pusherEmail: string;
366}> {
367 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
368 // `before` may be all-zeros for a first push to the branch — commitsBetween
369 // handles that by treating null `from` as "everything reachable from `to`".
370 const fromSha = /^0+$/.test(args.before) ? null : args.before;
371 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
372 let pusherName = "gluecron";
373 let pusherEmail = "noreply@gluecron.local";
374 try {
375 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
376 commits = list.slice(0, 50).map((c) => ({
377 id: c.sha,
378 message: c.message,
379 timestamp: c.date,
380 }));
381 if (list[0]) {
382 pusherName = list[0].author || pusherName;
383 pusherEmail = list[0].authorEmail || pusherEmail;
384 }
385 } catch {
386 /* ignore — payload still valid with empty commits[] */
387 }
388 return {
389 payload: {
390 event: "push",
391 repository: { full_name: `${args.owner}/${args.repo}` },
392 ref: args.ref,
393 after: args.after,
394 before: args.before,
395 pusher: { name: pusherName, email: pusherEmail },
396 commits,
397 // Ancillary fields — receiver may ignore but they're useful for logs:
398 sent_at: now.toISOString(),
399 source: "gluecron",
400 },
401 pusherName,
402 pusherEmail,
403 };
404}
405
9ecf5a4Claude406async function triggerVapronDeploy(
ba93444Claude407 args: TriggerArgs,
408 opts: TriggerOptions = {}
fc1817aClaude409): Promise<void> {
ba93444Claude410 const fetchImpl = opts.fetchImpl ?? fetch;
411 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
412 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
413 const now = opts.now ?? (() => new Date());
414
3ef4c9dClaude415 let deployId = "";
fc1817aClaude416 try {
3ef4c9dClaude417 const [row] = await db
418 .insert(deployments)
419 .values({
ba93444Claude420 repositoryId: args.repositoryId,
3ef4c9dClaude421 environment: "production",
ba93444Claude422 commitSha: args.after,
423 ref: args.ref,
3ef4c9dClaude424 status: "pending",
9ecf5a4Claude425 target: "vapron",
3ef4c9dClaude426 })
427 .returning();
428 deployId = row?.id || "";
429 } catch {
430 /* ignore */
fc1817aClaude431 }
432
ba93444Claude433 const { payload } = await buildPayload(args, now());
434 const body = JSON.stringify(payload);
9ecf5a4Claude435 const signature = signBody(body, config.vapronHmacSecret);
ba93444Claude436
437 const headers: Record<string, string> = {
438 "Content-Type": "application/json",
439 "User-Agent": "gluecron-webhook/1",
440 "X-Gluecron-Event": "push",
441 "X-Gluecron-Delivery": cryptoRandomId(),
442 };
443 if (signature) headers["X-Gluecron-Signature"] = signature;
444
445 let lastStatus = 0;
446 let lastError = "";
447 let success = false;
448
449 // Up to delays.length + 1 attempts (initial try + each delay).
450 const totalAttempts = delays.length + 1;
451 for (let attempt = 0; attempt < totalAttempts; attempt++) {
452 try {
9ecf5a4Claude453 const response = await fetchImpl(config.vapronDeployUrl, {
ba93444Claude454 method: "POST",
455 headers,
456 body,
457 });
458 lastStatus = response.status;
459 console.log(
9ecf5a4Claude460 `[vapron] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
ba93444Claude461 );
462 if (response.ok) {
463 success = true;
464 break;
465 }
466 // 4xx (except 408/429) is unrecoverable — stop retrying.
467 if (response.status >= 400 && response.status < 500 &&
468 response.status !== 408 && response.status !== 429) {
469 break;
470 }
471 } catch (err) {
472 lastError = err instanceof Error ? err.message : String(err);
473 console.error(
9ecf5a4Claude474 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
ba93444Claude475 );
3ef4c9dClaude476 }
ba93444Claude477 const nextDelay = delays[attempt];
478 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
479 await sleep(nextDelay);
1e162a8Claude480 }
ba93444Claude481 }
482
483 if (deployId) {
484 try {
3ef4c9dClaude485 await db
486 .update(deployments)
487 .set({
ba93444Claude488 status: success ? "success" : "failed",
489 blockedReason: success
490 ? null
491 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude492 completedAt: new Date(),
493 })
494 .where(eq(deployments.id, deployId));
ba93444Claude495 } catch {
496 /* ignore */
3ef4c9dClaude497 }
498 }
499
ba93444Claude500 if (!success && deployId) {
501 void onDeployFailure({
502 repositoryId: args.repositoryId,
503 deploymentId: deployId,
504 ref: args.ref,
505 commitSha: args.after,
9ecf5a4Claude506 target: "vapron",
ba93444Claude507 errorMessage: lastError || `HTTP ${lastStatus}`,
508 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude509 }
510}
43cf9b0Claude511
ba93444Claude512function cryptoRandomId(): string {
513 // Short opaque delivery ID for log correlation. Not security-sensitive.
514 const bytes = new Uint8Array(8);
515 crypto.getRandomValues(bytes);
516 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
517}
518
a686079Claude519/**
520 * Resolve `owner/repo` to its DB repository.id and dispatch the
521 * semantic-index update. Pulls the list of changed paths via
522 * `git diff --name-only`, dropping any deletions (handled implicitly
523 * because deleted blobs simply don't resolve in `indexChangedFiles`).
524 *
525 * Never throws — exhaust every external call inside a try/catch so the
526 * push completes even if Postgres or the embedding API is down.
527 */
528async function fireSemanticIndex(
529 owner: string,
530 repo: string,
531 oldSha: string,
532 newSha: string
533): Promise<void> {
534 let repositoryId = "";
535 try {
536 const [row] = await db
537 .select({ id: repositories.id })
538 .from(repositories)
539 .innerJoin(users, eq(repositories.ownerId, users.id))
540 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
541 .limit(1);
542 repositoryId = row?.id || "";
543 } catch {
544 return;
545 }
546 if (!repositoryId) return;
547
548 let changedPaths: string[] = [];
549 try {
550 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
551 } catch {
552 return;
553 }
554 if (!changedPaths.length) return;
555
556 try {
557 const out = await indexChangedFiles({
558 repositoryId,
559 ownerName: owner,
560 repoName: repo,
561 commitSha: newSha,
562 changedPaths,
563 });
564 if (out.indexed > 0) {
565 console.log(
566 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
567 );
568 }
569 } catch (err) {
570 console.warn("[semantic-index] indexChangedFiles error:", err);
571 }
572}
573
574/**
575 * Returns the list of files touched between `oldSha` and `newSha`. For
576 * the initial push on a branch (oldSha all-zero) we walk every file in
577 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
578 */
579async function listChangedPaths(
580 owner: string,
581 repo: string,
582 oldSha: string,
583 newSha: string
584): Promise<string[]> {
585 const cwd = getRepoPath(owner, repo);
586 const allZero = /^0+$/.test(oldSha);
587 const cmd = allZero
588 ? ["git", "ls-tree", "-r", "--name-only", newSha]
589 : ["git", "diff", "--name-only", oldSha, newSha];
590 try {
591 const proc = Bun.spawn(cmd, {
592 cwd,
593 stdout: "pipe",
594 stderr: "pipe",
595 });
596 const text = await new Response(proc.stdout).text();
597 await proc.exited;
598 return text
599 .split("\n")
600 .map((s) => s.trim())
601 .filter((s) => s.length > 0);
602 } catch {
603 return [];
604 }
605}
606
4bbacbeClaude607/**
608 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
609 * non-default branch on a `preview_builds_enabled` repo.
610 *
611 * Resolves the repo row once, fetches the default branch + opt-out flag,
612 * then upserts one preview row per pushed ref that isn't the default.
613 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
614 * are skipped — they shouldn't create preview rows. Never throws.
615 */
616async function firePreviewBuilds(
617 owner: string,
618 repo: string,
619 refs: PushRef[]
620): Promise<void> {
621 // Filter to live branch pushes only.
622 const branchRefs = refs.filter(
623 (r) =>
624 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
625 );
626 if (branchRefs.length === 0) return;
627
628 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
629 try {
630 const [row] = await db
631 .select({
632 id: repositories.id,
633 previewBuildsEnabled: repositories.previewBuildsEnabled,
634 defaultBranch: repositories.defaultBranch,
635 })
636 .from(repositories)
637 .innerJoin(users, eq(repositories.ownerId, users.id))
638 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
639 .limit(1);
640 repoRow = row || null;
641 } catch {
642 return;
643 }
644 if (!repoRow) return;
645 if (!repoRow.previewBuildsEnabled) return;
646
647 for (const ref of branchRefs) {
648 const branchName = ref.refName.replace("refs/heads/", "");
649 if (branchName === repoRow.defaultBranch) continue;
650 try {
651 await enqueuePreviewBuild({
652 repositoryId: repoRow.id,
653 ownerName: owner,
654 repoName: repo,
655 branchName,
656 commitSha: ref.newSha,
657 });
658 } catch (err) {
659 console.warn(
660 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
661 err instanceof Error ? err.message : err
662 );
663 }
664 }
665}
666
d199847Claude667/**
668 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
669 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
670 * on missing repo or DB error — pushes never block. Never throws.
671 */
672async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
673 let repositoryId = "";
674 try {
675 const [row] = await db
676 .select({ id: repositories.id })
677 .from(repositories)
678 .innerJoin(users, eq(repositories.ownerId, users.id))
679 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
680 .limit(1);
681 repositoryId = row?.id || "";
682 } catch {
683 return;
684 }
685 if (!repositoryId) return;
686 try {
687 const out = await runDocDriftCheckForRepo(repositoryId);
688 if (out.docs > 0 || out.proposed > 0) {
689 console.log(
690 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
691 );
692 }
693 } catch (err) {
694 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
695 }
696}
697
783dd46Claude698/**
699 * Block ST — fan out the push to any server targets that watch this
700 * (repo, branch). One sequential deploy per target so a slow box can't
701 * stall the next push, but multiple matching targets run in parallel.
702 * Every failure is contained to its own deploy row + console warn —
703 * nothing here can break the push path.
704 */
705async function fireServerTargetDeploys(
706 owner: string,
707 repo: string,
708 refs: PushRef[]
709): Promise<void> {
710 const liveRefs = refs.filter(
711 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
712 );
713 if (liveRefs.length === 0) return;
714
715 let repositoryId = "";
716 try {
717 const [row] = await db
718 .select({ id: repositories.id })
719 .from(repositories)
720 .innerJoin(users, eq(repositories.ownerId, users.id))
721 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
722 .limit(1);
723 repositoryId = row?.id || "";
724 } catch {
725 return;
726 }
727 if (!repositoryId) return;
728
729 await Promise.all(
730 liveRefs.map(async (ref) => {
731 const branch = ref.refName.replace("refs/heads/", "");
732 let targets;
733 try {
734 targets = await findTargetsForPush({ repositoryId, branch });
735 } catch {
736 return;
737 }
738 if (!targets.length) return;
739
740 await Promise.all(
741 targets.map(async (target) => {
742 try {
743 const env = await resolveEnv(target.id);
744 const deployId = await startDeployRow({
745 targetId: target.id,
746 commitSha: ref.newSha,
747 ref: ref.refName,
748 triggerSource: "push",
749 });
750 const result = await deployToTarget(target, {
751 commitSha: ref.newSha,
752 ref: ref.refName,
753 env,
754 });
755 if (deployId) {
756 await finishDeployRow({
757 id: deployId,
758 exitCode: result.exitCode,
759 stdout: result.stdout,
760 stderr: result.stderr,
761 });
762 }
763 console.log(
764 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
765 );
766 } catch (err) {
767 console.warn(
768 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
769 );
770 }
771 })
772 );
773 })
774 );
775}
776
da3fc18Claude777/**
778 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
779 * Resolves the repo DB row once, then runs the scanner on each live push.
780 * Swallows all errors so a scanner failure never affects the push path.
781 */
782async function fireDependencyScan(
783 owner: string,
784 repo: string,
785 refs: PushRef[]
786): Promise<void> {
787 const liveRefs = refs.filter(
788 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
789 );
790 if (liveRefs.length === 0) return;
791
792 let repoRow: { id: string; ownerId: string } | null = null;
793 try {
794 const [row] = await db
795 .select({ id: repositories.id, ownerId: repositories.ownerId })
796 .from(repositories)
797 .innerJoin(users, eq(repositories.ownerId, users.id))
798 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
799 .limit(1);
800 repoRow = row || null;
801 } catch {
802 return;
803 }
804 if (!repoRow) return;
805
806 for (const ref of liveRefs) {
807 try {
808 const findings = await scanDependencies(
809 repoRow.id,
810 owner,
811 repo,
812 ref.newSha,
813 ref.oldSha,
814 repoRow.ownerId
815 );
816 if (findings.length > 0) {
817 console.log(
818 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
819 );
820 }
821 } catch (err) {
822 console.warn(
823 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
824 err instanceof Error ? err.message : err
825 );
826 }
827 }
828}
829
9c5223fClaude830/**
831 * Migration 0088 — trigger repo onboarding on first push to the default branch.
832 * Detects "first push" by checking whether oldSha is all-zeros on a push to
833 * the repo's default branch (or any branch for a repo with no prior history).
834 * Resolves the repo DB id, then calls ensureRepoOnboarding which is idempotent.
835 * Never throws.
836 */
837async function fireRepoOnboarding(
838 owner: string,
839 repo: string,
840 refs: PushRef[]
841): Promise<void> {
842 // Only fire on first push — oldSha all-zeros means the branch is brand-new.
843 const firstPushRefs = refs.filter(
844 (r) => r.refName.startsWith("refs/heads/") && /^0+$/.test(r.oldSha) && !r.newSha.startsWith("0000")
845 );
846 if (firstPushRefs.length === 0) return;
847
848 let repositoryId = "";
849 try {
850 const [row] = await db
851 .select({ id: repositories.id })
852 .from(repositories)
853 .innerJoin(users, eq(repositories.ownerId, users.id))
854 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
855 .limit(1);
856 repositoryId = row?.id || "";
857 } catch {
858 return;
859 }
860 if (!repositoryId) return;
861
862 await ensureRepoOnboarding(repositoryId, owner, repo);
863}
864
43cf9b0Claude865/** Test-only access to internal helpers. */
a686079Claude866export const __test = {
9ecf5a4Claude867 triggerVapronDeploy,
a686079Claude868 signBody,
869 buildPayload,
870 RETRY_DELAYS_MS,
871 listChangedPaths,
872 fireSemanticIndex,
4bbacbeClaude873 firePreviewBuilds,
d199847Claude874 fireDocDriftCheck,
783dd46Claude875 fireServerTargetDeploys,
da3fc18Claude876 fireDependencyScan,
9c5223fClaude877 fireRepoOnboarding,
a686079Claude878};