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.tsBlame505 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";
fc1817aClaude28
29interface PushRef {
30 oldSha: string;
31 newSha: string;
32 refName: string;
33}
34
35export async function onPostReceive(
36 owner: string,
37 repo: string,
38 refs: PushRef[]
39): Promise<void> {
2c34075Claude40 for (const ref of refs) {
41 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
42 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude43
2c34075Claude44 // 1. Auto-repair (runs first, may create a new commit)
45 try {
46 const repair = await autoRepair(owner, repo, branchName);
47 if (repair.repaired) {
48 console.log(
49 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
50 );
51 }
52 } catch (err) {
53 console.error(`[autorepair] error:`, err);
54 }
fc1817aClaude55
2c34075Claude56 // 2. Push analysis
57 try {
58 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
59 console.log(
60 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
61 );
62 if (analysis.riskScore > 50) {
63 console.warn(
64 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
65 );
66 }
67 if (analysis.breakingChangeSignals.length > 0) {
68 console.warn(
69 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
70 );
71 }
72 } catch (err) {
73 console.error(`[push-analysis] error:`, err);
74 }
75
76 // 3. Health score (async, don't block)
77 computeHealthScore(owner, repo).then((report) => {
78 console.log(
79 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
80 );
81 }).catch((err) => {
82 console.error(`[health] error:`, err);
83 });
84 }
85
170ddb2Claude86 // 4. GateTest scan — fire-and-forget notification on every push. The
87 // helper short-circuits if `GATETEST_URL` is unset, so non-GateTest
88 // deployments pay no overhead. Results flow back via the inbound
89 // webhook at POST /api/hooks/gatetest.
90 for (const ref of refs) {
91 if (ref.newSha.startsWith("0000")) continue;
92 notifyGateTestOfPush(owner, repo, ref.refName, ref.newSha).catch((err) =>
93 console.warn("[gatetest] notify error:", err)
94 );
95 }
2c34075Claude96
a686079Claude97 // 4b. Continuous semantic index — embed changed files on every push so
98 // /api/v2/.../semantic-search has fresh vectors. Fire-and-forget;
99 // all failures are swallowed inside semantic-index.ts so a missing
100 // pgvector extension or absent embeddings API key never breaks the
101 // push path. Capped to MAX_FILES_PER_PUSH inside the lib.
102 for (const ref of refs) {
103 if (ref.newSha.startsWith("0000")) continue;
104 void fireSemanticIndex(owner, repo, ref.oldSha, ref.newSha).catch((err) =>
105 console.warn("[semantic-index] dispatch error:", err)
106 );
107 }
108
ba93444Claude109 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
110 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
111 // default branch. The branch case (`Main` vs `main`) is determined by
112 // the bare repo's HEAD, not hardcoded.
113 if (`${owner}/${repo}` === config.crontechRepo) {
a28cedeClaude114 let defaultBranch =
115 (await getDefaultBranch(owner, repo).catch((err) => {
116 console.warn(
117 `[post-receive] getDefaultBranch failed for ${owner}/${repo}, defaulting to "main":`,
118 err instanceof Error ? err.message : err
119 );
120 return null;
121 })) || "main";
ba93444Claude122 const targetRef = `refs/heads/${defaultBranch}`;
123 const deployPush = refs.find(
124 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
125 );
126 if (deployPush) {
127 let repositoryId = "";
128 try {
129 const [row] = await db
130 .select({ id: repositories.id })
131 .from(repositories)
132 .innerJoin(users, eq(repositories.ownerId, users.id))
133 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
134 .limit(1);
135 repositoryId = row?.id || "";
136 } catch {
137 /* ignore */
138 }
139 if (repositoryId) {
140 triggerCrontechDeploy({
141 owner,
142 repo,
143 before: deployPush.oldSha,
144 after: deployPush.newSha,
145 ref: targetRef,
146 branch: defaultBranch,
147 repositoryId,
148 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
149 }
0316dbbClaude150 }
fc1817aClaude151 }
f2c00b4CC LABS App152
153 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
154 // main, fire the local deploy via scripts/self-deploy.sh. The script
155 // forks into the background, so this call returns immediately (git
156 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
157 // avoid firing on customer repos that happen to be named "Gluecron.com".
158 const selfHostRepo = process.env.SELF_HOST_REPO;
159 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
160 const mainRef = refs.find(
161 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
162 );
163 if (mainRef) {
164 const scriptPath =
165 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
166 "/opt/gluecron/scripts/self-deploy.sh";
167 try {
168 const child = __selfHostSpawn(
169 [scriptPath, mainRef.oldSha, mainRef.newSha],
170 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
171 );
172 try {
173 (child as any)?.unref?.();
174 } catch {
175 /* unref optional */
176 }
177 console.log(
178 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
179 );
180 } catch (err) {
181 console.error(`[self-host] failed to spawn:`, err);
182 }
183 }
184 }
185}
186
187// BLOCK W — DI seam so the test suite can capture the spawn call without
188// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
189// callers go straight to Bun.spawn.
bf19c50Test User190const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
f2c00b4CC LABS App191 Bun.spawn(cmd, opts);
bf19c50Test User192let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
193/**
194 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
195 */
196export function __setSelfHostSpawnForTests(
197 fn: typeof __selfHostSpawn | null
198): void {
199 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
fc1817aClaude200}
201
43cf9b0Claude202/**
ba93444Claude203 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude204 *
ba93444Claude205 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude206 *
ba93444Claude207 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude208 * Content-Type: application/json
ba93444Claude209 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude210 *
211 * {
ba93444Claude212 * "event": "push",
213 * "repository": { "full_name": "ccantynz-alt/crontech" },
214 * "ref": "refs/heads/Main",
215 * "after": "<40-hex commit SHA>",
216 * "before": "<40-hex previous SHA>",
217 * "pusher": { "name": "<author>", "email": "<email>" },
218 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude219 * }
220 *
ba93444Claude221 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude222 *
ba93444Claude223 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
224 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
225 * is unset the signature header is omitted and Crontech is expected to reject —
226 * we still record the deploy row as failed.
43cf9b0Claude227 */
ba93444Claude228const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
229
230interface TriggerArgs {
231 owner: string;
232 repo: string;
233 before: string;
234 after: string;
235 ref: string;
236 branch: string;
237 repositoryId: string;
238}
239
240interface TriggerOptions {
241 fetchImpl?: typeof fetch;
242 sleep?: (ms: number) => Promise<void>;
243 retryDelaysMs?: number[];
244 now?: () => Date;
245}
246
247function signBody(body: string, secret: string): string | null {
248 if (!secret) return null;
249 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
250}
251
252async function buildPayload(args: TriggerArgs, now: Date): Promise<{
253 payload: Record<string, unknown>;
254 pusherName: string;
255 pusherEmail: string;
256}> {
257 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
258 // `before` may be all-zeros for a first push to the branch — commitsBetween
259 // handles that by treating null `from` as "everything reachable from `to`".
260 const fromSha = /^0+$/.test(args.before) ? null : args.before;
261 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
262 let pusherName = "gluecron";
263 let pusherEmail = "noreply@gluecron.local";
264 try {
265 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
266 commits = list.slice(0, 50).map((c) => ({
267 id: c.sha,
268 message: c.message,
269 timestamp: c.date,
270 }));
271 if (list[0]) {
272 pusherName = list[0].author || pusherName;
273 pusherEmail = list[0].authorEmail || pusherEmail;
274 }
275 } catch {
276 /* ignore — payload still valid with empty commits[] */
277 }
278 return {
279 payload: {
280 event: "push",
281 repository: { full_name: `${args.owner}/${args.repo}` },
282 ref: args.ref,
283 after: args.after,
284 before: args.before,
285 pusher: { name: pusherName, email: pusherEmail },
286 commits,
287 // Ancillary fields — receiver may ignore but they're useful for logs:
288 sent_at: now.toISOString(),
289 source: "gluecron",
290 },
291 pusherName,
292 pusherEmail,
293 };
294}
295
fc1817aClaude296async function triggerCrontechDeploy(
ba93444Claude297 args: TriggerArgs,
298 opts: TriggerOptions = {}
fc1817aClaude299): Promise<void> {
ba93444Claude300 const fetchImpl = opts.fetchImpl ?? fetch;
301 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
302 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
303 const now = opts.now ?? (() => new Date());
304
3ef4c9dClaude305 let deployId = "";
fc1817aClaude306 try {
3ef4c9dClaude307 const [row] = await db
308 .insert(deployments)
309 .values({
ba93444Claude310 repositoryId: args.repositoryId,
3ef4c9dClaude311 environment: "production",
ba93444Claude312 commitSha: args.after,
313 ref: args.ref,
3ef4c9dClaude314 status: "pending",
315 target: "crontech",
316 })
317 .returning();
318 deployId = row?.id || "";
319 } catch {
320 /* ignore */
fc1817aClaude321 }
322
ba93444Claude323 const { payload } = await buildPayload(args, now());
324 const body = JSON.stringify(payload);
325 const signature = signBody(body, config.gluecronWebhookSecret);
326
327 const headers: Record<string, string> = {
328 "Content-Type": "application/json",
329 "User-Agent": "gluecron-webhook/1",
330 "X-Gluecron-Event": "push",
331 "X-Gluecron-Delivery": cryptoRandomId(),
332 };
333 if (signature) headers["X-Gluecron-Signature"] = signature;
334
335 let lastStatus = 0;
336 let lastError = "";
337 let success = false;
338
339 // Up to delays.length + 1 attempts (initial try + each delay).
340 const totalAttempts = delays.length + 1;
341 for (let attempt = 0; attempt < totalAttempts; attempt++) {
342 try {
343 const response = await fetchImpl(config.crontechDeployUrl, {
344 method: "POST",
345 headers,
346 body,
347 });
348 lastStatus = response.status;
349 console.log(
350 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
351 );
352 if (response.ok) {
353 success = true;
354 break;
355 }
356 // 4xx (except 408/429) is unrecoverable — stop retrying.
357 if (response.status >= 400 && response.status < 500 &&
358 response.status !== 408 && response.status !== 429) {
359 break;
360 }
361 } catch (err) {
362 lastError = err instanceof Error ? err.message : String(err);
363 console.error(
364 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
365 );
3ef4c9dClaude366 }
ba93444Claude367 const nextDelay = delays[attempt];
368 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
369 await sleep(nextDelay);
1e162a8Claude370 }
ba93444Claude371 }
372
373 if (deployId) {
374 try {
3ef4c9dClaude375 await db
376 .update(deployments)
377 .set({
ba93444Claude378 status: success ? "success" : "failed",
379 blockedReason: success
380 ? null
381 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude382 completedAt: new Date(),
383 })
384 .where(eq(deployments.id, deployId));
ba93444Claude385 } catch {
386 /* ignore */
3ef4c9dClaude387 }
388 }
389
ba93444Claude390 if (!success && deployId) {
391 void onDeployFailure({
392 repositoryId: args.repositoryId,
393 deploymentId: deployId,
394 ref: args.ref,
395 commitSha: args.after,
396 target: "crontech",
397 errorMessage: lastError || `HTTP ${lastStatus}`,
398 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude399 }
400}
43cf9b0Claude401
ba93444Claude402function cryptoRandomId(): string {
403 // Short opaque delivery ID for log correlation. Not security-sensitive.
404 const bytes = new Uint8Array(8);
405 crypto.getRandomValues(bytes);
406 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
407}
408
a686079Claude409/**
410 * Resolve `owner/repo` to its DB repository.id and dispatch the
411 * semantic-index update. Pulls the list of changed paths via
412 * `git diff --name-only`, dropping any deletions (handled implicitly
413 * because deleted blobs simply don't resolve in `indexChangedFiles`).
414 *
415 * Never throws — exhaust every external call inside a try/catch so the
416 * push completes even if Postgres or the embedding API is down.
417 */
418async function fireSemanticIndex(
419 owner: string,
420 repo: string,
421 oldSha: string,
422 newSha: string
423): Promise<void> {
424 let repositoryId = "";
425 try {
426 const [row] = await db
427 .select({ id: repositories.id })
428 .from(repositories)
429 .innerJoin(users, eq(repositories.ownerId, users.id))
430 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
431 .limit(1);
432 repositoryId = row?.id || "";
433 } catch {
434 return;
435 }
436 if (!repositoryId) return;
437
438 let changedPaths: string[] = [];
439 try {
440 changedPaths = await listChangedPaths(owner, repo, oldSha, newSha);
441 } catch {
442 return;
443 }
444 if (!changedPaths.length) return;
445
446 try {
447 const out = await indexChangedFiles({
448 repositoryId,
449 ownerName: owner,
450 repoName: repo,
451 commitSha: newSha,
452 changedPaths,
453 });
454 if (out.indexed > 0) {
455 console.log(
456 `[semantic-index] ${owner}/${repo}@${newSha.slice(0, 7)}: indexed ${out.indexed} file(s) via ${out.model}`
457 );
458 }
459 } catch (err) {
460 console.warn("[semantic-index] indexChangedFiles error:", err);
461 }
462}
463
464/**
465 * Returns the list of files touched between `oldSha` and `newSha`. For
466 * the initial push on a branch (oldSha all-zero) we walk every file in
467 * the new tree via `git ls-tree -r`. Returns [] on any subprocess error.
468 */
469async function listChangedPaths(
470 owner: string,
471 repo: string,
472 oldSha: string,
473 newSha: string
474): Promise<string[]> {
475 const cwd = getRepoPath(owner, repo);
476 const allZero = /^0+$/.test(oldSha);
477 const cmd = allZero
478 ? ["git", "ls-tree", "-r", "--name-only", newSha]
479 : ["git", "diff", "--name-only", oldSha, newSha];
480 try {
481 const proc = Bun.spawn(cmd, {
482 cwd,
483 stdout: "pipe",
484 stderr: "pipe",
485 });
486 const text = await new Response(proc.stdout).text();
487 await proc.exited;
488 return text
489 .split("\n")
490 .map((s) => s.trim())
491 .filter((s) => s.length > 0);
492 } catch {
493 return [];
494 }
495}
496
43cf9b0Claude497/** Test-only access to internal helpers. */
a686079Claude498export const __test = {
499 triggerCrontechDeploy,
500 signBody,
501 buildPayload,
502 RETRY_DELAYS_MS,
503 listChangedPaths,
504 fireSemanticIndex,
505};