Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

post-receive.tsBlame246 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";
fc1817aClaude20
21interface PushRef {
22 oldSha: string;
23 newSha: string;
24 refName: string;
25}
26
27export async function onPostReceive(
28 owner: string,
29 repo: string,
30 refs: PushRef[]
31): Promise<void> {
2c34075Claude32 for (const ref of refs) {
33 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
34 const branchName = ref.refName.replace("refs/heads/", "");
fc1817aClaude35
2c34075Claude36 // 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 }
fc1817aClaude47
2c34075Claude48 // 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
0316dbbClaude78 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
79 // triggerGateTest helper is slated for the intelligence rework.
2c34075Claude80
81 // 5. Crontech deploy on push to main
fc1817aClaude82 const mainPush = refs.find(
83 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
84 );
85 if (mainPush) {
0316dbbClaude86 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 }
fc1817aClaude103 }
104}
105
43cf9b0Claude106/**
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 */
fc1817aClaude133async function triggerCrontechDeploy(
134 owner: string,
135 repo: string,
3ef4c9dClaude136 sha: string,
137 repositoryId: string
fc1817aClaude138): Promise<void> {
3ef4c9dClaude139 let deployId = "";
fc1817aClaude140 try {
3ef4c9dClaude141 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 */
fc1817aClaude155 }
156
157 try {
43cf9b0Claude158 const headers: Record<string, string> = {
159 "Content-Type": "application/json",
160 };
161 if (config.gluecronWebhookSecret) {
162 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
163 }
fc1817aClaude164 const response = await fetch(config.crontechDeployUrl, {
165 method: "POST",
43cf9b0Claude166 headers,
fc1817aClaude167 body: JSON.stringify({
168 repository: `${owner}/${repo}`,
169 sha,
170 branch: "main",
43cf9b0Claude171 ref: "refs/heads/main",
fc1817aClaude172 source: "gluecron",
43cf9b0Claude173 timestamp: new Date().toISOString(),
fc1817aClaude174 }),
175 });
176 console.log(
177 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
178 );
3ef4c9dClaude179 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 }
1e162a8Claude188 // 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 }
fc1817aClaude200 } catch (err) {
201 console.error(`[crontech] failed to trigger deploy:`, err);
3ef4c9dClaude202 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));
1e162a8Claude211 // 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));
3ef4c9dClaude220 }
221 }
222}
223
224async 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
fc1817aClaude242 }
243}
43cf9b0Claude244
245/** Test-only access to internal helpers. */
246export const __test = { triggerCrontechDeploy };