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.tsBlame795 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";
fc1817aClaude39
40interface PushRef {
41 oldSha: string;
42 newSha: string;
43 refName: string;
44}
45
46export async function onPostReceive(
47 owner: string,
48 repo: string,
6efae38Claude49 refs: PushRef[],
50 pusherUserId: string = ""
fc1817aClaude51): Promise<void> {
2c34075Claude52 for (const ref of refs) {
53 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
54 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude55
2c34075Claude56 // 1. Auto-repair (runs first, may create a new commit)
57 try {
58 const repair = await autoRepair(owner, repo, branchName);
59 if (repair.repaired) {
60 console.log(
61 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
62 );
63 }
64 } catch (err) {
65 console.error(`[autorepair] error:`, err);
66 }
fc1817aClaude67
2c34075Claude68 // 2. Push analysis
69 try {
70 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
71 console.log(
72 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
73 );
74 if (analysis.riskScore > 50) {
75 console.warn(
76 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
77 );
78 }
79 if (analysis.breakingChangeSignals.length > 0) {
80 console.warn(
81 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
82 );
83 }
84 } catch (err) {
85 console.error(`[push-analysis] error:`, err);
86 }
87
88 // 3. Health score (async, don't block)
89 computeHealthScore(owner, repo).then((report) => {
90 console.log(
91 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
92 );
93 }).catch((err) => {
94 console.error(`[health] error:`, err);
95 });
96 }
97
170ddb2Claude98 // 4. GateTest scan — fire-and-forget notification on every push. The
99 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
100 // deployments pay no overhead. Results flow back via the inbound
101 // webhook at POST /api/hooks/gatetest.
102 for (const ref of refs) {
103 if (ref.newSha.startsWith("0000")) continue;
104 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
105 console.warn("[gatetest] notify error:", err)
106 );
107 }
2c34075Claude108
a686079Claude109 // 4b. Continuous semantic index — embed changed files on every push so
110 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
111 // all failures are swallowed inside semantic-index.ts so a missing
112 // pgvector extension or absent embeddings API key never breaks the
113 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
114 for (const ref of refs) {
115 if (ref.newSha.startsWith("0000")) continue;
116 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
117 console.warn("[semantic-index] dispatch error:", err)
118 );
119 }
120
4bbacbeClaude121 // 4c. Per-branch preview URLs (migration 0062). Every push to a
122 // non-default branch enqueues a preview-build row. Gated on
123 // repositories.preview_builds_enabled (default on) so owners can
124 // opt out per-repo via repo-settings. Fire-and-forget; failures
125 // never break the push path.
126 void firePreviewBuilds(owner, repo, refs).catch((err) =>
127 console.warn("[branch-previews] dispatch error:", err)
128 );
129
6efae38Claude130 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
131 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
132 // and debug console.log calls. Opens one issue per finding type per
133 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1.
134 // Fire-and-forget; never blocks the push path.
135 for (const ref of refs) {
136 if (ref.newSha.startsWith("0000")) continue;
137 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
138 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
139 );
140 }
141
da3fc18Claude142 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
143 // every push that touches a recognized manifest file (package.json,
144 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
145 // for critical/high findings and updates a weekly digest for
146 // medium/low. Fire-and-forget; never blocks the push path.
147 if (config.dependencyScanEnabled) {
148 void fireDependencyScan(owner, repo, refs).catch((err) =>
149 console.warn("[dependency-scanner] dispatch error:", err)
150 );
151 }
152
d199847Claude153 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
154 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
155 // regions, hashes the referenced source, and opens a PR labelled
156 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
157 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
158 // or empty doc_tracking table never breaks the push.
159 void fireDocDriftCheck(owner, repo).catch((err) =>
160 console.warn("[ai-doc-updater] dispatch error:", err)
161 );
162
ba93444Claude163 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
164 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
165 // default branch. The branch case (`Main` vs `main`) is determined by
166 // the bare repo's HEAD, not hardcoded.
167 if (`${owner}/${repo}` === config.crontechRepo) {
a28cedeClaude168 let defaultBranch =
169 (await getDefaultBranch(owner, repo).catch((err) => {
170 console.warn(
171 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
172 err instanceof Error ? err.message : err
173 );
174 return null;
175 })) || "main";
ba93444Claude176 const targetRef = `refs/heads/${defaultBranch}`;
177 const deployPush = refs.find(
178 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
179 );
180 if (deployPush) {
181 let repositoryId = "";
182 try {
183 const [row] = await db
184 .select({ id: repositories.id })
185 .from(repositories)
186 .innerJoin(users, eq(repositories.ownerId, users.id))
187 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
188 .limit(1);
189 repositoryId = row?.id || "";
190 } catch {
191 /* ignore */
192 }
193 if (repositoryId) {
194 triggerCrontechDeploy({
195 owner,
196 repo,
197 before: deployPush.oldSha,
198 after: deployPush.newSha,
199 ref: targetRef,
200 branch: defaultBranch,
201 repositoryId,
202 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
203 }
0316dbbClaude204 }
fc1817aClaude205 }
f2c00b4CC LABS App206
783dd46Claude207 // 5b. Block ST — Server targets. After core post-receive work, fire
208 // deploys against any `server_targets` row whose
209 // (watched_repository_id, watched_branch) matches a pushed ref.
210 // Fire-and-forget; deploy results land in `server_target_deployments`
211 // and the UI at /admin/servers/:id surfaces them.
212 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
213 console.warn("[server-targets] dispatch error:", err)
214 );
215
f2c00b4CC LABS App216 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
217 // main, fire the local deploy via scripts/self-deploy.sh. The script
218 // forks into the background, so this call returns immediately (git
219 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
220 // avoid firing on customer repos that happen to be named "Gluecron.com".
221 const selfHostRepo = process.env.SELF_HOST_REPO;
222 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
223 const mainRef = refs.find(
224 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
225 );
226 if (mainRef) {
227 const scriptPath =
228 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
229 "/opt/gluecron/scripts/self-deploy.sh";
230 try {
231 const child = __selfHostSpawn(
232 [scriptPath, mainRef.oldSha, mainRef.newSha],
233 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
234 );
235 try {
236 (child as any)?.unref?.();
237 } catch {
238 /* unref optional */
239 }
240 console.log(
241 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
242 );
243 } catch (err) {
244 console.error(`[self-host] failed to spawn:`, err);
245 }
246 }
247 }
248}
249
250// BLOCK W — DI seam so the test suite can capture the spawn call without
251// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
252// callers go straight to Bun.spawn.
bf19c50Test User253const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App254 Bun.spawn(cmd, opts);
bf19c50Test User255let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
256/**
257 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
258 */
259export function __setSelfHostSpawnForTests(
260 fn: typeof __selfHostSpawn | null
261): void {
262 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude263}
264
43cf9b0Claude265/**
ba93444Claude266 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude267 *
ba93444Claude268 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude269 *
ba93444Claude270 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude271 * Content-Type: application/json
ba93444Claude272 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude273 *
274 * {
ba93444Claude275 * "event": "push",
276 * "repository": { "full_name": "ccantynz-alt/crontech" },
277 * "ref": "refs/heads/Main",
278 * "after": "<40-hex commit SHA>",
279 * "before": "<40-hex previous SHA>",
280 * "pusher": { "name": "<author>", "email": "<email>" },
281 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude282 * }
283 *
ba93444Claude284 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude285 *
ba93444Claude286 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
287 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
288 * is unset the signature header is omitted and Crontech is expected to reject —
289 * we still record the deploy row as failed.
43cf9b0Claude290 */
ba93444Claude291const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
292
293interface TriggerArgs {
294 owner: string;
295 repo: string;
296 before: string;
297 after: string;
298 ref: string;
299 branch: string;
300 repositoryId: string;
301}
302
303interface TriggerOptions {
304 fetchImpl?: typeof fetch;
305 sleep?: (ms: number) => Promise<void>;
306 retryDelaysMs?: number[];
307 now?: () => Date;
308}
309
310function signBody(body: string, secret: string): string | null {
311 if (!secret) return null;
312 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
313}
314
315async function buildPayload(args: TriggerArgs, now: Date): Promise<{
316 payload: Record<string, unknown>;
317 pusherName: string;
318 pusherEmail: string;
319}> {
320 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
321 // `before` may be all-zeros for a first push to the branch — commitsBetween
322 // handles that by treating null `from` as "everything reachable from `to`".
323 const fromSha = /^0+$/.test(args.before) ? null : args.before;
324 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
325 let pusherName = "gluecron";
326 let pusherEmail = "noreply@gluecron.local";
327 try {
328 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
329 commits = list.slice(0, 50).map((c) => ({
330 id: c.sha,
331 message: c.message,
332 timestamp: c.date,
333 }));
334 if (list[0]) {
335 pusherName = list[0].author || pusherName;
336 pusherEmail = list[0].authorEmail || pusherEmail;
337 }
338 } catch {
339 /* ignore — payload still valid with empty commits[] */
340 }
341 return {
342 payload: {
343 event: "push",
344 repository: { full_name: `${args.owner}/${args.repo}` },
345 ref: args.ref,
346 after: args.after,
347 before: args.before,
348 pusher: { name: pusherName, email: pusherEmail },
349 commits,
350 // Ancillary fields — receiver may ignore but they're useful for logs:
351 sent_at: now.toISOString(),
352 source: "gluecron",
353 },
354 pusherName,
355 pusherEmail,
356 };
357}
358
fc1817aClaude359async function triggerCrontechDeploy(
ba93444Claude360 args: TriggerArgs,
361 opts: TriggerOptions = {}
fc1817aClaude362): Promise<void> {
ba93444Claude363 const fetchImpl = opts.fetchImpl ?? fetch;
364 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
365 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
366 const now = opts.now ?? (() => new Date());
367
3ef4c9dClaude368 let deployId = "";
fc1817aClaude369 try {
3ef4c9dClaude370 const [row] = await db
371 .insert(deployments)
372 .values({
ba93444Claude373 repositoryId: args.repositoryId,
3ef4c9dClaude374 environment: "production",
ba93444Claude375 commitSha: args.after,
376 ref: args.ref,
3ef4c9dClaude377 status: "pending",
378 target: "crontech",
379 })
380 .returning();
381 deployId = row?.id || "";
382 } catch {
383 /* ignore */
fc1817aClaude384 }
385
ba93444Claude386 const { payload } = await buildPayload(args, now());
387 const body = JSON.stringify(payload);
388 const signature = signBody(body, config.gluecronWebhookSecret);
389
390 const headers: Record<string, string> = {
391 "Content-Type": "application/json",
392 "User-Agent": "gluecron-webhook/1",
393 "X-Gluecron-Event": "push",
394 "X-Gluecron-Delivery": cryptoRandomId(),
395 };
396 if (signature) headers["X-Gluecron-Signature"] = signature;
397
398 let lastStatus = 0;
399 let lastError = "";
400 let success = false;
401
402 // Up to delays.length + 1 attempts (initial try + each delay).
403 const totalAttempts = delays.length + 1;
404 for (let attempt = 0; attempt < totalAttempts; attempt++) {
405 try {
406 const response = await fetchImpl(config.crontechDeployUrl, {
407 method: "POST",
408 headers,
409 body,
410 });
411 lastStatus = response.status;
412 console.log(
413 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
414 );
415 if (response.ok) {
416 success = true;
417 break;
418 }
419 // 4xx (except 408/429) is unrecoverable — stop retrying.
420 if (response.status >= 400 && response.status < 500 &&
421 response.status !== 408 && response.status !== 429) {
422 break;
423 }
424 } catch (err) {
425 lastError = err instanceof Error ? err.message : String(err);
426 console.error(
427 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
428 );
3ef4c9dClaude429 }
ba93444Claude430 const nextDelay = delays[attempt];
431 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
432 await sleep(nextDelay);
1e162a8Claude433 }
ba93444Claude434 }
435
436 if (deployId) {
437 try {
3ef4c9dClaude438 await db
439 .update(deployments)
440 .set({
ba93444Claude441 status: success ? "success" : "failed",
442 blockedReason: success
443 ? null
444 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude445 completedAt: new Date(),
446 })
447 .where(eq(deployments.id, deployId));
ba93444Claude448 } catch {
449 /* ignore */
3ef4c9dClaude450 }
451 }
452
ba93444Claude453 if (!success && deployId) {
454 void onDeployFailure({
455 repositoryId: args.repositoryId,
456 deploymentId: deployId,
457 ref: args.ref,
458 commitSha: args.after,
459 target: "crontech",
460 errorMessage: lastError || `HTTP ${lastStatus}`,
461 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude462 }
463}
43cf9b0Claude464
ba93444Claude465function cryptoRandomId(): string {
466 // Short opaque delivery ID for log correlation. Not security-sensitive.
467 const bytes = new Uint8Array(8);
468 crypto.getRandomValues(bytes);
469 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
470}
471
a686079Claude472/**
473 * Resolve `owner/repo` to its DB repository.id and dispatch the
474 * semantic-index update. Pulls the list of changed paths via
475 * `git diff --name-only`, dropping any deletions (handled implicitly
476 * because deleted blobs simply don't resolve in `indexChangedFiles`).
477 *
478 * Never throws — exhaust every external call inside a try/catch so the
479 * push completes even if Postgres or the embedding API is down.
480 */
481async function fireSemanticIndex(
482 owner: string,
483 repo: string,
484 oldSha: string,
485 newSha: string
486): Promise<void> {
487 let repositoryId = "";
488 try {
489 const [row] = await db
490 .select({ id: repositories.id })
491 .from(repositories)
492 .innerJoin(users, eq(repositories.ownerId, users.id))
493 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
494 .limit(1);
495 repositoryId = row?.id || "";
496 } catch {
497 return;
498 }
499 if (!repositoryId) return;
500
501 let changedPaths: string[] = [];
502 try {
503 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
504 } catch {
505 return;
506 }
507 if (!changedPaths.length) return;
508
509 try {
510 const out = await indexChangedFiles({
511 repositoryId,
512 ownerName: owner,
513 repoName: repo,
514 commitSha: newSha,
515 changedPaths,
516 });
517 if (out.indexed > 0) {
518 console.log(
519 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
520 );
521 }
522 } catch (err) {
523 console.warn("[semantic-index] indexChangedFiles error:", err);
524 }
525}
526
527/**
528 * Returns the list of files touched between `oldSha` and `newSha`. For
529 * the initial push on a branch (oldSha all-zero) we walk every file in
530 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
531 */
532async function listChangedPaths(
533 owner: string,
534 repo: string,
535 oldSha: string,
536 newSha: string
537): Promise<string[]> {
538 const cwd = getRepoPath(owner, repo);
539 const allZero = /^0+$/.test(oldSha);
540 const cmd = allZero
541 ? ["git", "ls-tree", "-r", "--name-only", newSha]
542 : ["git", "diff", "--name-only", oldSha, newSha];
543 try {
544 const proc = Bun.spawn(cmd, {
545 cwd,
546 stdout: "pipe",
547 stderr: "pipe",
548 });
549 const text = await new Response(proc.stdout).text();
550 await proc.exited;
551 return text
552 .split("\n")
553 .map((s) => s.trim())
554 .filter((s) => s.length > 0);
555 } catch {
556 return [];
557 }
558}
559
4bbacbeClaude560/**
561 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
562 * non-default branch on a `preview_builds_enabled` repo.
563 *
564 * Resolves the repo row once, fetches the default branch + opt-out flag,
565 * then upserts one preview row per pushed ref that isn't the default.
566 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
567 * are skipped — they shouldn't create preview rows. Never throws.
568 */
569async function firePreviewBuilds(
570 owner: string,
571 repo: string,
572 refs: PushRef[]
573): Promise<void> {
574 // Filter to live branch pushes only.
575 const branchRefs = refs.filter(
576 (r) =>
577 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
578 );
579 if (branchRefs.length === 0) return;
580
581 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
582 try {
583 const [row] = await db
584 .select({
585 id: repositories.id,
586 previewBuildsEnabled: repositories.previewBuildsEnabled,
587 defaultBranch: repositories.defaultBranch,
588 })
589 .from(repositories)
590 .innerJoin(users, eq(repositories.ownerId, users.id))
591 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
592 .limit(1);
593 repoRow = row || null;
594 } catch {
595 return;
596 }
597 if (!repoRow) return;
598 if (!repoRow.previewBuildsEnabled) return;
599
600 for (const ref of branchRefs) {
601 const branchName = ref.refName.replace("refs/heads/", "");
602 if (branchName === repoRow.defaultBranch) continue;
603 try {
604 await enqueuePreviewBuild({
605 repositoryId: repoRow.id,
606 ownerName: owner,
607 repoName: repo,
608 branchName,
609 commitSha: ref.newSha,
610 });
611 } catch (err) {
612 console.warn(
613 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
614 err instanceof Error ? err.message : err
615 );
616 }
617 }
618}
619
d199847Claude620/**
621 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
622 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
623 * on missing repo or DB error — pushes never block. Never throws.
624 */
625async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
626 let repositoryId = "";
627 try {
628 const [row] = await db
629 .select({ id: repositories.id })
630 .from(repositories)
631 .innerJoin(users, eq(repositories.ownerId, users.id))
632 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
633 .limit(1);
634 repositoryId = row?.id || "";
635 } catch {
636 return;
637 }
638 if (!repositoryId) return;
639 try {
640 const out = await runDocDriftCheckForRepo(repositoryId);
641 if (out.docs > 0 || out.proposed > 0) {
642 console.log(
643 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
644 );
645 }
646 } catch (err) {
647 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
648 }
649}
650
783dd46Claude651/**
652 * Block ST — fan out the push to any server targets that watch this
653 * (repo, branch). One sequential deploy per target so a slow box can't
654 * stall the next push, but multiple matching targets run in parallel.
655 * Every failure is contained to its own deploy row + console warn —
656 * nothing here can break the push path.
657 */
658async function fireServerTargetDeploys(
659 owner: string,
660 repo: string,
661 refs: PushRef[]
662): Promise<void> {
663 const liveRefs = refs.filter(
664 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
665 );
666 if (liveRefs.length === 0) return;
667
668 let repositoryId = "";
669 try {
670 const [row] = await db
671 .select({ id: repositories.id })
672 .from(repositories)
673 .innerJoin(users, eq(repositories.ownerId, users.id))
674 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
675 .limit(1);
676 repositoryId = row?.id || "";
677 } catch {
678 return;
679 }
680 if (!repositoryId) return;
681
682 await Promise.all(
683 liveRefs.map(async (ref) => {
684 const branch = ref.refName.replace("refs/heads/", "");
685 let targets;
686 try {
687 targets = await findTargetsForPush({ repositoryId, branch });
688 } catch {
689 return;
690 }
691 if (!targets.length) return;
692
693 await Promise.all(
694 targets.map(async (target) => {
695 try {
696 const env = await resolveEnv(target.id);
697 const deployId = await startDeployRow({
698 targetId: target.id,
699 commitSha: ref.newSha,
700 ref: ref.refName,
701 triggerSource: "push",
702 });
703 const result = await deployToTarget(target, {
704 commitSha: ref.newSha,
705 ref: ref.refName,
706 env,
707 });
708 if (deployId) {
709 await finishDeployRow({
710 id: deployId,
711 exitCode: result.exitCode,
712 stdout: result.stdout,
713 stderr: result.stderr,
714 });
715 }
716 console.log(
717 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
718 );
719 } catch (err) {
720 console.warn(
721 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
722 );
723 }
724 })
725 );
726 })
727 );
728}
729
da3fc18Claude730/**
731 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
732 * Resolves the repo DB row once, then runs the scanner on each live push.
733 * Swallows all errors so a scanner failure never affects the push path.
734 */
735async function fireDependencyScan(
736 owner: string,
737 repo: string,
738 refs: PushRef[]
739): Promise<void> {
740 const liveRefs = refs.filter(
741 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
742 );
743 if (liveRefs.length === 0) return;
744
745 let repoRow: { id: string; ownerId: string } | null = null;
746 try {
747 const [row] = await db
748 .select({ id: repositories.id, ownerId: repositories.ownerId })
749 .from(repositories)
750 .innerJoin(users, eq(repositories.ownerId, users.id))
751 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
752 .limit(1);
753 repoRow = row || null;
754 } catch {
755 return;
756 }
757 if (!repoRow) return;
758
759 for (const ref of liveRefs) {
760 try {
761 const findings = await scanDependencies(
762 repoRow.id,
763 owner,
764 repo,
765 ref.newSha,
766 ref.oldSha,
767 repoRow.ownerId
768 );
769 if (findings.length > 0) {
770 console.log(
771 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
772 );
773 }
774 } catch (err) {
775 console.warn(
776 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
777 err instanceof Error ? err.message : err
778 );
779 }
780 }
781}
782
43cf9b0Claude783/** Test-only access to internal helpers. */
a686079Claude784export const __test = {
785 triggerCrontechDeploy,
786 signBody,
787 buildPayload,
788 RETRY_DELAYS_MS,
789 listChangedPaths,
790 fireSemanticIndex,
4bbacbeClaude791 firePreviewBuilds,
d199847Claude792 fireDocDriftCheck,
783dd46Claude793 fireServerTargetDeploys,
da3fc18Claude794 fireDependencyScan,
a686079Claude795};