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