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.tsBlame619 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";
4bbacbeClaude28import { enqueuePreviewBuild } from "../lib/branch-previews";
d199847Claude29import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
fc1817aClaude30
31interface PushRef {
32 oldSha: string;
33 newSha: string;
34 refName: string;
35}
36
37export async function onPostReceive(
38 owner: string,
39 repo: string,
40 refs: PushRef[]
41): Promise<void> {
2c34075Claude42 for (const ref of refs) {
43 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
44 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude45
2c34075Claude46 // 1. Auto-repair (runs first, may create a new commit)
47 try {
48 const repair = await autoRepair(owner, repo, branchName);
49 if (repair.repaired) {
50 console.log(
51 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
52 );
53 }
54 } catch (err) {
55 console.error(`[autorepair] error:`, err);
56 }
fc1817aClaude57
2c34075Claude58 // 2. Push analysis
59 try {
60 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
61 console.log(
62 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
63 );
64 if (analysis.riskScore > 50) {
65 console.warn(
66 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
67 );
68 }
69 if (analysis.breakingChangeSignals.length > 0) {
70 console.warn(
71 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
72 );
73 }
74 } catch (err) {
75 console.error(`[push-analysis] error:`, err);
76 }
77
78 // 3. Health score (async, don't block)
79 computeHealthScore(owner, repo).then((report) => {
80 console.log(
81 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
82 );
83 }).catch((err) => {
84 console.error(`[health] error:`, err);
85 });
86 }
87
170ddb2Claude88 // 4. GateTest scan — fire-and-forget notification on every push. The
89 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
90 // deployments pay no overhead. Results flow back via the inbound
91 // webhook at POST /api/hooks/gatetest.
92 for (const ref of refs) {
93 if (ref.newSha.startsWith("0000")) continue;
94 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
95 console.warn("[gatetest] notify error:", err)
96 );
97 }
2c34075Claude98
a686079Claude99 // 4b. Continuous semantic index — embed changed files on every push so
100 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
101 // all failures are swallowed inside semantic-index.ts so a missing
102 // pgvector extension or absent embeddings API key never breaks the
103 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
104 for (const ref of refs) {
105 if (ref.newSha.startsWith("0000")) continue;
106 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
107 console.warn("[semantic-index] dispatch error:", err)
108 );
109 }
110
4bbacbeClaude111 // 4c. Per-branch preview URLs (migration 0062). Every push to a
112 // non-default branch enqueues a preview-build row. Gated on
113 // repositories.preview_builds_enabled (default on) so owners can
114 // opt out per-repo via repo-settings. Fire-and-forget; failures
115 // never break the push path.
116 void firePreviewBuilds(owner, repo, refs).catch((err) =>
117 console.warn("[branch-previews] dispatch error:", err)
118 );
119
d199847Claude120 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
121 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
122 // regions, hashes the referenced source, and opens a PR labelled
123 // `ai:doc-update` when the prose drifts. Fire-and-forget; failures
124 // are swallowed inside ai-doc-updater.ts so a missing anthropic key
125 // or empty doc_tracking table never breaks the push.
126 void fireDocDriftCheck(owner, repo).catch((err) =>
127 console.warn("[ai-doc-updater] dispatch error:", err)
128 );
129
ba93444Claude130 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
131 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
132 // default branch. The branch case (`Main` vs `main`) is determined by
133 // the bare repo's HEAD, not hardcoded.
134 if (`${owner}/${repo}` === config.crontechRepo) {
a28cedeClaude135 let defaultBranch =
136 (await getDefaultBranch(owner, repo).catch((err) => {
137 console.warn(
138 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
139 err instanceof Error ? err.message : err
140 );
141 return null;
142 })) || "main";
ba93444Claude143 const targetRef = `refs/heads/${defaultBranch}`;
144 const deployPush = refs.find(
145 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
146 );
147 if (deployPush) {
148 let repositoryId = "";
149 try {
150 const [row] = await db
151 .select({ id: repositories.id })
152 .from(repositories)
153 .innerJoin(users, eq(repositories.ownerId, users.id))
154 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
155 .limit(1);
156 repositoryId = row?.id || "";
157 } catch {
158 /* ignore */
159 }
160 if (repositoryId) {
161 triggerCrontechDeploy({
162 owner,
163 repo,
164 before: deployPush.oldSha,
165 after: deployPush.newSha,
166 ref: targetRef,
167 branch: defaultBranch,
168 repositoryId,
169 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
170 }
0316dbbClaude171 }
fc1817aClaude172 }
f2c00b4CC LABS App173
174 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
175 // main, fire the local deploy via scripts/self-deploy.sh. The script
176 // forks into the background, so this call returns immediately (git
177 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
178 // avoid firing on customer repos that happen to be named "Gluecron.com".
179 const selfHostRepo = process.env.SELF_HOST_REPO;
180 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
181 const mainRef = refs.find(
182 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
183 );
184 if (mainRef) {
185 const scriptPath =
186 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
187 "/opt/gluecron/scripts/self-deploy.sh";
188 try {
189 const child = __selfHostSpawn(
190 [scriptPath, mainRef.oldSha, mainRef.newSha],
191 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
192 );
193 try {
194 (child as any)?.unref?.();
195 } catch {
196 /* unref optional */
197 }
198 console.log(
199 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
200 );
201 } catch (err) {
202 console.error(`[self-host] failed to spawn:`, err);
203 }
204 }
205 }
206}
207
208// BLOCK W — DI seam so the test suite can capture the spawn call without
209// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
210// callers go straight to Bun.spawn.
bf19c50Test User211const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App212 Bun.spawn(cmd, opts);
bf19c50Test User213let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
214/**
215 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
216 */
217export function __setSelfHostSpawnForTests(
218 fn: typeof __selfHostSpawn | null
219): void {
220 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude221}
222
43cf9b0Claude223/**
ba93444Claude224 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude225 *
ba93444Claude226 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude227 *
ba93444Claude228 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude229 * Content-Type: application/json
ba93444Claude230 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude231 *
232 * {
ba93444Claude233 * "event": "push",
234 * "repository": { "full_name": "ccantynz-alt/crontech" },
235 * "ref": "refs/heads/Main",
236 * "after": "<40-hex commit SHA>",
237 * "before": "<40-hex previous SHA>",
238 * "pusher": { "name": "<author>", "email": "<email>" },
239 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude240 * }
241 *
ba93444Claude242 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude243 *
ba93444Claude244 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
245 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
246 * is unset the signature header is omitted and Crontech is expected to reject —
247 * we still record the deploy row as failed.
43cf9b0Claude248 */
ba93444Claude249const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
250
251interface TriggerArgs {
252 owner: string;
253 repo: string;
254 before: string;
255 after: string;
256 ref: string;
257 branch: string;
258 repositoryId: string;
259}
260
261interface TriggerOptions {
262 fetchImpl?: typeof fetch;
263 sleep?: (ms: number) => Promise<void>;
264 retryDelaysMs?: number[];
265 now?: () => Date;
266}
267
268function signBody(body: string, secret: string): string | null {
269 if (!secret) return null;
270 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
271}
272
273async function buildPayload(args: TriggerArgs, now: Date): Promise<{
274 payload: Record<string, unknown>;
275 pusherName: string;
276 pusherEmail: string;
277}> {
278 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
279 // `before` may be all-zeros for a first push to the branch — commitsBetween
280 // handles that by treating null `from` as "everything reachable from `to`".
281 const fromSha = /^0+$/.test(args.before) ? null : args.before;
282 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
283 let pusherName = "gluecron";
284 let pusherEmail = "noreply@gluecron.local";
285 try {
286 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
287 commits = list.slice(0, 50).map((c) => ({
288 id: c.sha,
289 message: c.message,
290 timestamp: c.date,
291 }));
292 if (list[0]) {
293 pusherName = list[0].author || pusherName;
294 pusherEmail = list[0].authorEmail || pusherEmail;
295 }
296 } catch {
297 /* ignore — payload still valid with empty commits[] */
298 }
299 return {
300 payload: {
301 event: "push",
302 repository: { full_name: `${args.owner}/${args.repo}` },
303 ref: args.ref,
304 after: args.after,
305 before: args.before,
306 pusher: { name: pusherName, email: pusherEmail },
307 commits,
308 // Ancillary fields — receiver may ignore but they're useful for logs:
309 sent_at: now.toISOString(),
310 source: "gluecron",
311 },
312 pusherName,
313 pusherEmail,
314 };
315}
316
fc1817aClaude317async function triggerCrontechDeploy(
ba93444Claude318 args: TriggerArgs,
319 opts: TriggerOptions = {}
fc1817aClaude320): Promise<void> {
ba93444Claude321 const fetchImpl = opts.fetchImpl ?? fetch;
322 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
323 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
324 const now = opts.now ?? (() => new Date());
325
3ef4c9dClaude326 let deployId = "";
fc1817aClaude327 try {
3ef4c9dClaude328 const [row] = await db
329 .insert(deployments)
330 .values({
ba93444Claude331 repositoryId: args.repositoryId,
3ef4c9dClaude332 environment: "production",
ba93444Claude333 commitSha: args.after,
334 ref: args.ref,
3ef4c9dClaude335 status: "pending",
336 target: "crontech",
337 })
338 .returning();
339 deployId = row?.id || "";
340 } catch {
341 /* ignore */
fc1817aClaude342 }
343
ba93444Claude344 const { payload } = await buildPayload(args, now());
345 const body = JSON.stringify(payload);
346 const signature = signBody(body, config.gluecronWebhookSecret);
347
348 const headers: Record<string, string> = {
349 "Content-Type": "application/json",
350 "User-Agent": "gluecron-webhook/1",
351 "X-Gluecron-Event": "push",
352 "X-Gluecron-Delivery": cryptoRandomId(),
353 };
354 if (signature) headers["X-Gluecron-Signature"] = signature;
355
356 let lastStatus = 0;
357 let lastError = "";
358 let success = false;
359
360 // Up to delays.length + 1 attempts (initial try + each delay).
361 const totalAttempts = delays.length + 1;
362 for (let attempt = 0; attempt < totalAttempts; attempt++) {
363 try {
364 const response = await fetchImpl(config.crontechDeployUrl, {
365 method: "POST",
366 headers,
367 body,
368 });
369 lastStatus = response.status;
370 console.log(
371 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
372 );
373 if (response.ok) {
374 success = true;
375 break;
376 }
377 // 4xx (except 408/429) is unrecoverable — stop retrying.
378 if (response.status >= 400 && response.status < 500 &&
379 response.status !== 408 && response.status !== 429) {
380 break;
381 }
382 } catch (err) {
383 lastError = err instanceof Error ? err.message : String(err);
384 console.error(
385 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
386 );
3ef4c9dClaude387 }
ba93444Claude388 const nextDelay = delays[attempt];
389 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
390 await sleep(nextDelay);
1e162a8Claude391 }
ba93444Claude392 }
393
394 if (deployId) {
395 try {
3ef4c9dClaude396 await db
397 .update(deployments)
398 .set({
ba93444Claude399 status: success ? "success" : "failed",
400 blockedReason: success
401 ? null
402 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude403 completedAt: new Date(),
404 })
405 .where(eq(deployments.id, deployId));
ba93444Claude406 } catch {
407 /* ignore */
3ef4c9dClaude408 }
409 }
410
ba93444Claude411 if (!success && deployId) {
412 void onDeployFailure({
413 repositoryId: args.repositoryId,
414 deploymentId: deployId,
415 ref: args.ref,
416 commitSha: args.after,
417 target: "crontech",
418 errorMessage: lastError || `HTTP ${lastStatus}`,
419 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude420 }
421}
43cf9b0Claude422
ba93444Claude423function cryptoRandomId(): string {
424 // Short opaque delivery ID for log correlation. Not security-sensitive.
425 const bytes = new Uint8Array(8);
426 crypto.getRandomValues(bytes);
427 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
428}
429
a686079Claude430/**
431 * Resolve `owner/repo` to its DB repository.id and dispatch the
432 * semantic-index update. Pulls the list of changed paths via
433 * `git diff --name-only`, dropping any deletions (handled implicitly
434 * because deleted blobs simply don't resolve in `indexChangedFiles`).
435 *
436 * Never throws — exhaust every external call inside a try/catch so the
437 * push completes even if Postgres or the embedding API is down.
438 */
439async function fireSemanticIndex(
440 owner: string,
441 repo: string,
442 oldSha: string,
443 newSha: string
444): Promise<void> {
445 let repositoryId = "";
446 try {
447 const [row] = await db
448 .select({ id: repositories.id })
449 .from(repositories)
450 .innerJoin(users, eq(repositories.ownerId, users.id))
451 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
452 .limit(1);
453 repositoryId = row?.id || "";
454 } catch {
455 return;
456 }
457 if (!repositoryId) return;
458
459 let changedPaths: string[] = [];
460 try {
461 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
462 } catch {
463 return;
464 }
465 if (!changedPaths.length) return;
466
467 try {
468 const out = await indexChangedFiles({
469 repositoryId,
470 ownerName: owner,
471 repoName: repo,
472 commitSha: newSha,
473 changedPaths,
474 });
475 if (out.indexed > 0) {
476 console.log(
477 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
478 );
479 }
480 } catch (err) {
481 console.warn("[semantic-index] indexChangedFiles error:", err);
482 }
483}
484
485/**
486 * Returns the list of files touched between `oldSha` and `newSha`. For
487 * the initial push on a branch (oldSha all-zero) we walk every file in
488 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
489 */
490async function listChangedPaths(
491 owner: string,
492 repo: string,
493 oldSha: string,
494 newSha: string
495): Promise<string[]> {
496 const cwd = getRepoPath(owner, repo);
497 const allZero = /^0+$/.test(oldSha);
498 const cmd = allZero
499 ? ["git", "ls-tree", "-r", "--name-only", newSha]
500 : ["git", "diff", "--name-only", oldSha, newSha];
501 try {
502 const proc = Bun.spawn(cmd, {
503 cwd,
504 stdout: "pipe",
505 stderr: "pipe",
506 });
507 const text = await new Response(proc.stdout).text();
508 await proc.exited;
509 return text
510 .split("\n")
511 .map((s) => s.trim())
512 .filter((s) => s.length > 0);
513 } catch {
514 return [];
515 }
516}
517
4bbacbeClaude518/**
519 * Migration 0062 — fan out push refs to enqueuePreviewBuild for every
520 * non-default branch on a `preview_builds_enabled` repo.
521 *
522 * Resolves the repo row once, fetches the default branch + opt-out flag,
523 * then upserts one preview row per pushed ref that isn't the default.
524 * Branch deletions (oldSha all-zero or newSha all-zero with oldSha set)
525 * are skipped — they shouldn't create preview rows. Never throws.
526 */
527async function firePreviewBuilds(
528 owner: string,
529 repo: string,
530 refs: PushRef[]
531): Promise<void> {
532 // Filter to live branch pushes only.
533 const branchRefs = refs.filter(
534 (r) =>
535 r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
536 );
537 if (branchRefs.length === 0) return;
538
539 let repoRow: { id: string; previewBuildsEnabled: boolean; defaultBranch: string } | null = null;
540 try {
541 const [row] = await db
542 .select({
543 id: repositories.id,
544 previewBuildsEnabled: repositories.previewBuildsEnabled,
545 defaultBranch: repositories.defaultBranch,
546 })
547 .from(repositories)
548 .innerJoin(users, eq(repositories.ownerId, users.id))
549 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
550 .limit(1);
551 repoRow = row || null;
552 } catch {
553 return;
554 }
555 if (!repoRow) return;
556 if (!repoRow.previewBuildsEnabled) return;
557
558 for (const ref of branchRefs) {
559 const branchName = ref.refName.replace("refs/heads/", "");
560 if (branchName === repoRow.defaultBranch) continue;
561 try {
562 await enqueuePreviewBuild({
563 repositoryId: repoRow.id,
564 ownerName: owner,
565 repoName: repo,
566 branchName,
567 commitSha: ref.newSha,
568 });
569 } catch (err) {
570 console.warn(
571 `[branch-previews] enqueue failed for ${owner}/${repo}@${branchName}:`,
572 err instanceof Error ? err.message : err
573 );
574 }
575 }
576}
577
d199847Claude578/**
579 * Migration 0068 — resolve `owner/repo` to its DB id and kick off the
580 * doc-drift sweep (findTrackedDocs + proposeDocUpdate). Returns immediately
581 * on missing repo or DB error — pushes never block. Never throws.
582 */
583async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
584 let repositoryId = "";
585 try {
586 const [row] = await db
587 .select({ id: repositories.id })
588 .from(repositories)
589 .innerJoin(users, eq(repositories.ownerId, users.id))
590 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
591 .limit(1);
592 repositoryId = row?.id || "";
593 } catch {
594 return;
595 }
596 if (!repositoryId) return;
597 try {
598 const out = await runDocDriftCheckForRepo(repositoryId);
599 if (out.docs > 0 || out.proposed > 0) {
600 console.log(
601 `[ai-doc-updater] ${owner}/${repo}: docs=${out.docs} proposed=${out.proposed}`
602 );
603 }
604 } catch (err) {
605 console.warn("[ai-doc-updater] runDocDriftCheckForRepo error:", err);
606 }
607}
608
43cf9b0Claude609/** Test-only access to internal helpers. */
a686079Claude610export const __test = {
611 triggerCrontechDeploy,
612 signBody,
613 buildPayload,
614 RETRY_DELAYS_MS,
615 listChangedPaths,
616 fireSemanticIndex,
4bbacbeClaude617 firePreviewBuilds,
d199847Claude618 fireDocDriftCheck,
a686079Claude619};