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.tsBlame729 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";
d199847Claude30import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
783dd46Claude31import {
32 findTargetsForPush,
33 finishDeployRow,
34 resolveEnv,
35 startDeployRow,
36} from "../lib/server-target-store";
37import { deployToTarget } from "../lib/server-targets";
fc1817aClaude38
39interface PushRef {
40 oldSha: string;
41 newSha: string;
42 refName: string;
43}
44
45export async function onPostReceive(
46 owner: string,
47 repo: string,
6efae38Claude48 refs: PushRef[],
49 pusherUserId: string = ""
fc1817aClaude50): Promise<void> {
2c34075Claude51 for (const ref of refs) {
52 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
53 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude54
2c34075Claude55 // 1. Auto-repair (runs first, may create a new commit)
56 try {
57 const repair = await autoRepair(owner, repo, branchName);
58 if (repair.repaired) {
59 console.log(
60 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
61 );
62 }
63 } catch (err) {
64 console.error(`[autorepair] error:`, err);
65 }
fc1817aClaude66
2c34075Claude67 // 2. Push analysis
68 try {
69 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
70 console.log(
71 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
72 );
73 if (analysis.riskScore > 50) {
74 console.warn(
75 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
76 );
77 }
78 if (analysis.breakingChangeSignals.length > 0) {
79 console.warn(
80 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
81 );
82 }
83 } catch (err) {
84 console.error(`[push-analysis] error:`, err);
85 }
86
87 // 3. Health score (async, don't block)
88 computeHealthScore(owner, repo).then((report) => {
89 console.log(
90 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
91 );
92 }).catch((err) => {
93 console.error(`[health] error:`, err);
94 });
95 }
96
170ddb2Claude97 // 4. GateTest scan — fire-and-forget notification on every push. The
98 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
99 // deployments pay no overhead. Results flow back via the inbound
100 // webhook at POST /api/hooks/gatetest.
101 for (const ref of refs) {
102 if (ref.newSha.startsWith("0000")) continue;
103 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
104 console.warn("[gatetest] notify error:", err)
105 );
106 }
2c34075Claude107
a686079Claude108 // 4b. Continuous semantic index — embed changed files on every push so
109 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
110 // all failures are swallowed inside semantic-index.ts so a missing
111 // pgvector extension or absent embeddings API key never breaks the
112 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
113 for (const ref of refs) {
114 if (ref.newSha.startsWith("0000")) continue;
115 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
116 console.warn("[semantic-index] dispatch error:", err)
117 );
118 }
119
4bbacbeClaude120 // 4c. Per-branch preview URLs (migration 0062). Every push to a
121 // non-default branch enqueues a preview-build row. Gated on
122 // repositories.preview_builds_enabled (default on) so owners can
123 // opt out per-repo via repo-settings. Fire-and-forget; failures
124 // never break the push path.
125 void firePreviewBuilds(owner, repo, refs).catch((err) =>
126 console.warn("[branch-previews] dispatch error:", err)
127 );
128
6efae38Claude129 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
130 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
131 // and debug console.log calls. Opens one issue per finding type per
132 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1.
133 // Fire-and-forget; never blocks the push path.
134 for (const ref of refs) {
135 if (ref.newSha.startsWith("0000")) continue;
136 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
137 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
138 );
139 }
140
d199847Claude141 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
142 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
143 // regions, hashes the referenced source, and opens a PR labelled
144 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
145 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
146 // or empty doc_tracking table never breaks the push.
147 void fireDocDriftCheck(owner, repo).catch((err) =>
148 console.warn("[ai-doc-updater] dispatch error:", err)
149 );
150
ba93444Claude151 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
152 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
153 // default branch. The branch case (`Main` vs `main`) is determined by
154 // the bare repo's HEAD, not hardcoded.
155 if (`${owner}/${repo}` === config.crontechRepo) {
a28cedeClaude156 let defaultBranch =
157 (await getDefaultBranch(owner, repo).catch((err) => {
158 console.warn(
159 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
160 err instanceof Error ? err.message : err
161 );
162 return null;
163 })) || "main";
ba93444Claude164 const targetRef = `refs/heads/${defaultBranch}`;
165 const deployPush = refs.find(
166 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
167 );
168 if (deployPush) {
169 let repositoryId = "";
170 try {
171 const [row] = await db
172 .select({ id: repositories.id })
173 .from(repositories)
174 .innerJoin(users, eq(repositories.ownerId, users.id))
175 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
176 .limit(1);
177 repositoryId = row?.id || "";
178 } catch {
179 /* ignore */
180 }
181 if (repositoryId) {
182 triggerCrontechDeploy({
183 owner,
184 repo,
185 before: deployPush.oldSha,
186 after: deployPush.newSha,
187 ref: targetRef,
188 branch: defaultBranch,
189 repositoryId,
190 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
191 }
0316dbbClaude192 }
fc1817aClaude193 }
f2c00b4CC LABS App194
783dd46Claude195 // 5b. Block ST — Server targets. After core post-receive work, fire
196 // deploys against any `server_targets` row whose
197 // (watched_repository_id, watched_branch) matches a pushed ref.
198 // Fire-and-forget; deploy results land in `server_target_deployments`
199 // and the UI at /admin/servers/:id surfaces them.
200 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
201 console.warn("[server-targets] dispatch error:", err)
202 );
203
f2c00b4CC LABS App204 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
205 // main, fire the local deploy via scripts/self-deploy.sh. The script
206 // forks into the background, so this call returns immediately (git
207 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
208 // avoid firing on customer repos that happen to be named "Gluecron.com".
209 const selfHostRepo = process.env.SELF_HOST_REPO;
210 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
211 const mainRef = refs.find(
212 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
213 );
214 if (mainRef) {
215 const scriptPath =
216 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
217 "/opt/gluecron/scripts/self-deploy.sh";
218 try {
219 const child = __selfHostSpawn(
220 [scriptPath, mainRef.oldSha, mainRef.newSha],
221 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
222 );
223 try {
224 (child as any)?.unref?.();
225 } catch {
226 /* unref optional */
227 }
228 console.log(
229 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
230 );
231 } catch (err) {
232 console.error(`[self-host] failed to spawn:`, err);
233 }
234 }
235 }
236}
237
238// BLOCK W — DI seam so the test suite can capture the spawn call without
239// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
240// callers go straight to Bun.spawn.
bf19c50Test User241const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App242 Bun.spawn(cmd, opts);
bf19c50Test User243let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
244/**
245 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
246 */
247export function __setSelfHostSpawnForTests(
248 fn: typeof __selfHostSpawn | null
249): void {
250 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude251}
252
43cf9b0Claude253/**
ba93444Claude254 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude255 *
ba93444Claude256 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude257 *
ba93444Claude258 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude259 * Content-Type: application/json
ba93444Claude260 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude261 *
262 * {
ba93444Claude263 * "event": "push",
264 * "repository": { "full_name": "ccantynz-alt/crontech" },
265 * "ref": "refs/heads/Main",
266 * "after": "<40-hex commit SHA>",
267 * "before": "<40-hex previous SHA>",
268 * "pusher": { "name": "<author>", "email": "<email>" },
269 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude270 * }
271 *
ba93444Claude272 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude273 *
ba93444Claude274 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
275 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
276 * is unset the signature header is omitted and Crontech is expected to reject —
277 * we still record the deploy row as failed.
43cf9b0Claude278 */
ba93444Claude279const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
280
281interface TriggerArgs {
282 owner: string;
283 repo: string;
284 before: string;
285 after: string;
286 ref: string;
287 branch: string;
288 repositoryId: string;
289}
290
291interface TriggerOptions {
292 fetchImpl?: typeof fetch;
293 sleep?: (ms: number) => Promise<void>;
294 retryDelaysMs?: number[];
295 now?: () => Date;
296}
297
298function signBody(body: string, secret: string): string | null {
299 if (!secret) return null;
300 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
301}
302
303async function buildPayload(args: TriggerArgs, now: Date): Promise<{
304 payload: Record<string, unknown>;
305 pusherName: string;
306 pusherEmail: string;
307}> {
308 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
309 // `before` may be all-zeros for a first push to the branch — commitsBetween
310 // handles that by treating null `from` as "everything reachable from `to`".
311 const fromSha = /^0+$/.test(args.before) ? null : args.before;
312 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
313 let pusherName = "gluecron";
314 let pusherEmail = "noreply@gluecron.local";
315 try {
316 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
317 commits = list.slice(0, 50).map((c) => ({
318 id: c.sha,
319 message: c.message,
320 timestamp: c.date,
321 }));
322 if (list[0]) {
323 pusherName = list[0].author || pusherName;
324 pusherEmail = list[0].authorEmail || pusherEmail;
325 }
326 } catch {
327 /* ignore — payload still valid with empty commits[] */
328 }
329 return {
330 payload: {
331 event: "push",
332 repository: { full_name: `${args.owner}/${args.repo}` },
333 ref: args.ref,
334 after: args.after,
335 before: args.before,
336 pusher: { name: pusherName, email: pusherEmail },
337 commits,
338 // Ancillary fields — receiver may ignore but they're useful for logs:
339 sent_at: now.toISOString(),
340 source: "gluecron",
341 },
342 pusherName,
343 pusherEmail,
344 };
345}
346
fc1817aClaude347async function triggerCrontechDeploy(
ba93444Claude348 args: TriggerArgs,
349 opts: TriggerOptions = {}
fc1817aClaude350): Promise<void> {
ba93444Claude351 const fetchImpl = opts.fetchImpl ?? fetch;
352 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
353 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
354 const now = opts.now ?? (() => new Date());
355
3ef4c9dClaude356 let deployId = "";
fc1817aClaude357 try {
3ef4c9dClaude358 const [row] = await db
359 .insert(deployments)
360 .values({
ba93444Claude361 repositoryId: args.repositoryId,
3ef4c9dClaude362 environment: "production",
ba93444Claude363 commitSha: args.after,
364 ref: args.ref,
3ef4c9dClaude365 status: "pending",
366 target: "crontech",
367 })
368 .returning();
369 deployId = row?.id || "";
370 } catch {
371 /* ignore */
fc1817aClaude372 }
373
ba93444Claude374 const { payload } = await buildPayload(args, now());
375 const body = JSON.stringify(payload);
376 const signature = signBody(body, config.gluecronWebhookSecret);
377
378 const headers: Record<string, string> = {
379 "Content-Type": "application/json",
380 "User-Agent": "gluecron-webhook/1",
381 "X-Gluecron-Event": "push",
382 "X-Gluecron-Delivery": cryptoRandomId(),
383 };
384 if (signature) headers["X-Gluecron-Signature"] = signature;
385
386 let lastStatus = 0;
387 let lastError = "";
388 let success = false;
389
390 // Up to delays.length + 1 attempts (initial try + each delay).
391 const totalAttempts = delays.length + 1;
392 for (let attempt = 0; attempt < totalAttempts; attempt++) {
393 try {
394 const response = await fetchImpl(config.crontechDeployUrl, {
395 method: "POST",
396 headers,
397 body,
398 });
399 lastStatus = response.status;
400 console.log(
401 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
402 );
403 if (response.ok) {
404 success = true;
405 break;
406 }
407 // 4xx (except 408/429) is unrecoverable — stop retrying.
408 if (response.status >= 400 && response.status < 500 &&
409 response.status !== 408 && response.status !== 429) {
410 break;
411 }
412 } catch (err) {
413 lastError = err instanceof Error ? err.message : String(err);
414 console.error(
415 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
416 );
3ef4c9dClaude417 }
ba93444Claude418 const nextDelay = delays[attempt];
419 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
420 await sleep(nextDelay);
1e162a8Claude421 }
ba93444Claude422 }
423
424 if (deployId) {
425 try {
3ef4c9dClaude426 await db
427 .update(deployments)
428 .set({
ba93444Claude429 status: success ? "success" : "failed",
430 blockedReason: success
431 ? null
432 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude433 completedAt: new Date(),
434 })
435 .where(eq(deployments.id, deployId));
ba93444Claude436 } catch {
437 /* ignore */
3ef4c9dClaude438 }
439 }
440
ba93444Claude441 if (!success && deployId) {
442 void onDeployFailure({
443 repositoryId: args.repositoryId,
444 deploymentId: deployId,
445 ref: args.ref,
446 commitSha: args.after,
447 target: "crontech",
448 errorMessage: lastError || `HTTP ${lastStatus}`,
449 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude450 }
451}
43cf9b0Claude452
ba93444Claude453function cryptoRandomId(): string {
454 // Short opaque delivery ID for log correlation. Not security-sensitive.
455 const bytes = new Uint8Array(8);
456 crypto.getRandomValues(bytes);
457 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
458}
459
a686079Claude460/**
461 * Resolve `owner/repo` to its DB repository.id and dispatch the
462 * semantic-index update. Pulls the list of changed paths via
463 * `git diff --name-only`, dropping any deletions (handled implicitly
464 * because deleted blobs simply don't resolve in `indexChangedFiles`).
465 *
466 * Never throws — exhaust every external call inside a try/catch so the
467 * push completes even if Postgres or the embedding API is down.
468 */
469async function fireSemanticIndex(
470 owner: string,
471 repo: string,
472 oldSha: string,
473 newSha: string
474): Promise<void> {
475 let repositoryId = "";
476 try {
477 const [row] = await db
478 .select({ id: repositories.id })
479 .from(repositories)
480 .innerJoin(users, eq(repositories.ownerId, users.id))
481 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
482 .limit(1);
483 repositoryId = row?.id || "";
484 } catch {
485 return;
486 }
487 if (!repositoryId) return;
488
489 let changedPaths: string[] = [];
490 try {
491 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
492 } catch {
493 return;
494 }
495 if (!changedPaths.length) return;
496
497 try {
498 const out = await indexChangedFiles({
499 repositoryId,
500 ownerName: owner,
501 repoName: repo,
502 commitSha: newSha,
503 changedPaths,
504 });
505 if (out.indexed > 0) {
506 console.log(
507 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
508 );
509 }
510 } catch (err) {
511 console.warn("[semantic-index] indexChangedFiles error:", err);
512 }
513}
514
515/**
516 * Returns the list of files touched between `oldSha` and `newSha`. For
517 * the initial push on a branch (oldSha all-zero) we walk every file in
518 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
519 */
520async function listChangedPaths(
521 owner: string,
522 repo: string,
523 oldSha: string,
524 newSha: string
525): Promise<string[]> {
526 const cwd = getRepoPath(owner, repo);
527 const allZero = /^0+$/.test(oldSha);
528 const cmd = allZero
529 ? ["git", "ls-tree", "-r", "--name-only", newSha]
530 : ["git", "diff", "--name-only", oldSha, newSha];
531 try {
532 const proc = Bun.spawn(cmd, {
533 cwd,
534 stdout: "pipe",
535 stderr: "pipe",
536 });
537 const text = await new Response(proc.stdout).text();
538 await proc.exited;
539 return text
540 .split("\n")
541 .map((s) => s.trim())
542 .filter((s) => s.length > 0);
543 } catch {
544 return [];
545 }
546}
547
4bbacbeClaude548/**
549 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
550 * non-default branch on a `preview_builds_enabled` repo.
551 *
552 * Resolves the repo row once, fetches the default branch + opt-out flag,
553 * then upserts one preview row per pushed ref that isn't the default.
554 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
555 * are skipped — they shouldn't create preview rows. Never throws.
556 */
557async function firePreviewBuilds(
558 owner: string,
559 repo: string,
560 refs: PushRef[]
561): Promise<void> {
562 // Filter to live branch pushes only.
563 const branchRefs = refs.filter(
564 (r) =>
565 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
566 );
567 if (branchRefs.length === 0) return;
568
569 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
570 try {
571 const [row] = await db
572 .select({
573 id: repositories.id,
574 previewBuildsEnabled: repositories.previewBuildsEnabled,
575 defaultBranch: repositories.defaultBranch,
576 })
577 .from(repositories)
578 .innerJoin(users, eq(repositories.ownerId, users.id))
579 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
580 .limit(1);
581 repoRow = row || null;
582 } catch {
583 return;
584 }
585 if (!repoRow) return;
586 if (!repoRow.previewBuildsEnabled) return;
587
588 for (const ref of branchRefs) {
589 const branchName = ref.refName.replace("refs/heads/", "");
590 if (branchName === repoRow.defaultBranch) continue;
591 try {
592 await enqueuePreviewBuild({
593 repositoryId: repoRow.id,
594 ownerName: owner,
595 repoName: repo,
596 branchName,
597 commitSha: ref.newSha,
598 });
599 } catch (err) {
600 console.warn(
601 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
602 err instanceof Error ? err.message : err
603 );
604 }
605 }
606}
607
d199847Claude608/**
609 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
610 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
611 * on missing repo or DB error — pushes never block. Never throws.
612 */
613async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
614 let repositoryId = "";
615 try {
616 const [row] = await db
617 .select({ id: repositories.id })
618 .from(repositories)
619 .innerJoin(users, eq(repositories.ownerId, users.id))
620 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
621 .limit(1);
622 repositoryId = row?.id || "";
623 } catch {
624 return;
625 }
626 if (!repositoryId) return;
627 try {
628 const out = await runDocDriftCheckForRepo(repositoryId);
629 if (out.docs > 0 || out.proposed > 0) {
630 console.log(
631 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
632 );
633 }
634 } catch (err) {
635 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
636 }
637}
638
783dd46Claude639/**
640 * Block ST — fan out the push to any server targets that watch this
641 * (repo, branch). One sequential deploy per target so a slow box can't
642 * stall the next push, but multiple matching targets run in parallel.
643 * Every failure is contained to its own deploy row + console warn —
644 * nothing here can break the push path.
645 */
646async function fireServerTargetDeploys(
647 owner: string,
648 repo: string,
649 refs: PushRef[]
650): Promise<void> {
651 const liveRefs = refs.filter(
652 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
653 );
654 if (liveRefs.length === 0) return;
655
656 let repositoryId = "";
657 try {
658 const [row] = await db
659 .select({ id: repositories.id })
660 .from(repositories)
661 .innerJoin(users, eq(repositories.ownerId, users.id))
662 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
663 .limit(1);
664 repositoryId = row?.id || "";
665 } catch {
666 return;
667 }
668 if (!repositoryId) return;
669
670 await Promise.all(
671 liveRefs.map(async (ref) => {
672 const branch = ref.refName.replace("refs/heads/", "");
673 let targets;
674 try {
675 targets = await findTargetsForPush({ repositoryId, branch });
676 } catch {
677 return;
678 }
679 if (!targets.length) return;
680
681 await Promise.all(
682 targets.map(async (target) => {
683 try {
684 const env = await resolveEnv(target.id);
685 const deployId = await startDeployRow({
686 targetId: target.id,
687 commitSha: ref.newSha,
688 ref: ref.refName,
689 triggerSource: "push",
690 });
691 const result = await deployToTarget(target, {
692 commitSha: ref.newSha,
693 ref: ref.refName,
694 env,
695 });
696 if (deployId) {
697 await finishDeployRow({
698 id: deployId,
699 exitCode: result.exitCode,
700 stdout: result.stdout,
701 stderr: result.stderr,
702 });
703 }
704 console.log(
705 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
706 );
707 } catch (err) {
708 console.warn(
709 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
710 );
711 }
712 })
713 );
714 })
715 );
716}
717
43cf9b0Claude718/** Test-only access to internal helpers. */
a686079Claude719export const __test = {
720 triggerCrontechDeploy,
721 signBody,
722 buildPayload,
723 RETRY_DELAYS_MS,
724 listChangedPaths,
725 fireSemanticIndex,
4bbacbeClaude726 firePreviewBuilds,
d199847Claude727 fireDocDriftCheck,
783dd46Claude728 fireServerTargetDeploys,
a686079Claude729};