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.tsBlame254 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";
a7361c0Claude16import { analyzePush } from "../lib/intelligence";
17import { computeAndStoreHealthScore } from "../lib/health-score";
0316dbbClaude18import { db } from "../db";
19import { deployments, repositories, users } from "../db/schema";
20import { onDeployFailure } from "../lib/ai-incident";
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> {
2c34075Claude33 for (const ref of refs) {
34 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
35 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude36
2c34075Claude37 // 1. Auto-repair (runs first, may create a new commit)
38 try {
39 const repair = await autoRepair(owner, repo, branchName);
40 if (repair.repaired) {
41 console.log(
42 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
43 );
44 }
45 } catch (err) {
46 console.error(`[autorepair] error:`, err);
47 }
fc1817aClaude48
2c34075Claude49 // 2. Push analysis
50 try {
51 const analysis = await analyzePush(owner, repo, ref.oldSha, ref.newSha);
52 console.log(
53 `[push-analysis] ${owner}/${repo}: ${analysis.summary}`
54 );
55 if (analysis.riskScore > 50) {
56 console.warn(
57 `[push-analysis] HIGH RISK push detected (score: ${analysis.riskScore})`
58 );
59 }
60 if (analysis.breakingChangeSignals.length > 0) {
61 console.warn(
62 `[push-analysis] Breaking changes: ${analysis.breakingChangeSignals.join("; ")}`
63 );
64 }
65 } catch (err) {
66 console.error(`[push-analysis] error:`, err);
67 }
68
a7361c0Claude69 // 3. Health score — compute from platform data and persist to DB (async, non-blocking)
70 // Repo ID is resolved later in the deploy block; do a lightweight lookup here.
71 db.select({ id: repositories.id })
72 .from(repositories)
73 .innerJoin(users, eq(repositories.ownerId, users.id))
74 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
75 .limit(1)
76 .then(([row]) => {
77 if (!row) return;
78 return computeAndStoreHealthScore(row.id, owner, repo);
79 })
80 .then((score) => {
81 if (score) console.log(`[health] ${owner}/${repo}: ${score.grade} (${score.score}/100)`);
82 })
83 .catch((err) => console.error(`[health] error:`, err));
2c34075Claude84 }
85
0316dbbClaude86 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
87 // triggerGateTest helper is slated for the intelligence rework.
2c34075Claude88
89 // 5. Crontech deploy on push to main
fc1817aClaude90 const mainPush = refs.find(
91 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
92 );
93 if (mainPush) {
0316dbbClaude94 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(owner, repo, mainPush.newSha, repositoryId).catch(
108 (err: unknown) => console.error(`[crontech] error:`, err),
109 );
110 }
fc1817aClaude111 }
112}
113
43cf9b0Claude114/**
115 * Trigger Crontech auto-deploy via the outbound webhook.
116 *
117 * Wire contract (Gluecron's copy — do not import from Crontech):
118 *
119 * POST https://crontech.ai/api/hooks/gluecron/push
120 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
121 * Content-Type: application/json
122 *
123 * {
124 * "repository": "owner/name",
125 * "sha": "<40-hex>",
126 * "branch": "main",
127 * "ref": "refs/heads/main",
128 * "source": "gluecron",
129 * "timestamp": "<ISO-8601>"
130 * }
131 *
132 * → 200 { ok: true, deploymentId, status: "queued" | "skipped" }
133 * → 401 invalid bearer token
134 * → 400 malformed payload
135 * → 404 repository not configured for auto-deploy on Crontech
136 *
137 * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization
138 * header — Crontech will then respond 401, which we treat as a failed deploy
139 * row exactly like any other non-ok HTTP response.
140 */
fc1817aClaude141async function triggerCrontechDeploy(
142 owner: string,
143 repo: string,
3ef4c9dClaude144 sha: string,
145 repositoryId: string
fc1817aClaude146): Promise<void> {
3ef4c9dClaude147 let deployId = "";
fc1817aClaude148 try {
3ef4c9dClaude149 const [row] = await db
150 .insert(deployments)
151 .values({
152 repositoryId,
153 environment: "production",
154 commitSha: sha,
155 ref: "refs/heads/main",
156 status: "pending",
157 target: "crontech",
158 })
159 .returning();
160 deployId = row?.id || "";
161 } catch {
162 /* ignore */
fc1817aClaude163 }
164
165 try {
43cf9b0Claude166 const headers: Record<string, string> = {
167 "Content-Type": "application/json",
168 };
169 if (config.gluecronWebhookSecret) {
170 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
171 }
fc1817aClaude172 const response = await fetch(config.crontechDeployUrl, {
173 method: "POST",
43cf9b0Claude174 headers,
fc1817aClaude175 body: JSON.stringify({
176 repository: `${owner}/${repo}`,
177 sha,
178 branch: "main",
43cf9b0Claude179 ref: "refs/heads/main",
fc1817aClaude180 source: "gluecron",
43cf9b0Claude181 timestamp: new Date().toISOString(),
fc1817aClaude182 }),
183 });
184 console.log(
185 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
186 );
3ef4c9dClaude187 if (deployId) {
188 await db
189 .update(deployments)
190 .set({
191 status: response.ok ? "success" : "failed",
192 completedAt: new Date(),
193 })
194 .where(eq(deployments.id, deployId));
195 }
1e162a8Claude196 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
197 // incident responder AFTER the deployment row is flipped to "failed".
198 if (!response.ok && deployId) {
199 void onDeployFailure({
200 repositoryId,
201 deploymentId: deployId,
202 ref: "refs/heads/main",
203 commitSha: sha,
204 target: "crontech",
205 errorMessage: `HTTP ${response.status}`,
206 }).catch((e) => console.error("[ai-incident]", e));
207 }
fc1817aClaude208 } catch (err) {
209 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude210 if (deployId) {
211 await db
212 .update(deployments)
213 .set({
214 status: "failed",
215 blockedReason: (err as Error).message,
216 completedAt: new Date(),
217 })
218 .where(eq(deployments.id, deployId));
1e162a8Claude219 // D4: fire-and-forget incident analysis AFTER marking the row failed.
220 void onDeployFailure({
221 repositoryId,
222 deploymentId: deployId,
223 ref: "refs/heads/main",
224 commitSha: sha,
225 target: "crontech",
226 errorMessage: (err as Error).message,
227 }).catch((e) => console.error("[ai-incident]", e));
3ef4c9dClaude228 }
229 }
230}
231
232async function fanoutWebhooks(
233 repositoryId: string,
234 owner: string,
235 repo: string,
236 refs: PushRef[]
237): Promise<void> {
238 try {
239 const { fireWebhooks } = await import("../routes/webhooks");
240 await fireWebhooks(repositoryId, "push", {
241 repository: `${owner}/${repo}`,
242 refs: refs.map((r) => ({
243 ref: r.refName,
244 before: r.oldSha,
245 after: r.newSha,
246 })),
247 });
248 } catch {
249 // best-effort
fc1817aClaude250 }
251}
43cf9b0Claude252
253/** Test-only access to internal helpers. */
254export const __test = { triggerCrontechDeploy };