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.
| fc1817a | 1 | /** |
| 2 | * Post-receive hook logic. | |
| 2c34075 | 3 | * |
| 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 | |
| fc1817a | 11 | */ |
| 12 | ||
| 3ef4c9d | 13 | import { and, eq } from "drizzle-orm"; |
| fc1817a | 14 | import { config } from "../lib/config"; |
| 2c34075 | 15 | import { autoRepair } from "../lib/autorepair"; |
| 16 | import { analyzePush, computeHealthScore } from "../lib/intelligence"; | |
| 0316dbb | 17 | import { db } from "../db"; |
| 18 | import { deployments, repositories, users } from "../db/schema"; | |
| 19 | import { onDeployFailure } from "../lib/ai-incident"; | |
| 40e7738 | 20 | import { logAiEvent } from "../lib/ai-flywheel"; |
| fc1817a | 21 | |
| 22 | interface PushRef { | |
| 23 | oldSha: string; | |
| 24 | newSha: string; | |
| 25 | refName: string; | |
| 26 | } | |
| 27 | ||
| 28 | export async function onPostReceive( | |
| 29 | owner: string, | |
| 30 | repo: string, | |
| 31 | refs: PushRef[] | |
| 32 | ): Promise<void> { | |
| 40e7738 | 33 | // 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 | ||
| 2c34075 | 47 | for (const ref of refs) { |
| 48 | if (ref.newSha.startsWith("0000")) continue; // Branch deletion | |
| 49 | const branchName = ref.refName.replace("refs/heads/", ""); | |
| fc1817a | 50 | |
| 40e7738 | 51 | // 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(); | |
| 2c34075 | 55 | 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 | } | |
| 40e7738 | 62 | 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 | }); | |
| 2c34075 | 81 | } catch (err) { |
| 82 | console.error(`[autorepair] error:`, err); | |
| 40e7738 | 83 | 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 | }); | |
| 2c34075 | 93 | } |
| fc1817a | 94 | |
| 2c34075 | 95 | // 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 | ||
| 0316dbb | 125 | // 4. GateTest scan — fire-and-forget via generic webhook; the standalone |
| 126 | // triggerGateTest helper is slated for the intelligence rework. | |
| 2c34075 | 127 | |
| 40e7738 | 128 | // 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 | ||
| 2c34075 | 145 | // 5. Crontech deploy on push to main |
| fc1817a | 146 | const mainPush = refs.find( |
| 147 | (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000") | |
| 148 | ); | |
| 40e7738 | 149 | if (mainPush && repositoryId) { |
| 150 | triggerCrontechDeploy(owner, repo, mainPush.newSha, repositoryId).catch( | |
| 151 | (err: unknown) => console.error(`[crontech] error:`, err) | |
| 152 | ); | |
| fc1817a | 153 | } |
| 154 | } | |
| 155 | ||
| 43cf9b0 | 156 | /** |
| 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 | */ | |
| fc1817a | 183 | async function triggerCrontechDeploy( |
| 184 | owner: string, | |
| 185 | repo: string, | |
| 3ef4c9d | 186 | sha: string, |
| 187 | repositoryId: string | |
| fc1817a | 188 | ): Promise<void> { |
| 3ef4c9d | 189 | let deployId = ""; |
| fc1817a | 190 | try { |
| 3ef4c9d | 191 | 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 */ | |
| fc1817a | 205 | } |
| 206 | ||
| 207 | try { | |
| 43cf9b0 | 208 | const headers: Record<string, string> = { |
| 209 | "Content-Type": "application/json", | |
| 210 | }; | |
| 211 | if (config.gluecronWebhookSecret) { | |
| 212 | headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`; | |
| 213 | } | |
| fc1817a | 214 | const response = await fetch(config.crontechDeployUrl, { |
| 215 | method: "POST", | |
| 43cf9b0 | 216 | headers, |
| fc1817a | 217 | body: JSON.stringify({ |
| 218 | repository: `${owner}/${repo}`, | |
| 219 | sha, | |
| 220 | branch: "main", | |
| 43cf9b0 | 221 | ref: "refs/heads/main", |
| fc1817a | 222 | source: "gluecron", |
| 43cf9b0 | 223 | timestamp: new Date().toISOString(), |
| fc1817a | 224 | }), |
| 225 | }); | |
| 226 | console.log( | |
| 227 | `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}` | |
| 228 | ); | |
| 3ef4c9d | 229 | 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 | } | |
| 1e162a8 | 238 | // 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 | } | |
| fc1817a | 250 | } catch (err) { |
| 251 | console.error(`[crontech] failed to trigger deploy:`, err); | |
| 3ef4c9d | 252 | 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)); | |
| 1e162a8 | 261 | // 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)); | |
| 3ef4c9d | 270 | } |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | async 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 | |
| fc1817a | 292 | } |
| 293 | } | |
| 43cf9b0 | 294 | |
| 295 | /** Test-only access to internal helpers. */ | |
| 296 | export const __test = { triggerCrontechDeploy }; |