CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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"; | |
| fc1817a | 17 | |
| 18 | interface PushRef { | |
| 19 | oldSha: string; | |
| 20 | newSha: string; | |
| 21 | refName: string; | |
| 22 | } | |
| 23 | ||
| 24 | export async function onPostReceive( | |
| 25 | owner: string, | |
| 26 | repo: string, | |
| 27 | refs: PushRef[] | |
| 28 | ): Promise<void> { | |
| 2c34075 | 29 | for (const ref of refs) { |
| 30 | if (ref.newSha.startsWith("0000")) continue; // Branch deletion | |
| 31 | const branchName = ref.refName.replace("refs/heads/", ""); | |
| fc1817a | 32 | |
| 2c34075 | 33 | // 1. Auto-repair (runs first, may create a new commit) |
| 34 | try { | |
| 35 | const repair = await autoRepair(owner, repo, branchName); | |
| 36 | if (repair.repaired) { | |
| 37 | console.log( | |
| 38 | `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed` | |
| 39 | ); | |
| 40 | } | |
| 41 | } catch (err) { | |
| 42 | console.error(`[autorepair] error:`, err); | |
| 43 | } | |
| fc1817a | 44 | |
| 2c34075 | 45 | // 2. Push analysis |
| 46 | try { | |
| 47 | const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha); | |
| 48 | console.log( | |
| 49 | `[push-analysis] ${owner}/${repo}: ${analysis.summary}` | |
| 50 | ); | |
| 51 | if (analysis.riskScore > 50) { | |
| 52 | console.warn( | |
| 53 | `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})` | |
| 54 | ); | |
| 55 | } | |
| 56 | if (analysis.breakingChangeSignals.length > 0) { | |
| 57 | console.warn( | |
| 58 | `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}` | |
| 59 | ); | |
| 60 | } | |
| 61 | } catch (err) { | |
| 62 | console.error(`[push-analysis] error:`, err); | |
| 63 | } | |
| 64 | ||
| 65 | // 3. Health score (async, don't block) | |
| 66 | computeHealthScore(owner, repo).then((report) => { | |
| 67 | console.log( | |
| 68 | `[health] ${owner}/${repo}: ${report.grade} (${report.score}/100)` | |
| 69 | ); | |
| 70 | }).catch((err) => { | |
| 71 | console.error(`[health] error:`, err); | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | // 4. GateTest scan | |
| 76 | triggerGateTest(owner, repo, refs).catch((err) => | |
| 77 | console.error(`[gatetest] error:`, err) | |
| 78 | ); | |
| 79 | ||
| 80 | // 5. Crontech deploy on push to main | |
| fc1817a | 81 | const mainPush = refs.find( |
| 82 | (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000") | |
| 83 | ); | |
| 84 | if (mainPush) { | |
| 2c34075 | 85 | triggerCrontechDeploy(owner, repo, mainPush.newSha).catch((err) => |
| 86 | console.error(`[crontech] error:`, err) | |
| 87 | ); | |
| fc1817a | 88 | } |
| 89 | } | |
| 90 | ||
| 43cf9b0 | 91 | /** |
| 92 | * Trigger Crontech auto-deploy via the outbound webhook. | |
| 93 | * | |
| 94 | * Wire contract (Gluecron's copy — do not import from Crontech): | |
| 95 | * | |
| 96 | * POST https://crontech.ai/api/hooks/gluecron/push | |
| 97 | * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET} | |
| 98 | * Content-Type: application/json | |
| 99 | * | |
| 100 | * { | |
| 101 | * "repository": "owner/name", | |
| 102 | * "sha": "<40-hex>", | |
| 103 | * "branch": "main", | |
| 104 | * "ref": "refs/heads/main", | |
| 105 | * "source": "gluecron", | |
| 106 | * "timestamp": "<ISO-8601>" | |
| 107 | * } | |
| 108 | * | |
| 109 | * → 200 { ok: true, deploymentId, status: "queued" | "skipped" } | |
| 110 | * → 401 invalid bearer token | |
| 111 | * → 400 malformed payload | |
| 112 | * → 404 repository not configured for auto-deploy on Crontech | |
| 113 | * | |
| 114 | * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization | |
| 115 | * header — Crontech will then respond 401, which we treat as a failed deploy | |
| 116 | * row exactly like any other non-ok HTTP response. | |
| 117 | */ | |
| fc1817a | 118 | async function triggerCrontechDeploy( |
| 119 | owner: string, | |
| 120 | repo: string, | |
| 3ef4c9d | 121 | sha: string, |
| 122 | repositoryId: string | |
| fc1817a | 123 | ): Promise<void> { |
| 3ef4c9d | 124 | let deployId = ""; |
| fc1817a | 125 | try { |
| 3ef4c9d | 126 | const [row] = await db |
| 127 | .insert(deployments) | |
| 128 | .values({ | |
| 129 | repositoryId, | |
| 130 | environment: "production", | |
| 131 | commitSha: sha, | |
| 132 | ref: "refs/heads/main", | |
| 133 | status: "pending", | |
| 134 | target: "crontech", | |
| 135 | }) | |
| 136 | .returning(); | |
| 137 | deployId = row?.id || ""; | |
| 138 | } catch { | |
| 139 | /* ignore */ | |
| fc1817a | 140 | } |
| 141 | ||
| 142 | try { | |
| 43cf9b0 | 143 | const headers: Record<string, string> = { |
| 144 | "Content-Type": "application/json", | |
| 145 | }; | |
| 146 | if (config.gluecronWebhookSecret) { | |
| 147 | headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`; | |
| 148 | } | |
| fc1817a | 149 | const response = await fetch(config.crontechDeployUrl, { |
| 150 | method: "POST", | |
| 43cf9b0 | 151 | headers, |
| fc1817a | 152 | body: JSON.stringify({ |
| 153 | repository: `${owner}/${repo}`, | |
| 154 | sha, | |
| 155 | branch: "main", | |
| 43cf9b0 | 156 | ref: "refs/heads/main", |
| fc1817a | 157 | source: "gluecron", |
| 43cf9b0 | 158 | timestamp: new Date().toISOString(), |
| fc1817a | 159 | }), |
| 160 | }); | |
| 161 | console.log( | |
| 162 | `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}` | |
| 163 | ); | |
| 3ef4c9d | 164 | if (deployId) { |
| 165 | await db | |
| 166 | .update(deployments) | |
| 167 | .set({ | |
| 168 | status: response.ok ? "success" : "failed", | |
| 169 | completedAt: new Date(), | |
| 170 | }) | |
| 171 | .where(eq(deployments.id, deployId)); | |
| 172 | } | |
| 1e162a8 | 173 | // D4: when Crontech returns a non-ok HTTP status, kick off the AI |
| 174 | // incident responder AFTER the deployment row is flipped to "failed". | |
| 175 | if (!response.ok && deployId) { | |
| 176 | void onDeployFailure({ | |
| 177 | repositoryId, | |
| 178 | deploymentId: deployId, | |
| 179 | ref: "refs/heads/main", | |
| 180 | commitSha: sha, | |
| 181 | target: "crontech", | |
| 182 | errorMessage: `HTTP ${response.status}`, | |
| 183 | }).catch((e) => console.error("[ai-incident]", e)); | |
| 184 | } | |
| fc1817a | 185 | } catch (err) { |
| 186 | console.error(`[crontech] failed to trigger deploy:`, err); | |
| 3ef4c9d | 187 | if (deployId) { |
| 188 | await db | |
| 189 | .update(deployments) | |
| 190 | .set({ | |
| 191 | status: "failed", | |
| 192 | blockedReason: (err as Error).message, | |
| 193 | completedAt: new Date(), | |
| 194 | }) | |
| 195 | .where(eq(deployments.id, deployId)); | |
| 1e162a8 | 196 | // D4: fire-and-forget incident analysis AFTER marking the row failed. |
| 197 | void onDeployFailure({ | |
| 198 | repositoryId, | |
| 199 | deploymentId: deployId, | |
| 200 | ref: "refs/heads/main", | |
| 201 | commitSha: sha, | |
| 202 | target: "crontech", | |
| 203 | errorMessage: (err as Error).message, | |
| 204 | }).catch((e) => console.error("[ai-incident]", e)); | |
| 3ef4c9d | 205 | } |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | async function fanoutWebhooks( | |
| 210 | repositoryId: string, | |
| 211 | owner: string, | |
| 212 | repo: string, | |
| 213 | refs: PushRef[] | |
| 214 | ): Promise<void> { | |
| 215 | try { | |
| 216 | const { fireWebhooks } = await import("../routes/webhooks"); | |
| 217 | await fireWebhooks(repositoryId, "push", { | |
| 218 | repository: `${owner}/${repo}`, | |
| 219 | refs: refs.map((r) => ({ | |
| 220 | ref: r.refName, | |
| 221 | before: r.oldSha, | |
| 222 | after: r.newSha, | |
| 223 | })), | |
| 224 | }); | |
| 225 | } catch { | |
| 226 | // best-effort | |
| fc1817a | 227 | } |
| 228 | } | |
| 43cf9b0 | 229 | |
| 230 | /** Test-only access to internal helpers. */ | |
| 231 | export const __test = { triggerCrontechDeploy }; |