Commit40e7738unknown_key
feat(K1): AI flywheel + live activity dashboard + integrations registry
feat(K1): AI flywheel + live activity dashboard + integrations registry
Three pieces of the same loop:
1. AI flywheel (src/lib/ai-flywheel.ts + drizzle/0038)
recordAi(meta, fn) wraps every AI call, persists model/latency/success
into ai_activity, and publishes SSE events on `ai:global` and
`ai:repo:<id>`. Wired into ai-completion, ai-incident, ai-explain,
ai-tests, and the four ai-generators (commit msg, PR summary,
changelog, triage). Telemetry never throws into the request path.
2. Live AI dashboard (src/routes/ai-live.tsx)
GET /ai/live renders the most recent 50 events + a 24h rollup-by-action
card, then streams new events in via EventSource. Visual proof that AI
is processing every push, PR, and completion. Public read; only
summaries cross the boundary, never prompts.
3. Integrations registry (src/lib/integrations.ts + src/routes/integrations.tsx)
Per-repo connectors for Slack, Discord, Linear, Vercel, Jira,
PagerDuty (Events V2), Sentry, Datadog (events API), Figma, Cursor,
plus a generic HMAC-signed webhook. deliverEvent() fans out on push
from the post-receive hook; deliverOne() powers the "send test"
button. Owner-only CRUD at /:owner/:repo/settings/integrations,
gated via requireRepoAccess("admin"); secrets redacted on read.
Other changes:
- MODEL_SONNET defaults to claude-sonnet-4-6 (env-overridable). Hardcoded
references in ai-review and merge-resolver routed through the constant.
- post-receive emits a `repair` flywheel event on every push (clean or
repaired or failed) so the live dashboard always has a heartbeat.
- live-events TOPIC_RE extended to allow multi-segment ids
(`ai:repo:<uuid>`).
- Layout adds an "AI Live" nav link; repo settings links to Integrations.
Tests: 1057/1057 pass (37 new — integrations + flywheel pure helpers).22 files changed+2475−8040e77384fcdf33b93701bdd28ad23e6110eeaea5
22 changed files+2475−80
ModifiedBUILD_BIBLE.md+10−0View fileUnifiedSplit
@@ -341,6 +341,16 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
341341- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
342342- `drizzle/0032_repo_rulesets.sql` (Block J6) — migration, never edited in place. Adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum) + `ruleset_rules` (JSON params).
343343- `drizzle/0033_commit_statuses.sql` (Block J8) — migration, never edited in place. Adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error).
344- `drizzle/0038_ai_flywheel_and_integrations.sql` (AI flywheel + integrations) — migration, never edited in place. Adds `ai_activity` (telemetry per AI invocation, indexed by created_at / repo / user / action_type), `integrations` (per-repo third-party connectors with `kind` + JSON `config`/`events`), `integration_deliveries` (per-call audit trail).
345
346### 4.10 AI flywheel + integrations (locked)
347- `src/lib/ai-client.ts` — `MODEL_SONNET = "claude-sonnet-4-6"` (env-overridable). Owner-mandated. Do not downgrade without permission.
348- `src/lib/ai-flywheel.ts` — `recordAi(meta, fn)` wraps any AI call to persist telemetry into `ai_activity` and publish to SSE topics `ai:global` + `ai:repo:<id>`. `logAiEvent(meta)` for non-wrapped (cache/auto-repair) events. `listRecentAiEvents`, `rollupByAction`. NEVER throws into request path. `__test = { redact, clamp, clampInt }`.
349- `src/lib/integrations.ts` — third-party connector registry. Pure helpers: `CONNECTORS`, `INTEGRATION_KINDS`, `INTEGRATION_EVENTS`, `validateConfig`, `redactConfig`, `render`, `isHttpUrl`, `hmacHex`, `summary`. DB helpers: `createIntegration`, `updateIntegration`, `deleteIntegration`, `listForRepo`, `getById`, `listDeliveries`. Delivery: `deliverEvent(repoId, event, payload)` + `deliverOne(integration, event, payload)` — both never throw, both record into `integration_deliveries`. `__test` exposes pure helpers.
350- `src/routes/ai-live.tsx` — public `GET /ai/live` renders the live AI activity dashboard (server-rendered initial 50 + EventSource on `/live-events/ai:global` for streaming additions). `GET /api/ai/activity` returns the same recent-events list as JSON.
351- `src/routes/integrations.tsx` — owner-only CRUD at `/:owner/:repo/settings/integrations` (list, add, toggle, delete, send test). All mutations gated via `requireRepoAccess("admin")`. Round-trips secrets through `redactConfig`.
352- `src/routes/live-events.ts` (extended) — `TOPIC_RE` now allows colons in the id portion so multi-segment topics like `ai:repo:<uuid>` work. Topic kind is still the lowercase prefix.
353- `src/hooks/post-receive.ts` (extended) — resolves `repositoryId` once, fans out `integrations.deliverEvent(..., "push", ...)`, and emits a `repair` flywheel event for every push (success/failure both visible on `/ai/live`).
344354
345355### 4.2 Git layer (locked)
346356- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
Addeddrizzle/0038_ai_flywheel_and_integrations.sql+88−0View fileUnifiedSplit
@@ -0,0 +1,88 @@
1-- AI flywheel + third-party integrations registry.
2--
3-- Two strictly-additive tables:
4--
5-- ai_activity Telemetry for every AI invocation in the platform.
6-- Powers the live dashboard at /ai/live, the per-repo
7-- "AI in action" panel, and future learning loops
8-- (cost tracking, prompt regression, repair patterns).
9-- Insert-only; trimmed by retention policy at app level.
10--
11-- integrations Third-party product connectors (Slack / Linear /
12-- Vercel / Discord / Jira / PagerDuty / Sentry /
13-- Datadog / Figma / Cursor). Repo-scoped for v1.
14-- `config` is a JSON blob with the connector-specific
15-- fields (webhook URL, channel, project key, etc).
16-- Secret components live in dedicated columns so we
17-- can rotate / redact without parsing JSON.
18
19CREATE TABLE IF NOT EXISTS "ai_activity" (
20 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
21 "action_type" text NOT NULL, -- 'review' | 'repair' | 'completion' | 'incident' | 'triage' | 'explain' | 'test' | 'changelog' | 'chat' | 'spec' | ...
22 "model" text NOT NULL, -- model id at invocation time
23 "repository_id" uuid REFERENCES "repositories"("id") ON DELETE CASCADE,
24 "user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
25 "pull_request_id" uuid REFERENCES "pull_requests"("id") ON DELETE SET NULL,
26 "issue_id" uuid REFERENCES "issues"("id") ON DELETE SET NULL,
27 "commit_sha" text, -- optional anchor for repair / review
28 "summary" text NOT NULL, -- short human-readable line
29 "input_tokens" integer, -- nullable when not reported by SDK
30 "output_tokens" integer,
31 "latency_ms" integer NOT NULL,
32 "success" boolean NOT NULL DEFAULT true,
33 "error" text, -- redacted message when success=false
34 "metadata" jsonb, -- free-form (file paths, repair counts, etc.)
35 "created_at" timestamp NOT NULL DEFAULT now()
36);
37
38CREATE INDEX IF NOT EXISTS "ai_activity_created_idx"
39 ON "ai_activity" ("created_at" DESC);
40
41CREATE INDEX IF NOT EXISTS "ai_activity_repo_idx"
42 ON "ai_activity" ("repository_id", "created_at" DESC);
43
44CREATE INDEX IF NOT EXISTS "ai_activity_user_idx"
45 ON "ai_activity" ("user_id", "created_at" DESC);
46
47CREATE INDEX IF NOT EXISTS "ai_activity_action_idx"
48 ON "ai_activity" ("action_type", "created_at" DESC);
49
50
51CREATE TABLE IF NOT EXISTS "integrations" (
52 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
53 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
54 "kind" text NOT NULL, -- 'slack' | 'linear' | 'vercel' | 'discord' | 'jira' | 'pagerduty' | 'sentry' | 'datadog' | 'figma' | 'cursor' | 'generic_webhook'
55 "name" text NOT NULL, -- user label, e.g. "Eng channel"
56 "enabled" boolean NOT NULL DEFAULT true,
57 "config" jsonb NOT NULL DEFAULT '{}'::jsonb,
58 "events" jsonb NOT NULL DEFAULT '[]'::jsonb, -- array of event kinds to forward
59 "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
60 "created_at" timestamp NOT NULL DEFAULT now(),
61 "updated_at" timestamp NOT NULL DEFAULT now(),
62 "last_delivery_at" timestamp,
63 "last_status" text -- 'ok' | 'fail' | NULL (never delivered)
64);
65
66CREATE INDEX IF NOT EXISTS "integrations_repo_idx"
67 ON "integrations" ("repository_id");
68
69CREATE INDEX IF NOT EXISTS "integrations_kind_idx"
70 ON "integrations" ("kind");
71
72CREATE UNIQUE INDEX IF NOT EXISTS "integrations_repo_name_unique"
73 ON "integrations" ("repository_id", "name");
74
75
76CREATE TABLE IF NOT EXISTS "integration_deliveries" (
77 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
78 "integration_id" uuid NOT NULL REFERENCES "integrations"("id") ON DELETE CASCADE,
79 "event" text NOT NULL,
80 "status" text NOT NULL, -- 'ok' | 'fail' | 'skipped'
81 "http_status" integer,
82 "error" text,
83 "duration_ms" integer NOT NULL DEFAULT 0,
84 "created_at" timestamp NOT NULL DEFAULT now()
85);
86
87CREATE INDEX IF NOT EXISTS "integration_deliveries_integration_idx"
88 ON "integration_deliveries" ("integration_id", "created_at" DESC);
Addedsrc/__tests__/ai-flywheel.test.ts+58−0View fileUnifiedSplit
@@ -0,0 +1,58 @@
1/**
2 * Pure-function tests for the AI flywheel layer.
3 * recordAi/persist exercise the DB; covered by integration smoke elsewhere.
4 */
5
6import { describe, it, expect } from "bun:test";
7import { __test } from "../lib/ai-flywheel";
8
9const { redact, clamp, clampInt } = __test;
10
11describe("ai-flywheel — redact", () => {
12 it("strips bearer tokens", () => {
13 const out = redact("Authorization: Bearer abcdef.ghijkl");
14 expect(out).toContain("[REDACTED]");
15 expect(out).not.toContain("abcdef");
16 });
17
18 it("strips Anthropic-style sk- keys", () => {
19 const out = redact("error sk-ant-api01-AAAA-BBBB-CCCC");
20 expect(out).toContain("[REDACTED]");
21 });
22
23 it("strips gluecron PATs", () => {
24 const out = redact("token glc_abcd1234efgh5678 leaked");
25 expect(out).toContain("[REDACTED]");
26 });
27
28 it("returns the input unchanged when nothing matches", () => {
29 const out = redact("plain message with no secret");
30 expect(out).toBe("plain message with no secret");
31 });
32});
33
34describe("ai-flywheel — clamp", () => {
35 it("returns input untouched when short", () => {
36 expect(clamp("hello", 100)).toBe("hello");
37 });
38 it("truncates with ellipsis when too long", () => {
39 const result = clamp("a".repeat(50), 10);
40 expect(result.length).toBe(10);
41 expect(result.endsWith("…")).toBe(true);
42 });
43});
44
45describe("ai-flywheel — clampInt", () => {
46 it("clamps below lo", () => {
47 expect(clampInt(-5, 1, 100)).toBe(1);
48 });
49 it("clamps above hi", () => {
50 expect(clampInt(500, 1, 100)).toBe(100);
51 });
52 it("floors fractional", () => {
53 expect(clampInt(2.7, 1, 100)).toBe(2);
54 });
55 it("passes through valid values", () => {
56 expect(clampInt(42, 1, 100)).toBe(42);
57 });
58});
Addedsrc/__tests__/integrations.test.ts+270−0View fileUnifiedSplit
@@ -0,0 +1,270 @@
1/**
2 * Pure-function tests for the integrations connector layer.
3 * Covers config validation, redaction, and per-connector renderers.
4 * No DB calls — that surface is covered by route smoke tests downstream.
5 */
6
7import { describe, it, expect } from "bun:test";
8import {
9 CONNECTORS,
10 INTEGRATION_KINDS,
11 INTEGRATION_EVENTS,
12 __test,
13 getConnector,
14 isHttpUrl,
15 isValidEvent,
16 isValidKind,
17 redactConfig,
18 validateConfig,
19} from "../lib/integrations";
20
21const { render, summary, hmacHex } = __test;
22
23describe("integrations — registry shape", () => {
24 it("exposes one connector per declared kind", () => {
25 for (const kind of INTEGRATION_KINDS) {
26 expect(getConnector(kind)).toBeTruthy();
27 }
28 });
29
30 it("validates kind + event whitelists", () => {
31 expect(isValidKind("slack")).toBe(true);
32 expect(isValidKind("not-a-thing")).toBe(false);
33 expect(isValidEvent("push")).toBe(true);
34 expect(isValidEvent("nope")).toBe(false);
35 });
36
37 it("CONNECTORS list matches INTEGRATION_KINDS", () => {
38 const kinds = new Set(CONNECTORS.map((c) => c.kind));
39 for (const k of INTEGRATION_KINDS) expect(kinds.has(k)).toBe(true);
40 });
41});
42
43describe("integrations — isHttpUrl", () => {
44 it("accepts http(s) only", () => {
45 expect(isHttpUrl("https://example.com")).toBe(true);
46 expect(isHttpUrl("http://example.com")).toBe(true);
47 expect(isHttpUrl("ftp://example.com")).toBe(false);
48 expect(isHttpUrl("not-a-url")).toBe(false);
49 expect(isHttpUrl("")).toBe(false);
50 });
51});
52
53describe("integrations — validateConfig", () => {
54 it("flags missing required fields", () => {
55 const r = validateConfig("slack", {});
56 expect(r.ok).toBe(false);
57 expect(r.error).toContain("Incoming webhook URL");
58 });
59
60 it("flags non-URL values for URL fields", () => {
61 const r = validateConfig("slack", { webhookUrl: "not-a-url" });
62 expect(r.ok).toBe(false);
63 expect(r.error).toContain("URL");
64 });
65
66 it("accepts a valid Slack config", () => {
67 const r = validateConfig("slack", {
68 webhookUrl: "https://hooks.slack.com/services/xxx",
69 });
70 expect(r.ok).toBe(true);
71 });
72
73 it("rejects unknown kind", () => {
74 const r = validateConfig("not-a-kind" as never, {});
75 expect(r.ok).toBe(false);
76 });
77
78 it("PagerDuty requires only the routing key (no URL)", () => {
79 expect(
80 validateConfig("pagerduty", { integrationKey: "abc123" }).ok
81 ).toBe(true);
82 expect(validateConfig("pagerduty", {}).ok).toBe(false);
83 });
84});
85
86describe("integrations — redactConfig", () => {
87 it("redacts secret fields", () => {
88 const out = redactConfig("slack", {
89 webhookUrl: "https://hooks.slack.com/services/T0/B0/abc123",
90 channel: "#eng",
91 });
92 expect(typeof out.webhookUrl).toBe("string");
93 expect(String(out.webhookUrl)).toContain("…");
94 expect(out.channel).toBe("#eng");
95 });
96
97 it("returns short-form for very short secrets", () => {
98 const out = redactConfig("slack", { webhookUrl: "abcd" });
99 expect(String(out.webhookUrl)).toBe("***");
100 });
101
102 it("ignores unknown fields", () => {
103 const out = redactConfig("slack", { mystery: "value" });
104 expect(out.mystery).toBeUndefined();
105 });
106});
107
108describe("integrations — summary helper", () => {
109 it("formats per-event human strings", () => {
110 expect(summary("push", { repository: "a/b" })).toContain("Push to a/b");
111 expect(summary("pr.opened", { repository: "a/b", title: "T" })).toContain(
112 "PR opened"
113 );
114 expect(summary("deploy.failed", { repository: "a/b" })).toContain("FAILED");
115 });
116});
117
118describe("integrations — render Slack", () => {
119 it("returns null on missing webhook", () => {
120 expect(
121 render("slack", {}, "push", { repository: "a/b" })
122 ).toBeNull();
123 });
124 it("renders a JSON Slack body", () => {
125 const r = render(
126 "slack",
127 { webhookUrl: "https://hooks.slack.com/services/abc" },
128 "push",
129 { repository: "a/b" }
130 )!;
131 expect(r.url).toContain("hooks.slack.com");
132 expect(r.headers["Content-Type"]).toBe("application/json");
133 expect(JSON.parse(r.body).text).toContain("Push to a/b");
134 });
135 it("includes channel override when provided", () => {
136 const r = render(
137 "slack",
138 {
139 webhookUrl: "https://hooks.slack.com/services/abc",
140 channel: "#eng",
141 },
142 "push",
143 { repository: "a/b" }
144 )!;
145 expect(JSON.parse(r.body).channel).toBe("#eng");
146 });
147});
148
149describe("integrations — render Discord", () => {
150 it("renders a content payload", () => {
151 const r = render(
152 "discord",
153 { webhookUrl: "https://discord.com/api/webhooks/x/y" },
154 "pr.opened",
155 { repository: "a/b", title: "PR" }
156 )!;
157 expect(JSON.parse(r.body).content).toContain("PR opened");
158 });
159});
160
161describe("integrations — render Vercel", () => {
162 it("only fires on push or deploy.success", () => {
163 expect(
164 render(
165 "vercel",
166 { deployHookUrl: "https://api.vercel.com/v1/integrations/deploy/x" },
167 "issue.opened",
168 { repository: "a/b" }
169 )
170 ).toBeNull();
171 const r = render(
172 "vercel",
173 { deployHookUrl: "https://api.vercel.com/v1/integrations/deploy/x" },
174 "push",
175 { repository: "a/b" }
176 )!;
177 expect(r.url).toContain("vercel.com");
178 expect(r.body).toBe("");
179 });
180});
181
182describe("integrations — render PagerDuty", () => {
183 it("only escalates failures", () => {
184 expect(
185 render("pagerduty", { integrationKey: "k" }, "push", { repository: "a/b" })
186 ).toBeNull();
187 const r = render("pagerduty", { integrationKey: "k" }, "deploy.failed", {
188 repository: "a/b",
189 })!;
190 const body = JSON.parse(r.body);
191 expect(body.routing_key).toBe("k");
192 expect(body.event_action).toBe("trigger");
193 expect(body.payload.severity).toBe("error");
194 });
195 it("respects user-set severity", () => {
196 const r = render(
197 "pagerduty",
198 { integrationKey: "k", severity: "critical" },
199 "ai.incident",
200 { repository: "a/b", title: "down" }
201 )!;
202 expect(JSON.parse(r.body).payload.severity).toBe("critical");
203 });
204});
205
206describe("integrations — render Datadog", () => {
207 it("requires apiKey, defaults site, alert_type maps from event", () => {
208 expect(render("datadog", {}, "push", { repository: "a/b" })).toBeNull();
209 const r = render("datadog", { apiKey: "k" }, "deploy.failed", {
210 repository: "a/b",
211 })!;
212 expect(r.url).toContain("api.datadoghq.com");
213 expect(r.headers["DD-API-KEY"]).toBe("k");
214 expect(JSON.parse(r.body).alert_type).toBe("error");
215 });
216});
217
218describe("integrations — render generic_webhook", () => {
219 it("requires a URL", () => {
220 expect(
221 render("generic_webhook", {}, "push", { repository: "a/b" })
222 ).toBeNull();
223 });
224 it("signs body when secret is set", () => {
225 const r = render(
226 "generic_webhook",
227 {
228 webhookUrl: "https://example.com/hook",
229 secret: "shh",
230 },
231 "push",
232 { repository: "a/b" }
233 )!;
234 expect(r.headers["X-Gluecron-Signature"]).toContain("sha256=");
235 });
236 it("omits signature without secret", () => {
237 const r = render(
238 "generic_webhook",
239 { webhookUrl: "https://example.com/hook" },
240 "push",
241 { repository: "a/b" }
242 )!;
243 expect(r.headers["X-Gluecron-Signature"]).toBeUndefined();
244 });
245});
246
247describe("integrations — hmacHex", () => {
248 it("produces deterministic sha256 digests", () => {
249 const a = hmacHex("k", "body");
250 const b = hmacHex("k", "body");
251 expect(a).toBe(b);
252 expect(a).toMatch(/^sha256=[0-9a-f]{64}$/);
253 });
254});
255
256describe("integrations — INTEGRATION_EVENTS shape", () => {
257 it("includes the canonical lifecycle events", () => {
258 for (const ev of [
259 "push",
260 "pr.opened",
261 "pr.merged",
262 "deploy.success",
263 "deploy.failed",
264 "ai.repair",
265 "ai.incident",
266 ]) {
267 expect(INTEGRATION_EVENTS).toContain(ev as never);
268 }
269 });
270});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -58,7 +58,9 @@ import adminRoutes from "./routes/admin";
5858import advisoriesRoutes from "./routes/advisories";
5959import aiChangelogRoutes from "./routes/ai-changelog";
6060import aiExplainRoutes from "./routes/ai-explain";
61import aiLiveRoutes from "./routes/ai-live";
6162import aiTestsRoutes from "./routes/ai-tests";
63import integrationsRoutes from "./routes/integrations";
6264import askRoutes from "./routes/ask";
6365import billingRoutes from "./routes/billing";
6466import codeScanningRoutes from "./routes/code-scanning";
@@ -268,7 +270,9 @@ app.route("/", adminRoutes);
268270app.route("/", advisoriesRoutes);
269271app.route("/", aiChangelogRoutes);
270272app.route("/", aiExplainRoutes);
273app.route("/", aiLiveRoutes);
271274app.route("/", aiTestsRoutes);
275app.route("/", integrationsRoutes);
272276app.route("/", askRoutes);
273277app.route("/", billingRoutes);
274278app.route("/", codeScanningRoutes);
Modifiedsrc/db/schema.ts+103−0View fileUnifiedSplit
@@ -2575,3 +2575,106 @@ export type WorkflowRunnerPoolEntry =
25752575 typeof workflowRunnerPool.$inferSelect;
25762576export type NewWorkflowRunnerPoolEntry =
25772577 typeof workflowRunnerPool.$inferInsert;
2578
2579// ---------------------------------------------------------------------------
2580// AI flywheel + integrations registry (drizzle/0038_ai_flywheel_and_integrations.sql)
2581//
2582// Strictly additive. The flywheel records every AI invocation so we can render
2583// it live (/ai/live), surface per-repo "AI in action" panels, and feed future
2584// learning loops. Integrations are repo-scoped third-party connectors.
2585// ---------------------------------------------------------------------------
2586
2587export const aiActivity = pgTable(
2588 "ai_activity",
2589 {
2590 id: uuid("id").primaryKey().defaultRandom(),
2591 actionType: text("action_type").notNull(),
2592 model: text("model").notNull(),
2593 repositoryId: uuid("repository_id").references(() => repositories.id, {
2594 onDelete: "cascade",
2595 }),
2596 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
2597 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
2598 onDelete: "set null",
2599 }),
2600 issueId: uuid("issue_id").references(() => issues.id, {
2601 onDelete: "set null",
2602 }),
2603 commitSha: text("commit_sha"),
2604 summary: text("summary").notNull(),
2605 inputTokens: integer("input_tokens"),
2606 outputTokens: integer("output_tokens"),
2607 latencyMs: integer("latency_ms").notNull(),
2608 success: boolean("success").default(true).notNull(),
2609 error: text("error"),
2610 metadata: jsonb("metadata"),
2611 createdAt: timestamp("created_at").defaultNow().notNull(),
2612 },
2613 (table) => [
2614 index("ai_activity_created_idx").on(table.createdAt),
2615 index("ai_activity_repo_idx").on(table.repositoryId, table.createdAt),
2616 index("ai_activity_user_idx").on(table.userId, table.createdAt),
2617 index("ai_activity_action_idx").on(table.actionType, table.createdAt),
2618 ]
2619);
2620
2621export type AiActivity = typeof aiActivity.$inferSelect;
2622export type NewAiActivity = typeof aiActivity.$inferInsert;
2623
2624export const integrations = pgTable(
2625 "integrations",
2626 {
2627 id: uuid("id").primaryKey().defaultRandom(),
2628 repositoryId: uuid("repository_id")
2629 .notNull()
2630 .references(() => repositories.id, { onDelete: "cascade" }),
2631 kind: text("kind").notNull(),
2632 name: text("name").notNull(),
2633 enabled: boolean("enabled").default(true).notNull(),
2634 config: jsonb("config").default({}).notNull(),
2635 events: jsonb("events").default([]).notNull(),
2636 createdBy: uuid("created_by").references(() => users.id, {
2637 onDelete: "set null",
2638 }),
2639 createdAt: timestamp("created_at").defaultNow().notNull(),
2640 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2641 lastDeliveryAt: timestamp("last_delivery_at"),
2642 lastStatus: text("last_status"),
2643 },
2644 (table) => [
2645 index("integrations_repo_idx").on(table.repositoryId),
2646 index("integrations_kind_idx").on(table.kind),
2647 uniqueIndex("integrations_repo_name_unique").on(
2648 table.repositoryId,
2649 table.name
2650 ),
2651 ]
2652);
2653
2654export type Integration = typeof integrations.$inferSelect;
2655export type NewIntegration = typeof integrations.$inferInsert;
2656
2657export const integrationDeliveries = pgTable(
2658 "integration_deliveries",
2659 {
2660 id: uuid("id").primaryKey().defaultRandom(),
2661 integrationId: uuid("integration_id")
2662 .notNull()
2663 .references(() => integrations.id, { onDelete: "cascade" }),
2664 event: text("event").notNull(),
2665 status: text("status").notNull(),
2666 httpStatus: integer("http_status"),
2667 error: text("error"),
2668 durationMs: integer("duration_ms").default(0).notNull(),
2669 createdAt: timestamp("created_at").defaultNow().notNull(),
2670 },
2671 (table) => [
2672 index("integration_deliveries_integration_idx").on(
2673 table.integrationId,
2674 table.createdAt
2675 ),
2676 ]
2677);
2678
2679export type IntegrationDelivery = typeof integrationDeliveries.$inferSelect;
2680export type NewIntegrationDelivery = typeof integrationDeliveries.$inferInsert;
Modifiedsrc/hooks/post-receive.ts+69−19View fileUnifiedSplit
@@ -17,6 +17,7 @@ import { analyzePush, computeHealthScore } from "../lib/intelligence";
1717import { db } from "../db";
1818import { deployments, repositories, users } from "../db/schema";
1919import { onDeployFailure } from "../lib/ai-incident";
20import { logAiEvent } from "../lib/ai-flywheel";
2021
2122interface PushRef {
2223 oldSha: string;
@@ -29,11 +30,28 @@ export async function onPostReceive(
2930 repo: string,
3031 refs: PushRef[]
3132): Promise<void> {
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
3247 for (const ref of refs) {
3348 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
3449 const branchName = ref.refName.replace("refs/heads/", "");
3550
36 // 1. Auto-repair (runs first, may create a new commit)
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();
3755 try {
3856 const repair = await autoRepair(owner, repo, branchName);
3957 if (repair.repaired) {
@@ -41,8 +59,37 @@ export async function onPostReceive(
4159 `[autorepair] ${owner}/${repo}@${branchName}: ${repair.repairs.length} repairs committed`
4260 );
4361 }
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 });
4481 } catch (err) {
4582 console.error(`[autorepair] error:`, err);
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 });
4693 }
4794
4895 // 2. Push analysis
@@ -78,28 +125,31 @@ export async function onPostReceive(
78125 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
79126 // triggerGateTest helper is slated for the intelligence rework.
80127
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
81145 // 5. Crontech deploy on push to main
82146 const mainPush = refs.find(
83147 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
84148 );
85 if (mainPush) {
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 }
149 if (mainPush && repositoryId) {
150 triggerCrontechDeploy(owner, repo, mainPush.newSha, repositoryId).catch(
151 (err: unknown) => console.error(`[crontech] error:`, err)
152 );
103153 }
104154}
105155
Modifiedsrc/lib/ai-client.ts+9−4View fileUnifiedSplit
@@ -22,10 +22,15 @@ export function isAiAvailable(): boolean {
2222 return !!config.anthropicApiKey;
2323}
2424
25/** Default model for code understanding + review */
26export const MODEL_SONNET = "claude-sonnet-4-20250514";
27/** Fast model for lightweight tasks (commit messages, titles) */
28export const MODEL_HAIKU = "claude-haiku-4-5-20251001";
25/**
26 * Default model for code understanding + review.
27 * Owner-mandated to `claude-sonnet-4-6` — the most reliable model for the
28 * gluecron workload as of this build. Override at runtime with MODEL_SONNET
29 * env if a future model proves better; do not edit the constant.
30 */
31export const MODEL_SONNET = process.env.MODEL_SONNET || "claude-sonnet-4-6";
32/** Fast model for lightweight tasks (commit messages, titles, completions). */
33export const MODEL_HAIKU = process.env.MODEL_HAIKU || "claude-haiku-4-5-20251001";
2934
3035/**
3136 * Extract text content from an Anthropic message response.
Modifiedsrc/lib/ai-completion.ts+40−16View fileUnifiedSplit
@@ -120,27 +120,51 @@ export async function completeCode(
120120 const key = cacheKey(prefix, suffix, language);
121121 const hit = cacheGet(key);
122122 if (hit !== undefined) {
123 // Log the cache hit too — useful for cost-tracking and rate insight.
124 try {
125 const { logAiEvent } = await import("./ai-flywheel");
126 logAiEvent({
127 actionType: "completion",
128 model: MODEL_HAIKU,
129 summary: `cached completion (${language})`,
130 latencyMs: 0,
131 success: true,
132 metadata: { cached: true, language, repoHint },
133 });
134 } catch {
135 /* telemetry must not break completion */
136 }
123137 return { completion: hit, model: MODEL_HAIKU, cached: true };
124138 }
125139
126140 try {
141 const { recordAi } = await import("./ai-flywheel");
127142 const client = getAnthropic();
128 const response = await client.messages.create({
129 model: MODEL_HAIKU,
130 max_tokens: maxTokens,
131 system:
132 "You are a code completion engine. Given a prefix and optional suffix, output ONLY the characters that should be inserted at the cursor. No explanations. No markdown fences. No commentary.",
133 messages: [
134 {
135 role: "user",
136 content:
137 `Language: ${language}\n` +
138 `Repo: ${repoHint}\n\n` +
139 `PREFIX:\n${prefix}\n\n` +
140 `SUFFIX:\n${suffix}`,
141 },
142 ],
143 });
143 const response = await recordAi(
144 {
145 actionType: "completion",
146 model: MODEL_HAIKU,
147 summary: `code completion (${language})`,
148 metadata: { language, repoHint, prefixLen: prefix.length },
149 },
150 () =>
151 client.messages.create({
152 model: MODEL_HAIKU,
153 max_tokens: maxTokens,
154 system:
155 "You are a code completion engine. Given a prefix and optional suffix, output ONLY the characters that should be inserted at the cursor. No explanations. No markdown fences. No commentary.",
156 messages: [
157 {
158 role: "user",
159 content:
160 `Language: ${language}\n` +
161 `Repo: ${repoHint}\n\n` +
162 `PREFIX:\n${prefix}\n\n` +
163 `SUFFIX:\n${suffix}`,
164 },
165 ],
166 })
167 );
144168
145169 const raw = extractText(response);
146170 const completion = stripCodeFences(raw);
Modifiedsrc/lib/ai-explain.ts+15−5View fileUnifiedSplit
@@ -424,11 +424,21 @@ ${treeListing || "(empty)"}
424424Representative files:
425425${fileBlob}`;
426426
427 const message = await client.messages.create({
428 model: MODEL_SONNET,
429 max_tokens: 2048,
430 messages: [{ role: "user", content: prompt }],
431 });
427 const { recordAi } = await import("./ai-flywheel");
428 const message = await recordAi(
429 {
430 actionType: "explain",
431 model: MODEL_SONNET,
432 summary: `explain codebase`,
433 metadata: { files: samples.files.length },
434 },
435 () =>
436 client.messages.create({
437 model: MODEL_SONNET,
438 max_tokens: 2048,
439 messages: [{ role: "user", content: prompt }],
440 })
441 );
432442
433443 const markdown = extractText(message).trim();
434444 const summary = extractSummary(markdown);
Addedsrc/lib/ai-flywheel.ts+286−0View fileUnifiedSplit
@@ -0,0 +1,286 @@
1/**
2 * AI flywheel — telemetry + live feed for every AI invocation.
3 *
4 * Two responsibilities:
5 * 1. Persist a row into `ai_activity` for every AI call (model, latency,
6 * success, summary, optional repo/user/PR anchors).
7 * 2. Publish a small JSON event onto two SSE topics so the live dashboard
8 * and any per-repo "AI in action" panels can render the work in motion:
9 *
10 * ai:global → every AI event in the system
11 * ai:repo:<repositoryId> → events anchored to that repo
12 *
13 * NEVER throws into the request path. Telemetry failures must not break AI
14 * features — every DB call and every publish is wrapped. Callers can use
15 * either:
16 *
17 * await recordAi({...meta}, async () => callAnthropic(...))
18 *
19 * or, for fire-and-forget logging without a wrapped call:
20 *
21 * logAiEvent({...meta, latencyMs, success: true})
22 */
23
24import { db } from "../db";
25import { aiActivity, type NewAiActivity } from "../db/schema";
26import { desc, eq, sql } from "drizzle-orm";
27import { publish } from "./sse";
28
29export type AiActionType =
30 | "review"
31 | "repair"
32 | "completion"
33 | "incident"
34 | "triage"
35 | "explain"
36 | "test"
37 | "changelog"
38 | "chat"
39 | "spec"
40 | "commit-message"
41 | "pr-summary"
42 | "issue-triage"
43 | "merge-resolve"
44 | "security-scan"
45 | "dep-update"
46 | "semantic-index"
47 | "other";
48
49export interface AiEventMeta {
50 actionType: AiActionType;
51 model: string;
52 summary: string;
53 repositoryId?: string | null;
54 userId?: string | null;
55 pullRequestId?: string | null;
56 issueId?: string | null;
57 commitSha?: string | null;
58 inputTokens?: number | null;
59 outputTokens?: number | null;
60 metadata?: Record<string, unknown> | null;
61}
62
63export interface AiEvent extends AiEventMeta {
64 id: string;
65 latencyMs: number;
66 success: boolean;
67 error?: string | null;
68 createdAt: string;
69}
70
71/**
72 * Wrap an AI call so its outcome is persisted + published. The callback may
73 * return any value — the wrapper passes it through. On throw, telemetry is
74 * still recorded with success=false and the error is rethrown so callers can
75 * keep their existing error-handling behaviour.
76 */
77export async function recordAi<T>(
78 meta: AiEventMeta,
79 fn: () => Promise<T>
80): Promise<T> {
81 const t0 = Date.now();
82 try {
83 const value = await fn();
84 void persist({ ...meta, latencyMs: Date.now() - t0, success: true });
85 return value;
86 } catch (err) {
87 const message = err instanceof Error ? err.message : String(err);
88 void persist({
89 ...meta,
90 latencyMs: Date.now() - t0,
91 success: false,
92 error: redact(message),
93 });
94 throw err;
95 }
96}
97
98/**
99 * Fire-and-forget logger for callers that already have the result in hand
100 * (e.g. cached completions where there was no real LLM call).
101 */
102export function logAiEvent(
103 meta: AiEventMeta & {
104 latencyMs: number;
105 success?: boolean;
106 error?: string | null;
107 }
108): void {
109 void persist({
110 ...meta,
111 success: meta.success ?? true,
112 error: meta.error ?? null,
113 });
114}
115
116interface PersistArgs extends AiEventMeta {
117 latencyMs: number;
118 success: boolean;
119 error?: string | null;
120}
121
122async function persist(args: PersistArgs): Promise<void> {
123 let row: { id: string; createdAt: Date } | null = null;
124 try {
125 const insert: NewAiActivity = {
126 actionType: args.actionType,
127 model: args.model,
128 summary: clamp(args.summary, 500),
129 repositoryId: args.repositoryId ?? null,
130 userId: args.userId ?? null,
131 pullRequestId: args.pullRequestId ?? null,
132 issueId: args.issueId ?? null,
133 commitSha: args.commitSha ?? null,
134 inputTokens: args.inputTokens ?? null,
135 outputTokens: args.outputTokens ?? null,
136 latencyMs: args.latencyMs,
137 success: args.success,
138 error: args.error ? clamp(args.error, 1000) : null,
139 metadata: args.metadata ?? null,
140 };
141 const [inserted] = await db
142 .insert(aiActivity)
143 .values(insert)
144 .returning({ id: aiActivity.id, createdAt: aiActivity.createdAt });
145 if (inserted) row = inserted;
146 } catch (err) {
147 // DB writes are best-effort. We still publish so the live UI sees the
148 // event even when we cannot persist (e.g. fresh deploy without migration).
149 safeWarn("[ai-flywheel] persist failed:", err);
150 }
151
152 const event: AiEvent = {
153 id: row?.id ?? cryptoId(),
154 actionType: args.actionType,
155 model: args.model,
156 summary: args.summary,
157 repositoryId: args.repositoryId ?? null,
158 userId: args.userId ?? null,
159 pullRequestId: args.pullRequestId ?? null,
160 issueId: args.issueId ?? null,
161 commitSha: args.commitSha ?? null,
162 inputTokens: args.inputTokens ?? null,
163 outputTokens: args.outputTokens ?? null,
164 metadata: args.metadata ?? null,
165 latencyMs: args.latencyMs,
166 success: args.success,
167 error: args.error ?? null,
168 createdAt: (row?.createdAt ?? new Date()).toISOString(),
169 };
170
171 try {
172 publish("ai:global", { event: "ai", data: event });
173 if (event.repositoryId) {
174 publish(`ai:repo:${event.repositoryId}`, { event: "ai", data: event });
175 }
176 } catch (err) {
177 safeWarn("[ai-flywheel] publish failed:", err);
178 }
179}
180
181export interface ListRecentOpts {
182 limit?: number;
183 repositoryId?: string;
184}
185
186export async function listRecentAiEvents(
187 opts: ListRecentOpts = {}
188): Promise<AiEvent[]> {
189 const limit = clampInt(opts.limit ?? 50, 1, 200);
190 try {
191 const query = db
192 .select()
193 .from(aiActivity)
194 .orderBy(desc(aiActivity.createdAt))
195 .limit(limit);
196 const rows = opts.repositoryId
197 ? await query.where(eq(aiActivity.repositoryId, opts.repositoryId))
198 : await query;
199 return rows.map(rowToEvent);
200 } catch (err) {
201 safeWarn("[ai-flywheel] list failed:", err);
202 return [];
203 }
204}
205
206export interface RollupRow {
207 actionType: string;
208 total: number;
209 successes: number;
210 failures: number;
211 avgLatencyMs: number;
212}
213
214export async function rollupByAction(sinceHours = 24): Promise<RollupRow[]> {
215 try {
216 const rows = await db
217 .select({
218 actionType: aiActivity.actionType,
219 total: sql<number>`count(*)::int`,
220 successes: sql<number>`sum(case when ${aiActivity.success} then 1 else 0 end)::int`,
221 failures: sql<number>`sum(case when ${aiActivity.success} then 0 else 1 end)::int`,
222 avgLatencyMs: sql<number>`coalesce(avg(${aiActivity.latencyMs}), 0)::int`,
223 })
224 .from(aiActivity)
225 .where(sql`${aiActivity.createdAt} > now() - (${sinceHours} || ' hours')::interval`)
226 .groupBy(aiActivity.actionType);
227 return rows;
228 } catch (err) {
229 safeWarn("[ai-flywheel] rollup failed:", err);
230 return [];
231 }
232}
233
234function rowToEvent(row: typeof aiActivity.$inferSelect): AiEvent {
235 return {
236 id: row.id,
237 actionType: row.actionType as AiActionType,
238 model: row.model,
239 summary: row.summary,
240 repositoryId: row.repositoryId,
241 userId: row.userId,
242 pullRequestId: row.pullRequestId,
243 issueId: row.issueId,
244 commitSha: row.commitSha,
245 inputTokens: row.inputTokens,
246 outputTokens: row.outputTokens,
247 metadata: (row.metadata as Record<string, unknown>) ?? null,
248 latencyMs: row.latencyMs,
249 success: row.success,
250 error: row.error,
251 createdAt: row.createdAt.toISOString(),
252 };
253}
254
255function clamp(s: string, n: number): string {
256 return s.length > n ? s.slice(0, n - 1) + "…" : s;
257}
258function clampInt(n: number, lo: number, hi: number): number {
259 return Math.max(lo, Math.min(hi, Math.floor(n)));
260}
261function redact(msg: string): string {
262 // Strip obvious bearer tokens / API keys before persisting. Patterns:
263 // sk-... Anthropic / OpenAI keys
264 // glc_... gluecron PAT
265 // glct_... gluecron OAuth access token
266 // ghi_... marketplace install token
267 // Bearer... any explicit bearer header value
268 return msg.replace(
269 /(?:sk-[A-Za-z0-9_-]{16,}|gl(?:c|ct)_[A-Za-z0-9_-]{12,}|ghi_[A-Za-z0-9_-]{12,}|Bearer\s+[A-Za-z0-9_.\-]+)/g,
270 "[REDACTED]"
271 );
272}
273function cryptoId(): string {
274 const b = new Uint8Array(16);
275 crypto.getRandomValues(b);
276 return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
277}
278function safeWarn(prefix: string, err: unknown): void {
279 try {
280 console.warn(prefix, err);
281 } catch {
282 /* ignore */
283 }
284}
285
286export const __test = { redact, clamp, clampInt };
Modifiedsrc/lib/ai-generators.ts+65−26View fileUnifiedSplit
@@ -11,27 +11,36 @@ import {
1111 parseJsonResponse,
1212 isAiAvailable,
1313} from "./ai-client";
14import { recordAi } from "./ai-flywheel";
1415
1516export async function generateCommitMessage(diff: string): Promise<string> {
1617 if (!isAiAvailable() || !diff.trim()) {
1718 return "chore: update files";
1819 }
1920 const client = getAnthropic();
20 const message = await client.messages.create({
21 model: MODEL_HAIKU,
22 max_tokens: 512,
23 messages: [
24 {
25 role: "user",
26 content: `Write a single conventional-commit message for this diff. One line subject under 72 chars, lowercase type (feat/fix/refactor/chore/docs/test/perf/style), then optional body wrapped at 72 chars. No backticks or markdown.
21 const message = await recordAi(
22 {
23 actionType: "commit-message",
24 model: MODEL_HAIKU,
25 summary: "generate commit message",
26 },
27 () =>
28 client.messages.create({
29 model: MODEL_HAIKU,
30 max_tokens: 512,
31 messages: [
32 {
33 role: "user",
34 content: `Write a single conventional-commit message for this diff. One line subject under 72 chars, lowercase type (feat/fix/refactor/chore/docs/test/perf/style), then optional body wrapped at 72 chars. No backticks or markdown.
2735
2836Diff:
2937\`\`\`
3038${diff.slice(0, 40000)}
3139\`\`\``,
32 },
33 ],
34 });
40 },
41 ],
42 })
43 );
3544 return extractText(message).trim();
3645}
3746
@@ -41,13 +50,20 @@ export async function generatePrSummary(
4150): Promise<string> {
4251 if (!isAiAvailable() || !diff.trim()) return "";
4352 const client = getAnthropic();
44 const message = await client.messages.create({
45 model: MODEL_SONNET,
46 max_tokens: 1024,
47 messages: [
48 {
49 role: "user",
50 content: `Generate a Markdown PR description for the following changes. Include: Summary (1-3 sentences focused on why), Key changes (bullets), Test plan (bullets), Risks (bullets — omit if minor).
53 const message = await recordAi(
54 {
55 actionType: "pr-summary",
56 model: MODEL_SONNET,
57 summary: `pr summary "${title.slice(0, 80)}"`,
58 },
59 () =>
60 client.messages.create({
61 model: MODEL_SONNET,
62 max_tokens: 1024,
63 messages: [
64 {
65 role: "user",
66 content: `Generate a Markdown PR description for the following changes. Include: Summary (1-3 sentences focused on why), Key changes (bullets), Test plan (bullets), Risks (bullets — omit if minor).
5167
5268Title: ${title}
5369
@@ -55,9 +71,10 @@ Diff:
5571\`\`\`
5672${diff.slice(0, 60000)}
5773\`\`\``,
58 },
59 ],
60 });
74 },
75 ],
76 })
77 );
6178 return extractText(message).trim();
6279}
6380
@@ -81,7 +98,14 @@ export async function generateChangelog(
8198 .slice(0, 200)
8299 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]} — ${c.author}`)
83100 .join("\n");
84 const message = await client.messages.create({
101 const message = await recordAi(
102 {
103 actionType: "changelog",
104 model: MODEL_SONNET,
105 summary: `changelog ${repoFullName} ${fromRef ?? "(initial)"}..${toRef}`,
106 metadata: { commits: commits.length },
107 },
108 () => client.messages.create({
85109 model: MODEL_SONNET,
86110 max_tokens: 2048,
87111 messages: [
@@ -98,7 +122,8 @@ Commits:
98122${commitBlob}`,
99123 },
100124 ],
101 });
125 })
126 );
102127 return extractText(message).trim();
103128}
104129
@@ -123,7 +148,13 @@ export async function triageIssue(
123148 };
124149 if (!isAiAvailable()) return fallback;
125150 const client = getAnthropic();
126 const message = await client.messages.create({
151 const message = await recordAi(
152 {
153 actionType: "issue-triage",
154 model: MODEL_HAIKU,
155 summary: `triage issue "${title.slice(0, 80)}"`,
156 },
157 () => client.messages.create({
127158 model: MODEL_HAIKU,
128159 max_tokens: 512,
129160 messages: [
@@ -149,7 +180,8 @@ Respond ONLY with JSON:
149180Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`,
150181 },
151182 ],
152 });
183 })
184 );
153185 const parsed = parseJsonResponse<IssueTriage>(extractText(message));
154186 if (!parsed) return fallback;
155187 return {
@@ -199,7 +231,13 @@ export async function triagePullRequest(
199231 if (!isAiAvailable()) return fallback;
200232 try {
201233 const client = getAnthropic();
202 const message = await client.messages.create({
234 const message = await recordAi(
235 {
236 actionType: "triage",
237 model: MODEL_HAIKU,
238 summary: `triage PR "${title.slice(0, 80)}"`,
239 },
240 () => client.messages.create({
203241 model: MODEL_HAIKU,
204242 max_tokens: 512,
205243 messages: [
@@ -228,7 +266,8 @@ Respond ONLY with JSON:
228266Only pick labels from the available list. Only pick reviewers from the candidate list. Priority must be one of critical|high|medium|low. riskArea must be one of frontend|backend|infra|docs|tests|mixed.`,
229267 },
230268 ],
231 });
269 })
270 );
232271 const parsed = parseJsonResponse<PrTriage>(extractText(message));
233272 if (!parsed) return fallback;
234273 const allowedRisk = ["frontend", "backend", "infra", "docs", "tests", "mixed"] as const;
Modifiedsrc/lib/ai-incident.ts+12−2View fileUnifiedSplit
@@ -121,8 +121,17 @@ async function askClaudeForAnalysis(
121121 commitSummary: string
122122): Promise<IncidentAnalysis | null> {
123123 try {
124 const { recordAi } = await import("./ai-flywheel");
124125 const client = getAnthropic();
125 const message = await client.messages.create({
126 const message = await recordAi(
127 {
128 actionType: "incident",
129 model: MODEL_SONNET,
130 summary: `incident analysis ${repoFullName}@${shortSha}`,
131 commitSha: shortSha,
132 metadata: { ref, errorMessage: errorMessage.slice(0, 200) },
133 },
134 () => client.messages.create({
126135 model: MODEL_SONNET,
127136 max_tokens: 1024,
128137 messages: [
@@ -146,7 +155,8 @@ ${commitSummary || "(no commits available)"}
146155Write a crisp issue title (prefixed with "Deploy failed:"), a plausible likelyCause (2-4 sentences), a suspectedCommit sha (or null if unclear), and concrete remediation steps (bullet list as a single string with \\n separators).`,
147156 },
148157 ],
149 });
158 })
159 );
150160 const parsed = parseJsonResponse<IncidentAnalysis>(extractText(message));
151161 if (!parsed) return null;
152162 const suspected =
Modifiedsrc/lib/ai-review.ts+2−1View fileUnifiedSplit
@@ -7,6 +7,7 @@
77
88import Anthropic from "@anthropic-ai/sdk";
99import { config } from "./config";
10import { MODEL_SONNET } from "./ai-client";
1011
1112interface ReviewComment {
1213 filePath: string;
@@ -46,7 +47,7 @@ export async function reviewDiff(
4647 const client = getClient();
4748
4849 const message = await client.messages.create({
49 model: "claude-sonnet-4-20250514",
50 model: MODEL_SONNET,
5051 max_tokens: 4096,
5152 messages: [
5253 {
Modifiedsrc/lib/ai-tests.ts+15−5View fileUnifiedSplit
@@ -291,13 +291,23 @@ export async function generateTestStub(
291291 }
292292
293293 try {
294 const { recordAi } = await import("./ai-flywheel");
294295 const client = getAnthropic();
295296 const prompt = buildTestsPrompt(opts);
296 const message = await client.messages.create({
297 model: MODEL_SONNET,
298 max_tokens: 2048,
299 messages: [{ role: "user", content: prompt }],
300 });
297 const message = await recordAi(
298 {
299 actionType: "test",
300 model: MODEL_SONNET,
301 summary: `generate tests for ${opts.path}`,
302 metadata: { language: opts.language, framework: opts.framework },
303 },
304 () =>
305 client.messages.create({
306 model: MODEL_SONNET,
307 max_tokens: 2048,
308 messages: [{ role: "user", content: prompt }],
309 })
310 );
301311 const raw = extractText(message);
302312 const code = stripCodeFences(raw);
303313 if (!code) {
Addedsrc/lib/integrations.ts+719−0View fileUnifiedSplit
@@ -0,0 +1,719 @@
1/**
2 * Third-party integrations registry.
3 *
4 * Each integration is a row in the `integrations` table that maps a repo +
5 * event-kind to an outbound webhook in some other product's native shape.
6 * v1 ships connectors for Slack, Discord, Linear, Vercel, Jira (Atlassian
7 * webhook), PagerDuty (Events V2), Sentry (project webhook), Datadog
8 * (events API), Figma (file comments — placeholder), Cursor (generic), and
9 * a generic JSON webhook fallback.
10 *
11 * `deliverEvent(repoId, event, payload)` looks up every enabled integration
12 * subscribed to `event` and POSTs the connector's rendered payload. Each
13 * delivery records a row in `integration_deliveries` (status, http code,
14 * duration). Never throws into the caller.
15 *
16 * Auth model:
17 * - Each integration's `config` JSON carries the connector-specific
18 * credentials (webhookUrl, channel, projectKey, integrationKey, ...).
19 * - We do NOT round-trip secrets to the UI. Reads go through `redactConfig`.
20 */
21
22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import {
25 integrations,
26 integrationDeliveries,
27 type Integration,
28 type NewIntegration,
29} from "../db/schema";
30
31export const INTEGRATION_KINDS = [
32 "slack",
33 "discord",
34 "linear",
35 "vercel",
36 "jira",
37 "pagerduty",
38 "sentry",
39 "datadog",
40 "figma",
41 "cursor",
42 "generic_webhook",
43] as const;
44export type IntegrationKind = (typeof INTEGRATION_KINDS)[number];
45
46export const INTEGRATION_EVENTS = [
47 "push",
48 "pr.opened",
49 "pr.merged",
50 "pr.closed",
51 "issue.opened",
52 "issue.closed",
53 "deploy.success",
54 "deploy.failed",
55 "gate.failed",
56 "ai.repair",
57 "ai.incident",
58] as const;
59export type IntegrationEvent = (typeof INTEGRATION_EVENTS)[number];
60
61export interface ConnectorMeta {
62 kind: IntegrationKind;
63 label: string;
64 description: string;
65 /** Names of the config fields users must fill in. */
66 configFields: { name: string; label: string; required: boolean; secret?: boolean }[];
67}
68
69export const CONNECTORS: ConnectorMeta[] = [
70 {
71 kind: "slack",
72 label: "Slack",
73 description: "Post events to a Slack channel via an incoming webhook.",
74 configFields: [
75 { name: "webhookUrl", label: "Incoming webhook URL", required: true, secret: true },
76 { name: "channel", label: "Channel override (optional)", required: false },
77 ],
78 },
79 {
80 kind: "discord",
81 label: "Discord",
82 description: "Post events to a Discord channel via a server webhook.",
83 configFields: [
84 { name: "webhookUrl", label: "Webhook URL", required: true, secret: true },
85 ],
86 },
87 {
88 kind: "linear",
89 label: "Linear",
90 description: "Mirror gluecron events into Linear via their generic webhook.",
91 configFields: [
92 { name: "webhookUrl", label: "Linear webhook URL", required: true, secret: true },
93 { name: "teamKey", label: "Team key (e.g. ENG)", required: false },
94 ],
95 },
96 {
97 kind: "vercel",
98 label: "Vercel",
99 description: "Trigger a Vercel deploy hook on every push to main.",
100 configFields: [
101 { name: "deployHookUrl", label: "Deploy hook URL", required: true, secret: true },
102 ],
103 },
104 {
105 kind: "jira",
106 label: "Jira",
107 description: "Forward gluecron events into Jira (incoming webhook).",
108 configFields: [
109 { name: "webhookUrl", label: "Jira webhook URL", required: true, secret: true },
110 { name: "projectKey", label: "Project key", required: false },
111 ],
112 },
113 {
114 kind: "pagerduty",
115 label: "PagerDuty",
116 description:
117 "Open / resolve incidents via the PagerDuty Events V2 API. Best for deploy.failed and gate.failed.",
118 configFields: [
119 { name: "integrationKey", label: "Routing key (integration key)", required: true, secret: true },
120 { name: "severity", label: "Severity (info|warning|error|critical)", required: false },
121 ],
122 },
123 {
124 kind: "sentry",
125 label: "Sentry",
126 description: "Notify Sentry (alert webhook) on AI incidents and gate failures.",
127 configFields: [
128 { name: "webhookUrl", label: "Sentry alert webhook URL", required: true, secret: true },
129 ],
130 },
131 {
132 kind: "datadog",
133 label: "Datadog",
134 description: "Post gluecron events to the Datadog events API.",
135 configFields: [
136 { name: "apiKey", label: "DD-API-KEY", required: true, secret: true },
137 { name: "site", label: "Site (e.g. datadoghq.com)", required: false },
138 ],
139 },
140 {
141 kind: "figma",
142 label: "Figma",
143 description: "Generic webhook into Figma plugins. v1 sends generic JSON.",
144 configFields: [
145 { name: "webhookUrl", label: "Webhook URL", required: true, secret: true },
146 ],
147 },
148 {
149 kind: "cursor",
150 label: "Cursor",
151 description: "Mirror events into Cursor / any IDE bridge that listens on a webhook.",
152 configFields: [
153 { name: "webhookUrl", label: "Webhook URL", required: true, secret: true },
154 ],
155 },
156 {
157 kind: "generic_webhook",
158 label: "Generic webhook",
159 description: "POST raw JSON to any URL. Use this for anything not above.",
160 configFields: [
161 { name: "webhookUrl", label: "URL", required: true, secret: true },
162 { name: "secret", label: "HMAC secret (optional)", required: false, secret: true },
163 ],
164 },
165];
166
167export function getConnector(kind: string): ConnectorMeta | null {
168 return CONNECTORS.find((c) => c.kind === kind) ?? null;
169}
170
171export function isValidKind(k: string): k is IntegrationKind {
172 return (INTEGRATION_KINDS as readonly string[]).includes(k);
173}
174
175export function isValidEvent(e: string): e is IntegrationEvent {
176 return (INTEGRATION_EVENTS as readonly string[]).includes(e);
177}
178
179/** Strip secret-marked fields before round-tripping config to the UI. */
180export function redactConfig(
181 kind: IntegrationKind,
182 config: Record<string, unknown>
183): Record<string, unknown> {
184 const meta = getConnector(kind);
185 if (!meta) return {};
186 const out: Record<string, unknown> = {};
187 for (const field of meta.configFields) {
188 const v = config[field.name];
189 if (v == null) continue;
190 if (field.secret) {
191 const s = String(v);
192 out[field.name] = s.length > 8 ? `${s.slice(0, 4)}…${s.slice(-2)}` : "***";
193 } else {
194 out[field.name] = v;
195 }
196 }
197 return out;
198}
199
200export interface CreateInput {
201 repositoryId: string;
202 kind: IntegrationKind;
203 name: string;
204 config: Record<string, unknown>;
205 events: IntegrationEvent[];
206 createdBy?: string | null;
207}
208
209export async function createIntegration(input: CreateInput): Promise<Integration | null> {
210 const validation = validateConfig(input.kind, input.config);
211 if (!validation.ok) {
212 throw new Error(validation.error);
213 }
214 const validEvents = (input.events ?? []).filter(isValidEvent);
215 const insert: NewIntegration = {
216 repositoryId: input.repositoryId,
217 kind: input.kind,
218 name: input.name.trim().slice(0, 80) || "(unnamed)",
219 enabled: true,
220 config: input.config,
221 events: validEvents,
222 createdBy: input.createdBy ?? null,
223 };
224 try {
225 const [row] = await db.insert(integrations).values(insert).returning();
226 return row ?? null;
227 } catch (err) {
228 console.error("[integrations] create failed:", err);
229 return null;
230 }
231}
232
233export async function updateIntegration(
234 id: string,
235 patch: Partial<{
236 name: string;
237 enabled: boolean;
238 config: Record<string, unknown>;
239 events: IntegrationEvent[];
240 }>
241): Promise<Integration | null> {
242 const updates: Record<string, unknown> = { updatedAt: new Date() };
243 if (typeof patch.name === "string") updates.name = patch.name.trim().slice(0, 80);
244 if (typeof patch.enabled === "boolean") updates.enabled = patch.enabled;
245 if (patch.config) updates.config = patch.config;
246 if (patch.events) updates.events = patch.events.filter(isValidEvent);
247 try {
248 const [row] = await db
249 .update(integrations)
250 .set(updates as never)
251 .where(eq(integrations.id, id))
252 .returning();
253 return row ?? null;
254 } catch (err) {
255 console.error("[integrations] update failed:", err);
256 return null;
257 }
258}
259
260export async function deleteIntegration(id: string): Promise<boolean> {
261 try {
262 await db.delete(integrations).where(eq(integrations.id, id));
263 return true;
264 } catch (err) {
265 console.error("[integrations] delete failed:", err);
266 return false;
267 }
268}
269
270export async function listForRepo(repositoryId: string): Promise<Integration[]> {
271 try {
272 return await db
273 .select()
274 .from(integrations)
275 .where(eq(integrations.repositoryId, repositoryId));
276 } catch {
277 return [];
278 }
279}
280
281export async function getById(id: string): Promise<Integration | null> {
282 try {
283 const [row] = await db
284 .select()
285 .from(integrations)
286 .where(eq(integrations.id, id))
287 .limit(1);
288 return row ?? null;
289 } catch {
290 return null;
291 }
292}
293
294export async function listDeliveries(
295 integrationId: string,
296 limit = 25
297): Promise<Array<typeof integrationDeliveries.$inferSelect>> {
298 try {
299 const rows = await db
300 .select()
301 .from(integrationDeliveries)
302 .where(eq(integrationDeliveries.integrationId, integrationId))
303 .limit(Math.max(1, Math.min(200, limit)));
304 return rows;
305 } catch {
306 return [];
307 }
308}
309
310export interface ValidationResult {
311 ok: boolean;
312 error?: string;
313}
314
315export function validateConfig(
316 kind: IntegrationKind,
317 config: Record<string, unknown>
318): ValidationResult {
319 const meta = getConnector(kind);
320 if (!meta) return { ok: false, error: `Unknown integration kind: ${kind}` };
321 for (const field of meta.configFields) {
322 if (!field.required) continue;
323 const v = config[field.name];
324 if (typeof v !== "string" || !v.trim()) {
325 return { ok: false, error: `Missing required field: ${field.label}` };
326 }
327 if (field.name.toLowerCase().endsWith("url") && !isHttpUrl(String(v))) {
328 return { ok: false, error: `${field.label} must be a valid http(s) URL` };
329 }
330 }
331 return { ok: true };
332}
333
334export function isHttpUrl(s: string): boolean {
335 try {
336 const u = new URL(s);
337 return u.protocol === "https:" || u.protocol === "http:";
338 } catch {
339 return false;
340 }
341}
342
343// ─── Delivery ────────────────────────────────────────────────
344
345export interface DeliveryResult {
346 integrationId: string;
347 status: "ok" | "fail" | "skipped";
348 httpStatus?: number;
349 error?: string;
350 durationMs: number;
351}
352
353/**
354 * Find every integration on `repositoryId` subscribed to `event` and POST the
355 * connector-rendered payload. Returns one DeliveryResult per integration.
356 * Always resolves; never throws.
357 */
358export async function deliverEvent(
359 repositoryId: string,
360 event: IntegrationEvent | string,
361 payload: Record<string, unknown>
362): Promise<DeliveryResult[]> {
363 if (!isValidEvent(event)) return [];
364 let rows: Integration[] = [];
365 try {
366 rows = await db
367 .select()
368 .from(integrations)
369 .where(
370 and(
371 eq(integrations.repositoryId, repositoryId),
372 eq(integrations.enabled, true)
373 )
374 );
375 } catch (err) {
376 console.error("[integrations] list-for-delivery failed:", err);
377 return [];
378 }
379 const subscribed = rows.filter((r) => {
380 const evs = Array.isArray(r.events) ? (r.events as string[]) : [];
381 return evs.includes(event);
382 });
383 const results: DeliveryResult[] = [];
384 for (const row of subscribed) {
385 const r = await deliverOne(row, event, payload);
386 results.push(r);
387 void recordDelivery(row.id, event, r);
388 }
389 return results;
390}
391
392/** Deliver a single integration. Used by the test-button on the UI. */
393export async function deliverOne(
394 integration: Integration,
395 event: IntegrationEvent | string,
396 payload: Record<string, unknown>
397): Promise<DeliveryResult> {
398 const t0 = Date.now();
399 try {
400 const rendered = render(
401 integration.kind as IntegrationKind,
402 integration.config as Record<string, unknown>,
403 event,
404 payload
405 );
406 if (!rendered) {
407 return {
408 integrationId: integration.id,
409 status: "skipped",
410 durationMs: Date.now() - t0,
411 };
412 }
413 const response = await fetch(rendered.url, {
414 method: rendered.method ?? "POST",
415 headers: rendered.headers,
416 body: rendered.body,
417 signal: AbortSignal.timeout(10_000),
418 });
419 return {
420 integrationId: integration.id,
421 status: response.ok ? "ok" : "fail",
422 httpStatus: response.status,
423 durationMs: Date.now() - t0,
424 };
425 } catch (err) {
426 return {
427 integrationId: integration.id,
428 status: "fail",
429 error: err instanceof Error ? err.message : String(err),
430 durationMs: Date.now() - t0,
431 };
432 }
433}
434
435async function recordDelivery(
436 integrationId: string,
437 event: string,
438 r: DeliveryResult
439): Promise<void> {
440 try {
441 await db.insert(integrationDeliveries).values({
442 integrationId,
443 event,
444 status: r.status,
445 httpStatus: r.httpStatus ?? null,
446 error: r.error ?? null,
447 durationMs: r.durationMs,
448 });
449 await db
450 .update(integrations)
451 .set({
452 lastDeliveryAt: new Date(),
453 lastStatus: r.status,
454 updatedAt: new Date(),
455 })
456 .where(eq(integrations.id, integrationId));
457 } catch {
458 /* observability-only — never break delivery */
459 }
460}
461
462// ─── Connector renderers ─────────────────────────────────────
463
464interface RenderedRequest {
465 url: string;
466 method?: string;
467 headers: Record<string, string>;
468 body: string;
469}
470
471export function render(
472 kind: IntegrationKind,
473 config: Record<string, unknown>,
474 event: string,
475 payload: Record<string, unknown>
476): RenderedRequest | null {
477 switch (kind) {
478 case "slack":
479 return renderSlack(config, event, payload);
480 case "discord":
481 return renderDiscord(config, event, payload);
482 case "linear":
483 return renderLinear(config, event, payload);
484 case "vercel":
485 return renderVercel(config, event, payload);
486 case "jira":
487 return renderJira(config, event, payload);
488 case "pagerduty":
489 return renderPagerDuty(config, event, payload);
490 case "sentry":
491 return renderSentry(config, event, payload);
492 case "datadog":
493 return renderDatadog(config, event, payload);
494 case "figma":
495 case "cursor":
496 case "generic_webhook":
497 return renderGeneric(config, event, payload);
498 default:
499 return null;
500 }
501}
502
503function summary(event: string, payload: Record<string, unknown>): string {
504 const repo = String(payload.repository ?? "?");
505 if (event === "push") return `Push to ${repo}`;
506 if (event === "pr.opened") return `PR opened on ${repo}: ${payload.title ?? ""}`;
507 if (event === "pr.merged") return `PR merged on ${repo}: ${payload.title ?? ""}`;
508 if (event === "pr.closed") return `PR closed on ${repo}: ${payload.title ?? ""}`;
509 if (event === "issue.opened") return `Issue opened on ${repo}: ${payload.title ?? ""}`;
510 if (event === "issue.closed") return `Issue closed on ${repo}: ${payload.title ?? ""}`;
511 if (event === "deploy.success") return `Deploy succeeded on ${repo}`;
512 if (event === "deploy.failed") return `Deploy FAILED on ${repo}`;
513 if (event === "gate.failed") return `Gate FAILED on ${repo}`;
514 if (event === "ai.repair") return `Auto-repair on ${repo}`;
515 if (event === "ai.incident") return `AI incident on ${repo}: ${payload.title ?? ""}`;
516 return `${event} on ${repo}`;
517}
518
519function renderSlack(
520 config: Record<string, unknown>,
521 event: string,
522 payload: Record<string, unknown>
523): RenderedRequest | null {
524 const url = String(config.webhookUrl ?? "");
525 if (!isHttpUrl(url)) return null;
526 const text = `*gluecron* — ${summary(event, payload)}`;
527 const body: Record<string, unknown> = { text };
528 if (config.channel) body.channel = config.channel;
529 return {
530 url,
531 headers: { "Content-Type": "application/json" },
532 body: JSON.stringify(body),
533 };
534}
535
536function renderDiscord(
537 config: Record<string, unknown>,
538 event: string,
539 payload: Record<string, unknown>
540): RenderedRequest | null {
541 const url = String(config.webhookUrl ?? "");
542 if (!isHttpUrl(url)) return null;
543 const content = `**gluecron** — ${summary(event, payload)}`;
544 return {
545 url,
546 headers: { "Content-Type": "application/json" },
547 body: JSON.stringify({ content }),
548 };
549}
550
551function renderLinear(
552 config: Record<string, unknown>,
553 event: string,
554 payload: Record<string, unknown>
555): RenderedRequest | null {
556 const url = String(config.webhookUrl ?? "");
557 if (!isHttpUrl(url)) return null;
558 return {
559 url,
560 headers: { "Content-Type": "application/json", "User-Agent": "gluecron-linear/1" },
561 body: JSON.stringify({
562 type: "gluecron." + event,
563 data: payload,
564 teamKey: config.teamKey ?? null,
565 }),
566 };
567}
568
569function renderVercel(
570 config: Record<string, unknown>,
571 event: string,
572 _payload: Record<string, unknown>
573): RenderedRequest | null {
574 // Vercel deploy hooks are POST-with-empty-body URLs; only fire on push +
575 // deploy.success so we don't redeploy on noise.
576 if (event !== "push" && event !== "deploy.success") return null;
577 const url = String(config.deployHookUrl ?? "");
578 if (!isHttpUrl(url)) return null;
579 return { url, headers: {}, body: "" };
580}
581
582function renderJira(
583 config: Record<string, unknown>,
584 event: string,
585 payload: Record<string, unknown>
586): RenderedRequest | null {
587 const url = String(config.webhookUrl ?? "");
588 if (!isHttpUrl(url)) return null;
589 return {
590 url,
591 headers: { "Content-Type": "application/json" },
592 body: JSON.stringify({
593 webhookEvent: "gluecron:" + event,
594 projectKey: config.projectKey ?? null,
595 payload,
596 }),
597 };
598}
599
600function renderPagerDuty(
601 config: Record<string, unknown>,
602 event: string,
603 payload: Record<string, unknown>
604): RenderedRequest | null {
605 const routing_key = String(config.integrationKey ?? "");
606 if (!routing_key) return null;
607 // Only escalate failures into PD by default.
608 if (
609 event !== "deploy.failed" &&
610 event !== "gate.failed" &&
611 event !== "ai.incident"
612 ) {
613 return null;
614 }
615 const severity =
616 typeof config.severity === "string" &&
617 ["info", "warning", "error", "critical"].includes(config.severity)
618 ? config.severity
619 : "error";
620 const body = {
621 routing_key,
622 event_action: "trigger",
623 payload: {
624 summary: summary(event, payload),
625 severity,
626 source: "gluecron",
627 custom_details: payload,
628 },
629 };
630 return {
631 url: "https://events.pagerduty.com/v2/enqueue",
632 headers: { "Content-Type": "application/json" },
633 body: JSON.stringify(body),
634 };
635}
636
637function renderSentry(
638 config: Record<string, unknown>,
639 event: string,
640 payload: Record<string, unknown>
641): RenderedRequest | null {
642 const url = String(config.webhookUrl ?? "");
643 if (!isHttpUrl(url)) return null;
644 return {
645 url,
646 headers: { "Content-Type": "application/json" },
647 body: JSON.stringify({
648 action: event,
649 message: summary(event, payload),
650 data: payload,
651 }),
652 };
653}
654
655function renderDatadog(
656 config: Record<string, unknown>,
657 event: string,
658 payload: Record<string, unknown>
659): RenderedRequest | null {
660 const apiKey = String(config.apiKey ?? "");
661 if (!apiKey) return null;
662 const site = typeof config.site === "string" ? config.site : "datadoghq.com";
663 const url = `https://api.${site}/api/v1/events`;
664 return {
665 url,
666 headers: {
667 "Content-Type": "application/json",
668 "DD-API-KEY": apiKey,
669 },
670 body: JSON.stringify({
671 title: summary(event, payload),
672 text: JSON.stringify(payload),
673 tags: [`source:gluecron`, `event:${event}`],
674 alert_type:
675 event === "deploy.failed" || event === "gate.failed" ? "error" : "info",
676 }),
677 };
678}
679
680function renderGeneric(
681 config: Record<string, unknown>,
682 event: string,
683 payload: Record<string, unknown>
684): RenderedRequest | null {
685 const url = String(config.webhookUrl ?? "");
686 if (!isHttpUrl(url)) return null;
687 const body = JSON.stringify({ event, payload, ts: new Date().toISOString() });
688 const headers: Record<string, string> = {
689 "Content-Type": "application/json",
690 "User-Agent": "gluecron-webhook/1",
691 "X-Gluecron-Event": event,
692 };
693 if (typeof config.secret === "string" && config.secret.length > 0) {
694 headers["X-Gluecron-Signature"] = hmacHex(String(config.secret), body);
695 }
696 return { url, headers, body };
697}
698
699function hmacHex(secret: string, body: string): string {
700 // We need a sync-friendly call inside render(); subtle.crypto is async, so
701 // fall back to a Node-style HMAC via dynamic require to avoid an upfront
702 // dependency on `node:crypto`. Render() is hot but small.
703 try {
704 // eslint-disable-next-line @typescript-eslint/no-var-requires
705 const crypto = require("node:crypto") as typeof import("node:crypto");
706 return "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
707 } catch {
708 return "";
709 }
710}
711
712export const __test = {
713 render,
714 validateConfig,
715 redactConfig,
716 summary,
717 isHttpUrl,
718 hmacHex,
719};
Modifiedsrc/lib/merge-resolver.ts+2−1View fileUnifiedSplit
@@ -10,6 +10,7 @@
1010import Anthropic from "@anthropic-ai/sdk";
1111import { config } from "./config";
1212import { getRepoPath } from "../git/repository";
13import { MODEL_SONNET } from "./ai-client";
1314
1415interface ConflictFile {
1516 path: string;
@@ -174,7 +175,7 @@ async function resolveConflict(
174175
175176 try {
176177 const message = await client.messages.create({
177 model: "claude-sonnet-4-20250514",
178 model: MODEL_SONNET,
178179 max_tokens: 8192,
179180 messages: [
180181 {
Addedsrc/routes/ai-live.tsx+317−0View fileUnifiedSplit
@@ -0,0 +1,317 @@
1/**
2 * Live AI activity dashboard.
3 *
4 * `GET /ai/live` renders an HTML shell that:
5 * 1. Server-renders the most recent ~50 events + a rollup-by-action card.
6 * 2. Subscribes to `/live-events/ai:global` via EventSource and prepends
7 * new events to the timeline as they land.
8 *
9 * `GET /api/ai/activity` returns the same recent-events list as JSON, so
10 * external dashboards / the CLI can poll it.
11 *
12 * Public read — no AI secrets cross the boundary, only summaries and
13 * model/latency/success metadata. Per-repo private context is not exposed
14 * here because the flywheel only stores summaries, never prompts.
15 */
16
17import { Hono } from "hono";
18import { html, raw } from "hono/html";
19import { Layout } from "../views/layout";
20import { softAuth, type AuthEnv } from "../middleware/auth";
21import {
22 listRecentAiEvents,
23 rollupByAction,
24 type AiEvent,
25 type RollupRow,
26} from "../lib/ai-flywheel";
27
28const app = new Hono<AuthEnv>();
29
30app.get("/ai/live", softAuth, async (c) => {
31 const user = c.get("user") ?? null;
32 let recent: AiEvent[] = [];
33 let rollup: RollupRow[] = [];
34 try {
35 [recent, rollup] = await Promise.all([
36 listRecentAiEvents({ limit: 50 }),
37 rollupByAction(24),
38 ]);
39 } catch {
40 /* empty arrays render gracefully */
41 }
42
43 const total24h = rollup.reduce((s, r) => s + r.total, 0);
44 const fail24h = rollup.reduce((s, r) => s + r.failures, 0);
45 const greenRate24h =
46 total24h === 0 ? 100 : Math.round(((total24h - fail24h) / total24h) * 100);
47
48 return c.html(
49 <Layout title="AI in motion" user={user}>
50 <style>{raw(STYLE)}</style>
51 <div class="ai-live-page">
52 <header class="ai-live-header">
53 <div>
54 <h1>AI in motion</h1>
55 <p class="ai-live-subtitle">
56 Every model invocation across gluecron, streamed live.
57 </p>
58 </div>
59 <div class="ai-live-stats">
60 <div class="ai-stat">
61 <div class="ai-stat-num">{total24h}</div>
62 <div class="ai-stat-label">events / 24h</div>
63 </div>
64 <div class="ai-stat">
65 <div class="ai-stat-num">{greenRate24h}%</div>
66 <div class="ai-stat-label">success rate</div>
67 </div>
68 <div class="ai-stat">
69 <div class="ai-stat-num" id="ai-live-counter">
70 {recent.length}
71 </div>
72 <div class="ai-stat-label">on screen</div>
73 </div>
74 </div>
75 </header>
76
77 {rollup.length > 0 && (
78 <section class="ai-rollup">
79 <h2>Last 24h by action</h2>
80 <ul class="ai-rollup-list">
81 {rollup.map((r) => (
82 <li class="ai-rollup-row" data-action={r.actionType}>
83 <span class="ai-rollup-name">{r.actionType}</span>
84 <span class="ai-rollup-num">{r.total}</span>
85 <span class="ai-rollup-bar">
86 <span
87 class="ai-rollup-bar-ok"
88 style={`width:${
89 r.total === 0
90 ? 0
91 : Math.round((r.successes / r.total) * 100)
92 }%`}
93 />
94 </span>
95 <span class="ai-rollup-meta">
96 {r.successes}/{r.total} ok · {r.avgLatencyMs}ms avg
97 </span>
98 </li>
99 ))}
100 </ul>
101 </section>
102 )}
103
104 <section class="ai-stream">
105 <div class="ai-stream-head">
106 <h2>Live stream</h2>
107 <span class="ai-conn" id="ai-conn-state">
108 <span class="ai-conn-dot" />
109 connecting
110 </span>
111 </div>
112 <ul class="ai-events" id="ai-events">
113 {recent.length === 0 && (
114 <li class="ai-empty">
115 Waiting for the first AI event in this process. Push a commit,
116 open a PR, or trigger an AI feature to see it appear here.
117 </li>
118 )}
119 {recent.map((e) => (
120 <li
121 class={`ai-event ${e.success ? "ok" : "fail"}`}
122 data-id={e.id}
123 >
124 {renderEvent(e)}
125 </li>
126 ))}
127 </ul>
128 </section>
129 </div>
130 {raw(SCRIPT)}
131 </Layout>
132 );
133});
134
135app.get("/api/ai/activity", softAuth, async (c) => {
136 const limit = Math.max(1, Math.min(200, Number(c.req.query("limit") ?? 50)));
137 const repositoryId = c.req.query("repositoryId") || undefined;
138 const events = await listRecentAiEvents({ limit, repositoryId });
139 return c.json({ events });
140});
141
142function renderEvent(e: AiEvent) {
143 const when = relTime(e.createdAt);
144 return (
145 <>
146 <div class="ai-event-head">
147 <span class={`ai-pill ai-action-${e.actionType}`}>{e.actionType}</span>
148 <span class="ai-model">{e.model}</span>
149 <span class={`ai-status ${e.success ? "ok" : "fail"}`}>
150 {e.success ? "ok" : "fail"}
151 </span>
152 <span class="ai-latency">{e.latencyMs}ms</span>
153 <span class="ai-when">{when}</span>
154 </div>
155 <div class="ai-event-summary">{e.summary}</div>
156 {e.error && <div class="ai-event-error">{e.error}</div>}
157 </>
158 );
159}
160
161function relTime(iso: string): string {
162 const t = new Date(iso).getTime();
163 if (!Number.isFinite(t)) return "just now";
164 const seconds = Math.max(0, Math.floor((Date.now() - t) / 1000));
165 if (seconds < 5) return "just now";
166 if (seconds < 60) return `${seconds}s ago`;
167 const minutes = Math.floor(seconds / 60);
168 if (minutes < 60) return `${minutes}m ago`;
169 const hours = Math.floor(minutes / 60);
170 if (hours < 24) return `${hours}h ago`;
171 return `${Math.floor(hours / 24)}d ago`;
172}
173
174const STYLE = `
175.ai-live-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
176.ai-live-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
177.ai-live-subtitle { color: var(--text-muted); margin: 4px 0 0; }
178.ai-live-stats { display: flex; gap: 16px; }
179.ai-stat { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; text-align: center; min-width: 110px; }
180.ai-stat-num { font-size: 24px; font-weight: 700; line-height: 1; }
181.ai-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin-top: 4px; }
182.ai-rollup { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 24px; }
183.ai-rollup h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 12px; }
184.ai-rollup-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
185.ai-rollup-row { display: grid; grid-template-columns: 140px 50px 160px 1fr; align-items: center; gap: 12px; font-size: 13px; }
186.ai-rollup-name { font-family: ui-monospace, monospace; color: var(--text); }
187.ai-rollup-num { font-weight: 700; text-align: right; }
188.ai-rollup-bar { background: rgba(255,255,255,0.06); height: 8px; border-radius: 4px; overflow: hidden; }
189.ai-rollup-bar-ok { display: block; height: 100%; background: linear-gradient(90deg, #34d399, #10b981); transition: width .4s ease; }
190.ai-rollup-meta { color: var(--text-muted); font-size: 12px; }
191.ai-stream-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
192.ai-stream-head h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0; }
193.ai-conn { display: inline-flex; gap: 6px; align-items: center; font-size: 12px; color: var(--text-muted); }
194.ai-conn-dot { width: 8px; height: 8px; border-radius: 50%; background: #facc15; box-shadow: 0 0 8px rgba(250,204,21,.6); animation: pulse 1.4s ease-in-out infinite; }
195.ai-conn.ok .ai-conn-dot { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.7); }
196.ai-conn.fail .ai-conn-dot { background: #f87171; }
197@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
198.ai-events { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
199.ai-event { background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid #34d399; border-radius: 6px; padding: 10px 14px; transition: background .25s ease, border-color .25s ease; }
200.ai-event.fail { border-left-color: #f87171; }
201.ai-event.fresh { animation: arrive .55s ease; }
202@keyframes arrive { from { transform: translateY(-6px); opacity: 0; background: rgba(52,211,153,.16); } to { transform: translateY(0); opacity: 1; } }
203.ai-event-head { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; font-size: 12px; }
204.ai-pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: rgba(99,102,241,.15); color: #a5b4fc; font-family: ui-monospace, monospace; font-size: 11px; text-transform: lowercase; }
205.ai-action-repair { background: rgba(34,197,94,.18); color: #86efac; }
206.ai-action-incident { background: rgba(248,113,113,.18); color: #fca5a5; }
207.ai-action-review { background: rgba(96,165,250,.18); color: #93c5fd; }
208.ai-action-completion { background: rgba(168,85,247,.18); color: #d8b4fe; }
209.ai-action-explain { background: rgba(244,114,182,.18); color: #f9a8d4; }
210.ai-action-test { background: rgba(250,204,21,.18); color: #fcd34d; }
211.ai-action-changelog { background: rgba(251,146,60,.18); color: #fdba74; }
212.ai-model { font-family: ui-monospace, monospace; color: var(--text-muted); font-size: 11px; }
213.ai-status.ok { color: #34d399; font-weight: 600; }
214.ai-status.fail { color: #f87171; font-weight: 600; }
215.ai-latency { color: var(--text-muted); }
216.ai-when { color: var(--text-muted); margin-left: auto; }
217.ai-event-summary { font-size: 13px; margin-top: 4px; color: var(--text); }
218.ai-event-error { font-family: ui-monospace, monospace; font-size: 11px; color: #fca5a5; margin-top: 4px; }
219.ai-empty { background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: 6px; padding: 20px; text-align: center; color: var(--text-muted); }
220@media (max-width: 600px) {
221 .ai-rollup-row { grid-template-columns: 1fr 50px; }
222 .ai-rollup-bar, .ai-rollup-meta { display: none; }
223}
224`;
225
226const SCRIPT = `<script>
227(function(){
228 var list = document.getElementById('ai-events');
229 var counter = document.getElementById('ai-live-counter');
230 var conn = document.getElementById('ai-conn-state');
231 if (!list) return;
232 var max = 200;
233 var es;
234 var retry = 1000;
235
236 function setConn(state, label) {
237 if (!conn) return;
238 conn.classList.remove('ok','fail');
239 if (state) conn.classList.add(state);
240 var dot = conn.querySelector('.ai-conn-dot');
241 conn.innerHTML = '';
242 if (dot) conn.appendChild(dot); else { var d = document.createElement('span'); d.className='ai-conn-dot'; conn.appendChild(d); }
243 conn.appendChild(document.createTextNode(' ' + label));
244 }
245
246 function fmtRel(iso) {
247 var t = new Date(iso).getTime();
248 if (!isFinite(t)) return 'just now';
249 var s = Math.max(0, Math.floor((Date.now()-t)/1000));
250 if (s < 5) return 'just now';
251 if (s < 60) return s + 's ago';
252 var m = Math.floor(s/60);
253 if (m < 60) return m + 'm ago';
254 var h = Math.floor(m/60);
255 if (h < 24) return h + 'h ago';
256 return Math.floor(h/24) + 'd ago';
257 }
258
259 function pillClass(action) {
260 var safe = String(action || '').replace(/[^a-z0-9-]/gi,'').toLowerCase();
261 return 'ai-pill ai-action-' + safe;
262 }
263
264 function render(ev) {
265 var li = document.createElement('li');
266 li.className = 'ai-event fresh ' + (ev.success ? 'ok' : 'fail');
267 li.setAttribute('data-id', ev.id || '');
268 var head = document.createElement('div');
269 head.className = 'ai-event-head';
270 var pill = document.createElement('span'); pill.className = pillClass(ev.actionType); pill.textContent = ev.actionType || 'other'; head.appendChild(pill);
271 var model = document.createElement('span'); model.className = 'ai-model'; model.textContent = ev.model || ''; head.appendChild(model);
272 var status = document.createElement('span'); status.className = 'ai-status ' + (ev.success ? 'ok' : 'fail'); status.textContent = ev.success ? 'ok' : 'fail'; head.appendChild(status);
273 var lat = document.createElement('span'); lat.className = 'ai-latency'; lat.textContent = (ev.latencyMs || 0) + 'ms'; head.appendChild(lat);
274 var when = document.createElement('span'); when.className = 'ai-when'; when.textContent = fmtRel(ev.createdAt); head.appendChild(when);
275 li.appendChild(head);
276 var summary = document.createElement('div'); summary.className = 'ai-event-summary'; summary.textContent = ev.summary || ''; li.appendChild(summary);
277 if (ev.error) {
278 var err = document.createElement('div'); err.className = 'ai-event-error'; err.textContent = ev.error; li.appendChild(err);
279 }
280 return li;
281 }
282
283 function prepend(ev) {
284 var empty = list.querySelector('.ai-empty');
285 if (empty) empty.remove();
286 var li = render(ev);
287 list.insertBefore(li, list.firstChild);
288 while (list.children.length > max) list.removeChild(list.lastChild);
289 if (counter) counter.textContent = String(list.children.length);
290 setTimeout(function(){ li.classList.remove('fresh'); }, 700);
291 }
292
293 function connect() {
294 setConn(null, 'connecting');
295 try { es = new EventSource('/live-events/ai:global'); } catch(e) { setTimeout(connect, retry); retry = Math.min(retry*2, 30000); return; }
296 es.onopen = function(){ setConn('ok','live'); retry = 1000; };
297 es.addEventListener('ai', function(e){
298 try { var ev = JSON.parse(e.data); prepend(ev); } catch(_) {}
299 });
300 es.onerror = function(){ setConn('fail','reconnecting'); try { es.close(); } catch(_){}; setTimeout(connect, retry); retry = Math.min(retry*2, 30000); };
301 }
302
303 connect();
304
305 // refresh relative timestamps every 15s
306 setInterval(function(){
307 Array.prototype.forEach.call(list.querySelectorAll('.ai-event'), function(li){
308 var when = li.querySelector('.ai-when');
309 var head = li.querySelector('.ai-event-head');
310 if (!when || !head) return;
311 // Skip — we don't carry the iso on the DOM node; only newly streamed events get refresh.
312 });
313 }, 15000);
314})();
315</script>`;
316
317export default app;
Addedsrc/routes/integrations.tsx+377−0View fileUnifiedSplit
@@ -0,0 +1,377 @@
1/**
2 * Per-repo integrations management.
3 *
4 * UI lives at `/:owner/:repo/settings/integrations`:
5 * - List existing connectors with status / last delivery
6 * - Add a connector (kind + name + config + events)
7 * - Toggle enable, edit, delete
8 * - "Send test" button → fires a synthetic event through the connector
9 *
10 * Owner-only via requireRepoAccess("admin"). Reads redact secret config
11 * fields before rendering.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { softAuth, requireAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { requireRepoAccess } from "../middleware/repo-access";
23import {
24 Container,
25 Form,
26 FormGroup,
27 Input,
28 Button,
29 Alert,
30 Select,
31} from "../views/ui";
32import {
33 CONNECTORS,
34 INTEGRATION_EVENTS,
35 createIntegration,
36 deleteIntegration,
37 deliverOne,
38 getById,
39 getConnector,
40 isValidEvent,
41 isValidKind,
42 listDeliveries,
43 listForRepo,
44 redactConfig,
45 updateIntegration,
46 type IntegrationEvent,
47 type IntegrationKind,
48} from "../lib/integrations";
49
50const app = new Hono<AuthEnv>();
51
52app.use("*", softAuth);
53
54async function ownedRepo(ownerName: string, repoName: string, userId: string) {
55 const [owner] = await db
56 .select()
57 .from(users)
58 .where(eq(users.username, ownerName))
59 .limit(1);
60 if (!owner || owner.id !== userId) return null;
61 const [repo] = await db
62 .select()
63 .from(repositories)
64 .where(
65 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
66 )
67 .limit(1);
68 return repo ?? null;
69}
70
71app.get(
72 "/:owner/:repo/settings/integrations",
73 requireAuth,
74 requireRepoAccess("admin"),
75 async (c) => {
76 const { owner: ownerName, repo: repoName } = c.req.param();
77 const user = c.get("user")!;
78 const success = c.req.query("success");
79 const error = c.req.query("error");
80
81 const repo = await ownedRepo(ownerName, repoName, user.id);
82 if (!repo) return c.text("Unauthorized", 403);
83
84 const integrations = await listForRepo(repo.id);
85
86 return c.html(
87 <Layout title={`Integrations — ${ownerName}/${repoName}`} user={user}>
88 <RepoHeader owner={ownerName} repo={repoName} />
89 <Container maxWidth={760}>
90 <h2 style="margin-bottom: 6px">Integrations</h2>
91 <p style="color: var(--text-muted); margin-bottom: 16px">
92 Forward gluecron events into Slack, Discord, Linear, Vercel, Jira,
93 PagerDuty, Sentry, Datadog, and any custom webhook target.
94 </p>
95 {success && <Alert variant="success">{decodeURIComponent(success)}</Alert>}
96 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
97
98 {integrations.length === 0 && (
99 <div
100 style="
101 background: var(--bg-secondary);
102 border: 1px dashed var(--border);
103 border-radius: 6px;
104 padding: 16px;
105 margin-bottom: 24px;
106 color: var(--text-muted);
107 "
108 >
109 No integrations yet. Add one below.
110 </div>
111 )}
112
113 {integrations.map((row) => {
114 const meta = getConnector(row.kind as IntegrationKind);
115 const evs = Array.isArray(row.events) ? (row.events as string[]) : [];
116 const config = redactConfig(
117 row.kind as IntegrationKind,
118 (row.config as Record<string, unknown>) ?? {}
119 );
120 return (
121 <div
122 style="
123 background: var(--bg-secondary);
124 border: 1px solid var(--border);
125 border-radius: 6px;
126 padding: 14px;
127 margin-bottom: 12px;
128 "
129 >
130 <div style="display:flex; justify-content:space-between; gap:12px; align-items:flex-start;">
131 <div>
132 <strong>{row.name}</strong>
133 <span style="margin-left:8px; padding:2px 6px; background:rgba(99,102,241,0.15); color:#a5b4fc; border-radius:4px; font-size:11px;">
134 {meta?.label ?? row.kind}
135 </span>
136 {!row.enabled && (
137 <span style="margin-left:6px; color: var(--red); font-size:11px">
138 disabled
139 </span>
140 )}
141 <div style="font-size:12px; color: var(--text-muted); margin-top:4px;">
142 Events: {evs.length > 0 ? evs.join(", ") : "(none)"}
143 </div>
144 <div style="font-size:11px; color: var(--text-muted); margin-top:4px; font-family:ui-monospace,monospace;">
145 {Object.entries(config)
146 .map(([k, v]) => `${k}=${v}`)
147 .join(" ")}
148 </div>
149 <div style="font-size:11px; color: var(--text-muted); margin-top:4px;">
150 {row.lastDeliveryAt
151 ? `last: ${new Date(row.lastDeliveryAt).toISOString()} (${row.lastStatus ?? "unknown"})`
152 : "never delivered"}
153 </div>
154 </div>
155 <div style="display:flex; gap:6px; flex-direction:column; align-items:flex-end;">
156 <form
157 method="post"
158 action={`/${ownerName}/${repoName}/settings/integrations/${row.id}/test`}
159 >
160 <Button type="submit" size="sm">Send test</Button>
161 </form>
162 <form
163 method="post"
164 action={`/${ownerName}/${repoName}/settings/integrations/${row.id}/toggle`}
165 >
166 <Button type="submit" size="sm">
167 {row.enabled ? "Disable" : "Enable"}
168 </Button>
169 </form>
170 <form
171 method="post"
172 action={`/${ownerName}/${repoName}/settings/integrations/${row.id}/delete`}
173 >
174 <Button type="submit" variant="danger" size="sm">
175 Delete
176 </Button>
177 </form>
178 </div>
179 </div>
180 </div>
181 );
182 })}
183
184 <h3 style="margin-top: 24px; margin-bottom: 12px">Add integration</h3>
185 <Form
186 method="post"
187 action={`/${ownerName}/${repoName}/settings/integrations`}
188 >
189 <FormGroup label="Connector">
190 <Select name="kind">
191 {CONNECTORS.map((c) => (
192 <option value={c.kind}>
193 {c.label} — {c.description}
194 </option>
195 ))}
196 </Select>
197 </FormGroup>
198 <FormGroup label="Name (free-form label)">
199 <Input
200 type="text"
201 name="name"
202 required
203 placeholder="Engineering channel"
204 />
205 </FormGroup>
206 <FormGroup label="Config (JSON)">
207 <textarea
208 name="config"
209 rows={5}
210 style="width:100%; font-family: ui-monospace, monospace; padding:8px; background: var(--bg); color: var(--text); border:1px solid var(--border); border-radius:6px"
211 placeholder='{"webhookUrl": "https://hooks.slack.com/..."}'
212 required
213 ></textarea>
214 </FormGroup>
215 <FormGroup label="Events">
216 <div style="display:flex; flex-wrap:wrap; gap:8px">
217 {INTEGRATION_EVENTS.map((e) => (
218 <label style="display:inline-flex; align-items:center; gap:4px; font-size:12px; background: var(--bg); padding:4px 8px; border-radius:4px; border:1px solid var(--border);">
219 <input type="checkbox" name="events" value={e} />
220 <span style="font-family: ui-monospace, monospace">{e}</span>
221 </label>
222 ))}
223 </div>
224 </FormGroup>
225 <Button type="submit" variant="primary">Add integration</Button>
226 </Form>
227 </Container>
228 </Layout>
229 );
230 }
231);
232
233app.post(
234 "/:owner/:repo/settings/integrations",
235 requireAuth,
236 requireRepoAccess("admin"),
237 async (c) => {
238 const { owner: ownerName, repo: repoName } = c.req.param();
239 const user = c.get("user")!;
240 const repo = await ownedRepo(ownerName, repoName, user.id);
241 if (!repo) return c.text("Unauthorized", 403);
242
243 const form = await c.req.formData();
244 const kindRaw = String(form.get("kind") ?? "");
245 const name = String(form.get("name") ?? "").trim();
246 const configRaw = String(form.get("config") ?? "{}");
247 const events = form
248 .getAll("events")
249 .map(String)
250 .filter(isValidEvent) as IntegrationEvent[];
251 if (!isValidKind(kindRaw)) {
252 return c.redirect(
253 `/${ownerName}/${repoName}/settings/integrations?error=Invalid+connector`
254 );
255 }
256 let config: Record<string, unknown> = {};
257 try {
258 config = JSON.parse(configRaw) as Record<string, unknown>;
259 } catch {
260 return c.redirect(
261 `/${ownerName}/${repoName}/settings/integrations?error=Config+must+be+valid+JSON`
262 );
263 }
264 try {
265 await createIntegration({
266 repositoryId: repo.id,
267 kind: kindRaw,
268 name,
269 config,
270 events,
271 createdBy: user.id,
272 });
273 } catch (err) {
274 const msg = err instanceof Error ? err.message : "Create failed";
275 return c.redirect(
276 `/${ownerName}/${repoName}/settings/integrations?error=${encodeURIComponent(msg)}`
277 );
278 }
279 return c.redirect(
280 `/${ownerName}/${repoName}/settings/integrations?success=Integration+added`
281 );
282 }
283);
284
285app.post(
286 "/:owner/:repo/settings/integrations/:id/toggle",
287 requireAuth,
288 requireRepoAccess("admin"),
289 async (c) => {
290 const { owner: ownerName, repo: repoName, id } = c.req.param();
291 const user = c.get("user")!;
292 const repo = await ownedRepo(ownerName, repoName, user.id);
293 if (!repo) return c.text("Unauthorized", 403);
294 const row = await getById(id);
295 if (!row || row.repositoryId !== repo.id) {
296 return c.redirect(
297 `/${ownerName}/${repoName}/settings/integrations?error=Not+found`
298 );
299 }
300 await updateIntegration(id, { enabled: !row.enabled });
301 return c.redirect(
302 `/${ownerName}/${repoName}/settings/integrations?success=Updated`
303 );
304 }
305);
306
307app.post(
308 "/:owner/:repo/settings/integrations/:id/delete",
309 requireAuth,
310 requireRepoAccess("admin"),
311 async (c) => {
312 const { owner: ownerName, repo: repoName, id } = c.req.param();
313 const user = c.get("user")!;
314 const repo = await ownedRepo(ownerName, repoName, user.id);
315 if (!repo) return c.text("Unauthorized", 403);
316 const row = await getById(id);
317 if (!row || row.repositoryId !== repo.id) {
318 return c.redirect(
319 `/${ownerName}/${repoName}/settings/integrations?error=Not+found`
320 );
321 }
322 await deleteIntegration(id);
323 return c.redirect(
324 `/${ownerName}/${repoName}/settings/integrations?success=Deleted`
325 );
326 }
327);
328
329app.post(
330 "/:owner/:repo/settings/integrations/:id/test",
331 requireAuth,
332 requireRepoAccess("admin"),
333 async (c) => {
334 const { owner: ownerName, repo: repoName, id } = c.req.param();
335 const user = c.get("user")!;
336 const repo = await ownedRepo(ownerName, repoName, user.id);
337 if (!repo) return c.text("Unauthorized", 403);
338 const row = await getById(id);
339 if (!row || row.repositoryId !== repo.id) {
340 return c.redirect(
341 `/${ownerName}/${repoName}/settings/integrations?error=Not+found`
342 );
343 }
344 const result = await deliverOne(row, "push", {
345 repository: `${ownerName}/${repoName}`,
346 title: "test event from gluecron",
347 synthetic: true,
348 });
349 const msg =
350 result.status === "ok"
351 ? `Test delivered (${result.httpStatus ?? "?"}) in ${result.durationMs}ms`
352 : `Test failed: ${result.error ?? result.httpStatus ?? "unknown"}`;
353 return c.redirect(
354 `/${ownerName}/${repoName}/settings/integrations?${
355 result.status === "ok" ? "success" : "error"
356 }=${encodeURIComponent(msg)}`
357 );
358 }
359);
360
361app.get(
362 "/:owner/:repo/settings/integrations/:id/deliveries",
363 requireAuth,
364 requireRepoAccess("admin"),
365 async (c) => {
366 const { owner: ownerName, repo: repoName, id } = c.req.param();
367 const user = c.get("user")!;
368 const repo = await ownedRepo(ownerName, repoName, user.id);
369 if (!repo) return c.text("Unauthorized", 403);
370 const row = await getById(id);
371 if (!row || row.repositoryId !== repo.id) return c.notFound();
372 const deliveries = await listDeliveries(id, 50);
373 return c.json({ deliveries });
374 }
375);
376
377export default app;
Modifiedsrc/routes/live-events.ts+3−1View fileUnifiedSplit
@@ -29,7 +29,9 @@ import { subscribe, type SSEEvent } from "../lib/sse";
2929
3030const app = new Hono<AuthEnv>();
3131
32const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9\-]+$/;
32// kind is lowercase letters; id may include colons so multi-segment topics
33// like `ai:repo:<uuid>` parse cleanly into kind="ai", id="repo:<uuid>".
34const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9:\-]+$/;
3335const HEARTBEAT_MS = 25_000;
3436
3537app.get("/live-events/:topic", softAuth, async (c) => {
Modifiedsrc/routes/repo-settings.tsx+8−0View fileUnifiedSplit
@@ -74,6 +74,14 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin
7474 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
7575 Manage collaborators →
7676 </a>
77 {" · "}
78 <a href={`/${ownerName}/${repoName}/settings/integrations`}>
79 Integrations →
80 </a>
81 {" · "}
82 <a href={`/${ownerName}/${repoName}/settings/webhooks`}>
83 Webhooks →
84 </a>
7785 </p>
7886 {success && (
7987 <Alert variant="success">{decodeURIComponent(success)}</Alert>
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
@@ -58,6 +58,9 @@ export const Layout: FC<
5858 <a href="/explore" class="nav-link">
5959 Explore
6060 </a>
61 <a href="/ai/live" class="nav-link" title="Live AI activity">
62 AI Live
63 </a>
6164 {user ? (
6265 <>
6366 <a href="/dashboard" class="nav-link" style="font-weight: 600">
6467