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.tsBlame329 lines · 1 contributor
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";
17import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude18import { db } from "../db";
19import { deployments, repositories, users } from "../db/schema";
20import { onDeployFailure } from "../lib/ai-incident";
ba93444Claude21import { commitsBetween, getDefaultBranch } from "../git/repository";
fc1817aClaude22
23interface PushRef {
24 oldSha: string;
25 newSha: string;
26 refName: string;
27}
28
29export async function onPostReceive(
30 owner: string,
31 repo: string,
32 refs: PushRef[]
33): Promise<void> {
2c34075Claude34 for (const ref of refs) {
35 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
36 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude37
2c34075Claude38 // 1. Auto-repair (runs first, may create a new commit)
39 try {
40 const repair = await autoRepair(owner, repo, branchName);
41 if (repair.repaired) {
42 console.log(
43 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
44 );
45 }
46 } catch (err) {
47 console.error(`[autorepair] error:`, err);
48 }
fc1817aClaude49
2c34075Claude50 // 2. Push analysis
51 try {
52 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
53 console.log(
54 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
55 );
56 if (analysis.riskScore > 50) {
57 console.warn(
58 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
59 );
60 }
61 if (analysis.breakingChangeSignals.length > 0) {
62 console.warn(
63 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
64 );
65 }
66 } catch (err) {
67 console.error(`[push-analysis] error:`, err);
68 }
69
70 // 3. Health score (async, don't block)
71 computeHealthScore(owner, repo).then((report) => {
72 console.log(
73 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
74 );
75 }).catch((err) => {
76 console.error(`[health] error:`, err);
77 });
78 }
79
0316dbbClaude80 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
81 // triggerGateTest helper is slated for the intelligence rework.
2c34075Claude82
ba93444Claude83 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
84 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
85 // default branch. The branch case (`Main` vs `main`) is determined by
86 // the bare repo's HEAD, not hardcoded.
87 if (`${owner}/${repo}` === config.crontechRepo) {
88 let defaultBranch = (await getDefaultBranch(owner, repo).catch(() => null)) || "main";
89 const targetRef = `refs/heads/${defaultBranch}`;
90 const deployPush = refs.find(
91 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
92 );
93 if (deployPush) {
94 let repositoryId = "";
95 try {
96 const [row] = await db
97 .select({ id: repositories.id })
98 .from(repositories)
99 .innerJoin(users, eq(repositories.ownerId, users.id))
100 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
101 .limit(1);
102 repositoryId = row?.id || "";
103 } catch {
104 /* ignore */
105 }
106 if (repositoryId) {
107 triggerCrontechDeploy({
108 owner,
109 repo,
110 before: deployPush.oldSha,
111 after: deployPush.newSha,
112 ref: targetRef,
113 branch: defaultBranch,
114 repositoryId,
115 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
116 }
0316dbbClaude117 }
fc1817aClaude118 }
119}
120
43cf9b0Claude121/**
ba93444Claude122 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
43cf9b0Claude123 *
ba93444Claude124 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
43cf9b0Claude125 *
ba93444Claude126 * POST https://crontech.ai/api/webhooks/gluecron-push
43cf9b0Claude127 * Content-Type: application/json
ba93444Claude128 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
43cf9b0Claude129 *
130 * {
ba93444Claude131 * "event": "push",
132 * "repository": { "full_name": "ccantynz-alt/crontech" },
133 * "ref": "refs/heads/Main",
134 * "after": "<40-hex commit SHA>",
135 * "before": "<40-hex previous SHA>",
136 * "pusher": { "name": "<author>", "email": "<email>" },
137 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
43cf9b0Claude138 * }
139 *
ba93444Claude140 * The `after` SHA is the dedupe key on the receiver side (idempotent).
43cf9b0Claude141 *
ba93444Claude142 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
143 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
144 * is unset the signature header is omitted and Crontech is expected to reject —
145 * we still record the deploy row as failed.
43cf9b0Claude146 */
ba93444Claude147const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
148
149interface TriggerArgs {
150 owner: string;
151 repo: string;
152 before: string;
153 after: string;
154 ref: string;
155 branch: string;
156 repositoryId: string;
157}
158
159interface TriggerOptions {
160 fetchImpl?: typeof fetch;
161 sleep?: (ms: number) => Promise<void>;
162 retryDelaysMs?: number[];
163 now?: () => Date;
164}
165
166function signBody(body: string, secret: string): string | null {
167 if (!secret) return null;
168 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
169}
170
171async function buildPayload(args: TriggerArgs, now: Date): Promise<{
172 payload: Record<string, unknown>;
173 pusherName: string;
174 pusherEmail: string;
175}> {
176 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
177 // `before` may be all-zeros for a first push to the branch — commitsBetween
178 // handles that by treating null `from` as "everything reachable from `to`".
179 const fromSha = /^0+$/.test(args.before) ? null : args.before;
180 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
181 let pusherName = "gluecron";
182 let pusherEmail = "noreply@gluecron.local";
183 try {
184 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
185 commits = list.slice(0, 50).map((c) => ({
186 id: c.sha,
187 message: c.message,
188 timestamp: c.date,
189 }));
190 if (list[0]) {
191 pusherName = list[0].author || pusherName;
192 pusherEmail = list[0].authorEmail || pusherEmail;
193 }
194 } catch {
195 /* ignore — payload still valid with empty commits[] */
196 }
197 return {
198 payload: {
199 event: "push",
200 repository: { full_name: `${args.owner}/${args.repo}` },
201 ref: args.ref,
202 after: args.after,
203 before: args.before,
204 pusher: { name: pusherName, email: pusherEmail },
205 commits,
206 // Ancillary fields — receiver may ignore but they're useful for logs:
207 sent_at: now.toISOString(),
208 source: "gluecron",
209 },
210 pusherName,
211 pusherEmail,
212 };
213}
214
fc1817aClaude215async function triggerCrontechDeploy(
ba93444Claude216 args: TriggerArgs,
217 opts: TriggerOptions = {}
fc1817aClaude218): Promise<void> {
ba93444Claude219 const fetchImpl = opts.fetchImpl ?? fetch;
220 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
221 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
222 const now = opts.now ?? (() => new Date());
223
3ef4c9dClaude224 let deployId = "";
fc1817aClaude225 try {
3ef4c9dClaude226 const [row] = await db
227 .insert(deployments)
228 .values({
ba93444Claude229 repositoryId: args.repositoryId,
3ef4c9dClaude230 environment: "production",
ba93444Claude231 commitSha: args.after,
232 ref: args.ref,
3ef4c9dClaude233 status: "pending",
234 target: "crontech",
235 })
236 .returning();
237 deployId = row?.id || "";
238 } catch {
239 /* ignore */
fc1817aClaude240 }
241
ba93444Claude242 const { payload } = await buildPayload(args, now());
243 const body = JSON.stringify(payload);
244 const signature = signBody(body, config.gluecronWebhookSecret);
245
246 const headers: Record<string, string> = {
247 "Content-Type": "application/json",
248 "User-Agent": "gluecron-webhook/1",
249 "X-Gluecron-Event": "push",
250 "X-Gluecron-Delivery": cryptoRandomId(),
251 };
252 if (signature) headers["X-Gluecron-Signature"] = signature;
253
254 let lastStatus = 0;
255 let lastError = "";
256 let success = false;
257
258 // Up to delays.length + 1 attempts (initial try + each delay).
259 const totalAttempts = delays.length + 1;
260 for (let attempt = 0; attempt < totalAttempts; attempt++) {
261 try {
262 const response = await fetchImpl(config.crontechDeployUrl, {
263 method: "POST",
264 headers,
265 body,
266 });
267 lastStatus = response.status;
268 console.log(
269 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
270 );
271 if (response.ok) {
272 success = true;
273 break;
274 }
275 // 4xx (except 408/429) is unrecoverable — stop retrying.
276 if (response.status >= 400 && response.status < 500 &&
277 response.status !== 408 && response.status !== 429) {
278 break;
279 }
280 } catch (err) {
281 lastError = err instanceof Error ? err.message : String(err);
282 console.error(
283 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
284 );
3ef4c9dClaude285 }
ba93444Claude286 const nextDelay = delays[attempt];
287 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
288 await sleep(nextDelay);
1e162a8Claude289 }
ba93444Claude290 }
291
292 if (deployId) {
293 try {
3ef4c9dClaude294 await db
295 .update(deployments)
296 .set({
ba93444Claude297 status: success ? "success" : "failed",
298 blockedReason: success
299 ? null
300 : (lastError ? lastError : `HTTP ${lastStatus}`),
3ef4c9dClaude301 completedAt: new Date(),
302 })
303 .where(eq(deployments.id, deployId));
ba93444Claude304 } catch {
305 /* ignore */
3ef4c9dClaude306 }
307 }
308
ba93444Claude309 if (!success && deployId) {
310 void onDeployFailure({
311 repositoryId: args.repositoryId,
312 deploymentId: deployId,
313 ref: args.ref,
314 commitSha: args.after,
315 target: "crontech",
316 errorMessage: lastError || `HTTP ${lastStatus}`,
317 }).catch((e) => console.error("[ai-incident]", e));
fc1817aClaude318 }
319}
43cf9b0Claude320
ba93444Claude321function cryptoRandomId(): string {
322 // Short opaque delivery ID for log correlation. Not security-sensitive.
323 const bytes = new Uint8Array(8);
324 crypto.getRandomValues(bytes);
325 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
326}
327
43cf9b0Claude328/** Test-only access to internal helpers. */
ba93444Claude329export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };