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