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.tsBlame296 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
3ef4c9dClaude13import { and, eq } from "drizzle-orm";
fc1817aClaude14import { config } from "../lib/config";
2c34075Claude15import { autoRepair } from "../lib/autorepair";
16import { analyzePush, computeHealthScore } from "../lib/intelligence";
0316dbbClaude17import { db } from "../db";
18import { deployments, repositories, users } from "../db/schema";
19import { onDeployFailure } from "../lib/ai-incident";
40e7738Claude20import { logAiEvent } from "../lib/ai-flywheel";
fc1817aClaude21
22interface PushRef {
23 oldSha: string;
24 newSha: string;
25 refName: string;
26}
27
28export async function onPostReceive(
29 owner: string,
30 repo: string,
31 refs: PushRef[]
32): Promise<void> {
40e7738Claude33 // Resolve repo id once so flywheel events can anchor to the repo topic.
34 let repositoryId: string | null = null;
35 try {
36 const [row] = await db
37 .select({ id: repositories.id })
38 .from(repositories)
39 .innerJoin(users, eq(repositories.ownerId, users.id))
40 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
41 .limit(1);
42 repositoryId = row?.id ?? null;
43 } catch {
44 /* ignore — flywheel events still publish, just without repo anchor */
45 }
46
2c34075Claude47 for (const ref of refs) {
48 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
49 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude50
40e7738Claude51 // 1. Auto-repair (runs first, may create a new commit). Always emit a
52 // flywheel event so the live dashboard shows the heal attempt — even
53 // when nothing needed fixing (always-green visibility).
54 const t0 = Date.now();
2c34075Claude55 try {
56 const repair = await autoRepair(owner, repo, branchName);
57 if (repair.repaired) {
58 console.log(
59 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
60 );
61 }
40e7738Claude62 logAiEvent({
63 actionType: "repair",
64 model: "auto-repair",
65 summary: repair.repaired
66 ? `auto-repaired ${repair.repairs.length} issue(s) on ${owner}/${repo}@${branchName}`
67 : `clean push on ${owner}/${repo}@${branchName}`,
68 repositoryId,
69 commitSha: repair.commitSha ?? ref.newSha.slice(0, 12),
70 latencyMs: Date.now() - t0,
71 success: true,
72 metadata: {
73 branch: branchName,
74 repaired: repair.repaired,
75 repairs: repair.repairs.map((r) => ({
76 file: r.file,
77 type: r.type,
78 })),
79 },
80 });
2c34075Claude81 } catch (err) {
82 console.error(`[autorepair] error:`, err);
40e7738Claude83 logAiEvent({
84 actionType: "repair",
85 model: "auto-repair",
86 summary: `auto-repair failed on ${owner}/${repo}@${branchName}`,
87 repositoryId,
88 commitSha: ref.newSha.slice(0, 12),
89 latencyMs: Date.now() - t0,
90 success: false,
91 error: err instanceof Error ? err.message : String(err),
92 });
2c34075Claude93 }
fc1817aClaude94
2c34075Claude95 // 2. Push analysis
96 try {
97 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
98 console.log(
99 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
100 );
101 if (analysis.riskScore > 50) {
102 console.warn(
103 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
104 );
105 }
106 if (analysis.breakingChangeSignals.length > 0) {
107 console.warn(
108 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
109 );
110 }
111 } catch (err) {
112 console.error(`[push-analysis] error:`, err);
113 }
114
115 // 3. Health score (async, don't block)
116 computeHealthScore(owner, repo).then((report) => {
117 console.log(
118 `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)`
119 );
120 }).catch((err) => {
121 console.error(`[health] error:`, err);
122 });
123 }
124
0316dbbClaude125 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
126 // triggerGateTest helper is slated for the intelligence rework.
2c34075Claude127
40e7738Claude128 // 4b. Third-party integrations fanout (Slack/Linear/Vercel/Discord/...)
129 // Every configured integration that subscribes to "push" gets delivered.
130 if (repositoryId) {
131 void import("../lib/integrations")
132 .then((m) =>
133 m.deliverEvent(repositoryId!, "push", {
134 repository: `${owner}/${repo}`,
135 refs: refs.map((r) => ({
136 ref: r.refName,
137 before: r.oldSha,
138 after: r.newSha,
139 })),
140 })
141 )
142 .catch((e) => console.error("[integrations] push fanout failed:", e));
143 }
144
2c34075Claude145 // 5. Crontech deploy on push to main
fc1817aClaude146 const mainPush = refs.find(
147 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
148 );
40e7738Claude149 if (mainPush && repositoryId) {
150 triggerCrontechDeploy(owner, repo, mainPush.newSha, repositoryId).catch(
151 (err: unknown) => console.error(`[crontech] error:`, err)
152 );
fc1817aClaude153 }
154}
155
43cf9b0Claude156/**
157 * Trigger Crontech auto-deploy via the outbound webhook.
158 *
159 * Wire contract (Gluecron's copy — do not import from Crontech):
160 *
161 * POST https://crontech.ai/api/hooks/gluecron/push
162 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
163 * Content-Type: application/json
164 *
165 * {
166 * "repository": "owner/name",
167 * "sha": "<40-hex>",
168 * "branch": "main",
169 * "ref": "refs/heads/main",
170 * "source": "gluecron",
171 * "timestamp": "<ISO-8601>"
172 * }
173 *
174 * → 200 { ok: true, deploymentId, status: "queued" | "skipped" }
175 * → 401 invalid bearer token
176 * → 400 malformed payload
177 * → 404 repository not configured for auto-deploy on Crontech
178 *
179 * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization
180 * header — Crontech will then respond 401, which we treat as a failed deploy
181 * row exactly like any other non-ok HTTP response.
182 */
fc1817aClaude183async function triggerCrontechDeploy(
184 owner: string,
185 repo: string,
3ef4c9dClaude186 sha: string,
187 repositoryId: string
fc1817aClaude188): Promise<void> {
3ef4c9dClaude189 let deployId = "";
fc1817aClaude190 try {
3ef4c9dClaude191 const [row] = await db
192 .insert(deployments)
193 .values({
194 repositoryId,
195 environment: "production",
196 commitSha: sha,
197 ref: "refs/heads/main",
198 status: "pending",
199 target: "crontech",
200 })
201 .returning();
202 deployId = row?.id || "";
203 } catch {
204 /* ignore */
fc1817aClaude205 }
206
207 try {
43cf9b0Claude208 const headers: Record<string, string> = {
209 "Content-Type": "application/json",
210 };
211 if (config.gluecronWebhookSecret) {
212 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
213 }
fc1817aClaude214 const response = await fetch(config.crontechDeployUrl, {
215 method: "POST",
43cf9b0Claude216 headers,
fc1817aClaude217 body: JSON.stringify({
218 repository: `${owner}/${repo}`,
219 sha,
220 branch: "main",
43cf9b0Claude221 ref: "refs/heads/main",
fc1817aClaude222 source: "gluecron",
43cf9b0Claude223 timestamp: new Date().toISOString(),
fc1817aClaude224 }),
225 });
226 console.log(
227 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
228 );
3ef4c9dClaude229 if (deployId) {
230 await db
231 .update(deployments)
232 .set({
233 status: response.ok ? "success" : "failed",
234 completedAt: new Date(),
235 })
236 .where(eq(deployments.id, deployId));
237 }
1e162a8Claude238 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
239 // incident responder AFTER the deployment row is flipped to "failed".
240 if (!response.ok && deployId) {
241 void onDeployFailure({
242 repositoryId,
243 deploymentId: deployId,
244 ref: "refs/heads/main",
245 commitSha: sha,
246 target: "crontech",
247 errorMessage: `HTTP ${response.status}`,
248 }).catch((e) => console.error("[ai-incident]", e));
249 }
fc1817aClaude250 } catch (err) {
251 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude252 if (deployId) {
253 await db
254 .update(deployments)
255 .set({
256 status: "failed",
257 blockedReason: (err as Error).message,
258 completedAt: new Date(),
259 })
260 .where(eq(deployments.id, deployId));
1e162a8Claude261 // D4: fire-and-forget incident analysis AFTER marking the row failed.
262 void onDeployFailure({
263 repositoryId,
264 deploymentId: deployId,
265 ref: "refs/heads/main",
266 commitSha: sha,
267 target: "crontech",
268 errorMessage: (err as Error).message,
269 }).catch((e) => console.error("[ai-incident]", e));
3ef4c9dClaude270 }
271 }
272}
273
274async function fanoutWebhooks(
275 repositoryId: string,
276 owner: string,
277 repo: string,
278 refs: PushRef[]
279): Promise<void> {
280 try {
281 const { fireWebhooks } = await import("../routes/webhooks");
282 await fireWebhooks(repositoryId, "push", {
283 repository: `${owner}/${repo}`,
284 refs: refs.map((r) => ({
285 ref: r.refName,
286 before: r.oldSha,
287 after: r.newSha,
288 })),
289 });
290 } catch {
291 // best-effort
fc1817aClaude292 }
293}
43cf9b0Claude294
295/** Test-only access to internal helpers. */
296export const __test = { triggerCrontechDeploy };