Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit0c3eee5unknown_key

feat(landing): 2030 homepage — closed-loop showcase + 15 AI features + agent era pitch

Claude committed on May 25, 2026Parent: e3c90cd
17 files changed+376830c3eee5ba4670bb986f2fe2255394091ec3cfb65
17 changed files+3768−3
Addeddrizzle/0065_ai_cost_events.sql+76−0View fileUnifiedSplit
1-- Per-call AI cost tracking. Every successful Claude API call inserts one
2-- row here so the /billing/usage dashboard can attribute cents/tokens back
3-- to a user, repo, agent session, and feature category. Strictly additive —
4-- nothing else reads or writes this table (yet); the tracker is best-effort
5-- and the recordAiCost call site is wrapped in try/catch.
6--
7-- Columns:
8-- id — primary key, gen_random_uuid()
9-- occurred_at — when the model returned. UTC.
10-- owner_user_id — nullable; the user the cost belongs to (PAT owner,
11-- session user, etc.). Nullable for system/cron calls
12-- with no human attribution (e.g. autopilot tasks
13-- running before a user is associated).
14-- repository_id — nullable; the repo the call relates to (PR review,
15-- repo chat, CI healer, etc.). Nullable for global
16-- spend (e.g. proactive monitor).
17-- agent_session_id — nullable; the agent session that initiated the call
18-- (for /settings/agents budget enforcement).
19-- model — Claude model id, e.g. "claude-sonnet-4-20250514".
20-- input_tokens — Anthropic .usage.input_tokens for this call.
21-- output_tokens — Anthropic .usage.output_tokens for this call.
22-- cents_estimate — derived from the in-process pricing table at the
23-- time of insert. Stored so historical aggregates
24-- stay stable even when pricing changes.
25-- category — feature classification: ai_review | ai_patch |
26-- ci_healer | spec_to_pr | standup | chat | voice |
27-- test_gen | refactor | other
28-- source_id — opaque correlation id (PR id, chat id, run id…).
29-- source_kind — optional sub-category (e.g. "pull_request",
30-- "workflow_run") for the table-level grouping in
31-- the UI breakdown.
32-- created_at — row insert time (independent of occurred_at).
33
34CREATE TABLE IF NOT EXISTS ai_cost_events (
35 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
36 occurred_at timestamptz NOT NULL DEFAULT now(),
37 owner_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
38 repository_id uuid REFERENCES repositories(id) ON DELETE SET NULL,
39 agent_session_id uuid REFERENCES agent_sessions(id) ON DELETE SET NULL,
40 model text NOT NULL,
41 input_tokens integer NOT NULL DEFAULT 0,
42 output_tokens integer NOT NULL DEFAULT 0,
43 cents_estimate integer NOT NULL DEFAULT 0,
44 category text NOT NULL DEFAULT 'other',
45 source_id text,
46 source_kind text,
47 created_at timestamptz NOT NULL DEFAULT now()
48);
49
50-- Hot path: "show me this user's spend for the last 30 days bucketed by day".
51-- A composite (owner_user_id, occurred_at) index covers the most-common
52-- WHERE + ORDER BY combination on the dashboard.
53CREATE INDEX IF NOT EXISTS ai_cost_events_owner_time
54 ON ai_cost_events (owner_user_id, occurred_at DESC);
55
56-- Per-repo breakdown ("which repo is burning the most $?")
57CREATE INDEX IF NOT EXISTS ai_cost_events_repo_time
58 ON ai_cost_events (repository_id, occurred_at DESC);
59
60-- Per-agent breakdown (drives the agents budget surface).
61CREATE INDEX IF NOT EXISTS ai_cost_events_agent_time
62 ON ai_cost_events (agent_session_id, occurred_at DESC);
63
64-- Optional: category-rollup queries on big windows.
65CREATE INDEX IF NOT EXISTS ai_cost_events_category_time
66 ON ai_cost_events (category, occurred_at DESC);
67
68-- ai_budgets — per-user monthly budget cap (cents). One row per user.
69-- Used by /billing/usage to draw the "exceeded" warning banner when
70-- projected month-end spend exceeds the cap. Hard enforcement is out of
71-- scope for this migration; the dashboard is observational + advisory.
72CREATE TABLE IF NOT EXISTS ai_budgets (
73 user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
74 monthly_cents integer NOT NULL DEFAULT 0,
75 updated_at timestamptz NOT NULL DEFAULT now()
76);
Addeddrizzle/0065_auto_generate_tests.sql+5−0View fileUnifiedSplit
1-- AI test generator — per-repo opt-in flag.
2-- Additive only. Defaults to `false` so the new autopilot task is opt-in:
3-- repo owners must explicitly enable it via /:owner/:repo/settings.
4ALTER TABLE repositories
5 ADD COLUMN IF NOT EXISTS auto_generate_tests boolean NOT NULL DEFAULT false;
Modifiedsrc/db/schema.ts+67−0View fileUnifiedSplit
163163 previewBuildsEnabled: boolean("preview_builds_enabled")
164164 .default(true)
165165 .notNull(),
166 // Migration 0065 — opt-in flag for the AI test generator autopilot task.
167 // Default false (off) because writing tests against unreviewed code can
168 // produce noise; owners must explicitly enable it via repo-settings.
169 autoGenerateTests: boolean("auto_generate_tests")
170 .default(false)
171 .notNull(),
166172 },
167173 (table) => [
168174 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
32833289export type ChatIntegration = typeof chatIntegrations.$inferSelect;
32843290export type NewChatIntegration = typeof chatIntegrations.$inferInsert;
32853291
3292// ---------------------------------------------------------------------------
3293// 0065 — Per-call AI cost tracking. See src/lib/ai-cost-tracker.ts and
3294// src/routes/billing-usage.tsx. Best-effort: insert failures must NEVER
3295// throw out of the Claude caller, so every call site wraps recordAiCost in
3296// try/catch. Cents are computed at insert time from a hardcoded pricing
3297// table so historical aggregates stay stable across price changes.
3298// ---------------------------------------------------------------------------
3299export const aiCostEvents = pgTable(
3300 "ai_cost_events",
3301 {
3302 id: uuid("id").primaryKey().defaultRandom(),
3303 occurredAt: timestamp("occurred_at", { withTimezone: true })
3304 .defaultNow()
3305 .notNull(),
3306 ownerUserId: uuid("owner_user_id").references(() => users.id, {
3307 onDelete: "set null",
3308 }),
3309 repositoryId: uuid("repository_id").references(() => repositories.id, {
3310 onDelete: "set null",
3311 }),
3312 agentSessionId: uuid("agent_session_id").references(
3313 () => agentSessions.id,
3314 { onDelete: "set null" }
3315 ),
3316 model: text("model").notNull(),
3317 inputTokens: integer("input_tokens").default(0).notNull(),
3318 outputTokens: integer("output_tokens").default(0).notNull(),
3319 centsEstimate: integer("cents_estimate").default(0).notNull(),
3320 category: text("category").default("other").notNull(),
3321 sourceId: text("source_id"),
3322 sourceKind: text("source_kind"),
3323 createdAt: timestamp("created_at", { withTimezone: true })
3324 .defaultNow()
3325 .notNull(),
3326 },
3327 (table) => [
3328 index("ai_cost_events_owner_time").on(table.ownerUserId, table.occurredAt),
3329 index("ai_cost_events_repo_time").on(table.repositoryId, table.occurredAt),
3330 index("ai_cost_events_agent_time").on(
3331 table.agentSessionId,
3332 table.occurredAt
3333 ),
3334 index("ai_cost_events_category_time").on(table.category, table.occurredAt),
3335 ]
3336);
3337
3338export type AiCostEvent = typeof aiCostEvents.$inferSelect;
3339export type NewAiCostEvent = typeof aiCostEvents.$inferInsert;
3340
3341export const aiBudgets = pgTable("ai_budgets", {
3342 userId: uuid("user_id")
3343 .primaryKey()
3344 .references(() => users.id, { onDelete: "cascade" }),
3345 monthlyCents: integer("monthly_cents").default(0).notNull(),
3346 updatedAt: timestamp("updated_at", { withTimezone: true })
3347 .defaultNow()
3348 .notNull(),
3349});
3350
3351export type AiBudget = typeof aiBudgets.$inferSelect;
3352export type NewAiBudget = typeof aiBudgets.$inferInsert;
Modifiedsrc/lib/ai-ci-healer.ts+15−0View fileUnifiedSplit
356356 },
357357 ],
358358 });
359 try {
360 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
361 const usage = extractUsage(message);
362 await recordAiCost({
363 repositoryId: run.repositoryId ?? null,
364 model: MODEL_SONNET,
365 inputTokens: usage.input,
366 outputTokens: usage.output,
367 category: "ci_healer",
368 sourceId: runId,
369 sourceKind: "workflow_run",
370 });
371 } catch {
372 /* swallow — best-effort */
373 }
359374 parsed = parseJsonResponse<ClaudeCiResponse>(extractText(message));
360375 } catch (err) {
361376 console.warn(
Addedsrc/lib/ai-cost-tracker.ts+452−0View fileUnifiedSplit
1/**
2 * Per-call AI cost tracker. Every Claude caller in the codebase pipes a
3 * single line through `recordAiCost(...)` after a successful response so
4 * the /billing/usage dashboard can attribute spend back to a user, repo,
5 * agent session, and feature category.
6 *
7 * Design notes:
8 *
9 * - Best-effort. The caller wraps `recordAiCost` in try/catch and the
10 * function itself never throws — a DB blip or a malformed usage
11 * object must NOT escape into the request path.
12 *
13 * - Cents are computed at insert time from a hardcoded pricing table
14 * and persisted on the row, so historical aggregates stay stable
15 * across price changes. Operators can override the table at runtime
16 * by editing `MODEL_PRICING` and re-deploying, or by calling
17 * `setPricingOverride(model, prices)` from a future admin surface
18 * (system-config-backed; intentionally not wired here).
19 *
20 * - Per-1k pricing matches Anthropic's published Claude rates. Numbers
21 * are in CENTS for downstream arithmetic safety — Postgres `int`s
22 * avoid float drift over a month of rollups.
23 *
24 * - Summary helpers (`summarizeCostsForUser`, ...ForRepo, ...ForAgent)
25 * return the shape consumed by `routes/billing-usage.tsx` and by the
26 * `/api/v2/usage/*` JSON endpoints, so the UI and API never diverge.
27 */
28
29import { and, asc, desc, eq, gte, lte, sql } from "drizzle-orm";
30import { db } from "../db";
31import { aiCostEvents } from "../db/schema";
32import type { AiCostEvent } from "../db/schema";
33
34/**
35 * Canonical Claude pricing table (cents per 1k tokens). Keep this aligned
36 * with https://www.anthropic.com/pricing. The keys match the model IDs we
37 * pass to `client.messages.create({ model })`. Unknown models fall back to
38 * `DEFAULT_PRICING` so a new release never silently records $0.
39 */
40export interface ModelPrice {
41 /** USD cents per 1k input tokens. */
42 inputCentsPer1k: number;
43 /** USD cents per 1k output tokens. */
44 outputCentsPer1k: number;
45}
46
47export const MODEL_PRICING: Record<string, ModelPrice> = {
48 // Sonnet 4 (May 2025) — $3 / $15 per 1M tokens.
49 "claude-sonnet-4-20250514": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
50 // Newer Sonnet 4 family checkpoints (4.5, 4.6, 4.7) — same price tier.
51 "claude-sonnet-4-5": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
52 "claude-sonnet-4-6": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
53 "claude-sonnet-4-7": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
54 // Haiku 4.5 — $1 / $5 per 1M tokens.
55 "claude-haiku-4-5-20251001": {
56 inputCentsPer1k: 0.1,
57 outputCentsPer1k: 0.5,
58 },
59 "claude-haiku-4-5": { inputCentsPer1k: 0.1, outputCentsPer1k: 0.5 },
60 // Opus 4 family — $15 / $75 per 1M tokens.
61 "claude-opus-4": { inputCentsPer1k: 1.5, outputCentsPer1k: 7.5 },
62 "claude-opus-4-7": { inputCentsPer1k: 1.5, outputCentsPer1k: 7.5 },
63};
64
65/** Fallback pricing if we get a model id we don't recognise. Conservative
66 * "Sonnet-tier" estimate so unknown spend is at least visible in $$. */
67export const DEFAULT_PRICING: ModelPrice = {
68 inputCentsPer1k: 0.3,
69 outputCentsPer1k: 1.5,
70};
71
72/** Runtime override slot — future /admin/integrations surface can call
73 * this without touching the source file. Not currently wired to a UI. */
74export function setPricingOverride(model: string, prices: ModelPrice): void {
75 MODEL_PRICING[model] = prices;
76}
77
78/** Recognised feature buckets. The dashboard groups + colours by this. */
79export type AiCostCategory =
80 | "ai_review"
81 | "ai_patch"
82 | "ci_healer"
83 | "spec_to_pr"
84 | "standup"
85 | "chat"
86 | "voice"
87 | "test_gen"
88 | "refactor"
89 | "other";
90
91const CATEGORIES: readonly AiCostCategory[] = [
92 "ai_review",
93 "ai_patch",
94 "ci_healer",
95 "spec_to_pr",
96 "standup",
97 "chat",
98 "voice",
99 "test_gen",
100 "refactor",
101 "other",
102];
103
104/**
105 * Compute a cost estimate in CENTS (rounded UP to the next cent so a
106 * tiny call still registers as >0¢ in the dashboard). Pure function;
107 * exported so tests can pin the arithmetic.
108 */
109export function computeCentsForCall(
110 model: string,
111 inputTokens: number,
112 outputTokens: number
113): number {
114 const price = MODEL_PRICING[model] || DEFAULT_PRICING;
115 const inT = Math.max(0, Math.floor(inputTokens || 0));
116 const outT = Math.max(0, Math.floor(outputTokens || 0));
117 const cents =
118 (inT / 1000) * price.inputCentsPer1k +
119 (outT / 1000) * price.outputCentsPer1k;
120 if (cents <= 0) return 0;
121 return Math.max(1, Math.ceil(cents));
122}
123
124export interface RecordAiCostArgs {
125 ownerUserId?: string | null;
126 repositoryId?: string | null;
127 agentSessionId?: string | null;
128 model: string;
129 inputTokens: number;
130 outputTokens: number;
131 category: AiCostCategory;
132 sourceId?: string | null;
133 sourceKind?: string | null;
134}
135
136/**
137 * Insert one cost row. Never throws — DB failures are logged at debug
138 * level and swallowed so the calling AI feature continues unaffected.
139 */
140export async function recordAiCost(args: RecordAiCostArgs): Promise<void> {
141 try {
142 const cents = computeCentsForCall(
143 args.model,
144 args.inputTokens,
145 args.outputTokens
146 );
147 const category: AiCostCategory = CATEGORIES.includes(
148 args.category as AiCostCategory
149 )
150 ? args.category
151 : "other";
152 await db.insert(aiCostEvents).values({
153 ownerUserId: args.ownerUserId ?? null,
154 repositoryId: args.repositoryId ?? null,
155 agentSessionId: args.agentSessionId ?? null,
156 model: args.model || "unknown",
157 inputTokens: Math.max(0, Math.floor(args.inputTokens || 0)),
158 outputTokens: Math.max(0, Math.floor(args.outputTokens || 0)),
159 centsEstimate: cents,
160 category,
161 sourceId: args.sourceId ?? null,
162 sourceKind: args.sourceKind ?? null,
163 });
164 } catch (err) {
165 // Swallow. The dashboard is observational; missing rows are not a
166 // user-visible failure mode.
167 if (process.env.DEBUG_AI_COST === "1") {
168 console.warn(
169 "[ai-cost-tracker] insert failed:",
170 err instanceof Error ? err.message : err
171 );
172 }
173 }
174}
175
176/**
177 * Convenience: read `.usage` from an Anthropic message response and call
178 * `recordAiCost`. Tolerant of unknown shapes — extracts whatever it can
179 * find, defaults the rest to 0.
180 */
181export async function recordFromAnthropicResponse(
182 message: unknown,
183 rest: Omit<RecordAiCostArgs, "inputTokens" | "outputTokens" | "model"> & {
184 model: string;
185 }
186): Promise<void> {
187 const usage = extractUsage(message);
188 await recordAiCost({
189 ...rest,
190 inputTokens: usage.input,
191 outputTokens: usage.output,
192 });
193}
194
195/** Best-effort usage extractor. Anthropic SDK shape:
196 * `{ usage: { input_tokens, output_tokens, ... } }`. Returns zeros on
197 * anything unexpected. */
198export function extractUsage(message: unknown): {
199 input: number;
200 output: number;
201} {
202 if (!message || typeof message !== "object") return { input: 0, output: 0 };
203 const m = message as Record<string, unknown>;
204 const usage = m.usage as Record<string, unknown> | undefined;
205 if (!usage) return { input: 0, output: 0 };
206 const input = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
207 const output =
208 typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
209 return { input, output };
210}
211
212// ---------------------------------------------------------------------------
213// Summary helpers
214// ---------------------------------------------------------------------------
215
216export interface CostSummary {
217 totalCents: number;
218 totalInputTokens: number;
219 totalOutputTokens: number;
220 byCategory: Array<{
221 category: string;
222 cents: number;
223 inputTokens: number;
224 outputTokens: number;
225 }>;
226 byModel: Array<{
227 model: string;
228 cents: number;
229 inputTokens: number;
230 outputTokens: number;
231 }>;
232 byRepo: Array<{
233 repositoryId: string | null;
234 cents: number;
235 inputTokens: number;
236 outputTokens: number;
237 }>;
238 byAgent: Array<{
239 agentSessionId: string | null;
240 cents: number;
241 inputTokens: number;
242 outputTokens: number;
243 }>;
244 byDay: Array<{
245 day: string; // YYYY-MM-DD UTC
246 cents: number;
247 }>;
248}
249
250export interface SummaryWindow {
251 fromDate?: Date;
252 toDate?: Date;
253}
254
255/** Pure aggregator — kept separate from the DB read so tests can pin it
256 * deterministically against synthetic rows. */
257export function aggregateEvents(
258 rows: Pick<
259 AiCostEvent,
260 | "occurredAt"
261 | "model"
262 | "category"
263 | "repositoryId"
264 | "agentSessionId"
265 | "centsEstimate"
266 | "inputTokens"
267 | "outputTokens"
268 >[]
269): CostSummary {
270 const byCat = new Map<
271 string,
272 { cents: number; inputTokens: number; outputTokens: number }
273 >();
274 const byModel = new Map<
275 string,
276 { cents: number; inputTokens: number; outputTokens: number }
277 >();
278 const byRepo = new Map<
279 string | null,
280 { cents: number; inputTokens: number; outputTokens: number }
281 >();
282 const byAgent = new Map<
283 string | null,
284 { cents: number; inputTokens: number; outputTokens: number }
285 >();
286 const byDay = new Map<string, number>();
287 let totalCents = 0;
288 let totalIn = 0;
289 let totalOut = 0;
290 for (const r of rows) {
291 const cents = r.centsEstimate || 0;
292 const inT = r.inputTokens || 0;
293 const outT = r.outputTokens || 0;
294 totalCents += cents;
295 totalIn += inT;
296 totalOut += outT;
297 const bucketAdd = (
298 map: Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>,
299 key: string | null
300 ) => {
301 const cur = map.get(key) || { cents: 0, inputTokens: 0, outputTokens: 0 };
302 cur.cents += cents;
303 cur.inputTokens += inT;
304 cur.outputTokens += outT;
305 map.set(key, cur);
306 };
307 bucketAdd(byCat as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.category);
308 bucketAdd(byModel as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.model);
309 bucketAdd(byRepo, r.repositoryId ?? null);
310 bucketAdd(byAgent, r.agentSessionId ?? null);
311 const day = toUtcDayKey(r.occurredAt);
312 byDay.set(day, (byDay.get(day) || 0) + cents);
313 }
314 return {
315 totalCents,
316 totalInputTokens: totalIn,
317 totalOutputTokens: totalOut,
318 byCategory: Array.from(byCat.entries())
319 .map(([category, v]) => ({ category, ...v }))
320 .sort((a, b) => b.cents - a.cents),
321 byModel: Array.from(byModel.entries())
322 .map(([model, v]) => ({ model, ...v }))
323 .sort((a, b) => b.cents - a.cents),
324 byRepo: Array.from(byRepo.entries())
325 .map(([repositoryId, v]) => ({ repositoryId, ...v }))
326 .sort((a, b) => b.cents - a.cents),
327 byAgent: Array.from(byAgent.entries())
328 .map(([agentSessionId, v]) => ({ agentSessionId, ...v }))
329 .sort((a, b) => b.cents - a.cents),
330 byDay: Array.from(byDay.entries())
331 .map(([day, cents]) => ({ day, cents }))
332 .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)),
333 };
334}
335
336/** Render a Date as a YYYY-MM-DD UTC key. Stable across timezones so day
337 * rollups don't double-count rows around midnight. */
338export function toUtcDayKey(d: Date | string): string {
339 const dt = typeof d === "string" ? new Date(d) : d;
340 const y = dt.getUTCFullYear();
341 const m = String(dt.getUTCMonth() + 1).padStart(2, "0");
342 const day = String(dt.getUTCDate()).padStart(2, "0");
343 return `${y}-${m}-${day}`;
344}
345
346async function loadEvents(
347 whereSql: ReturnType<typeof and> | ReturnType<typeof eq>
348): Promise<AiCostEvent[]> {
349 try {
350 const rows = await db
351 .select()
352 .from(aiCostEvents)
353 .where(whereSql)
354 .orderBy(asc(aiCostEvents.occurredAt));
355 return rows as AiCostEvent[];
356 } catch (err) {
357 if (process.env.DEBUG_AI_COST === "1") {
358 console.warn(
359 "[ai-cost-tracker] loadEvents failed:",
360 err instanceof Error ? err.message : err
361 );
362 }
363 return [];
364 }
365}
366
367function rangeClause(field: typeof aiCostEvents.occurredAt, win: SummaryWindow) {
368 const clauses = [] as Array<ReturnType<typeof gte> | ReturnType<typeof lte>>;
369 if (win.fromDate) clauses.push(gte(field, win.fromDate));
370 if (win.toDate) clauses.push(lte(field, win.toDate));
371 return clauses;
372}
373
374export async function summarizeCostsForUser(
375 userId: string,
376 win: SummaryWindow = {}
377): Promise<CostSummary> {
378 const rows = await loadEvents(
379 and(eq(aiCostEvents.ownerUserId, userId), ...rangeClause(aiCostEvents.occurredAt, win))
380 );
381 return aggregateEvents(rows);
382}
383
384export async function summarizeCostsForRepo(
385 repoId: string,
386 win: SummaryWindow = {}
387): Promise<CostSummary> {
388 const rows = await loadEvents(
389 and(eq(aiCostEvents.repositoryId, repoId), ...rangeClause(aiCostEvents.occurredAt, win))
390 );
391 return aggregateEvents(rows);
392}
393
394export async function summarizeCostsForAgent(
395 sessionId: string,
396 win: SummaryWindow = {}
397): Promise<CostSummary> {
398 const rows = await loadEvents(
399 and(
400 eq(aiCostEvents.agentSessionId, sessionId),
401 ...rangeClause(aiCostEvents.occurredAt, win)
402 )
403 );
404 return aggregateEvents(rows);
405}
406
407// ---------------------------------------------------------------------------
408// Dashboard-level helpers (month rollups, projections, budget reads).
409// ---------------------------------------------------------------------------
410
411/** UTC month start, e.g. 2026-05-01 00:00:00Z for a 2026-05-25 input. */
412export function startOfUtcMonth(d: Date): Date {
413 return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
414}
415
416/** Project month-end cents from elapsed days vs days in the month. */
417export function projectMonthEndCents(
418 centsSoFar: number,
419 now: Date
420): number {
421 const start = startOfUtcMonth(now);
422 const elapsedMs = Math.max(1, now.getTime() - start.getTime());
423 const next = new Date(
424 Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)
425 );
426 const totalMs = next.getTime() - start.getTime();
427 return Math.round((centsSoFar * totalMs) / elapsedMs);
428}
429
430/** Daily average across the elapsed portion of the month. */
431export function dailyAverageCents(centsSoFar: number, now: Date): number {
432 const start = startOfUtcMonth(now);
433 const elapsedDays = Math.max(
434 1,
435 Math.floor((now.getTime() - start.getTime()) / (24 * 3600 * 1000)) + 1
436 );
437 return Math.round(centsSoFar / elapsedDays);
438}
439
440/** Render integer cents as a $1.23 (or $0.04) USD string. */
441export function formatCents(cents: number): string {
442 const sign = cents < 0 ? "-" : "";
443 const abs = Math.abs(cents);
444 const dollars = Math.floor(abs / 100);
445 const remainder = abs % 100;
446 return `${sign}$${dollars.toLocaleString()}.${String(remainder).padStart(2, "0")}`;
447}
448
449/** Quick token formatter — adds thousand separators. */
450export function formatTokens(n: number): string {
451 return Math.max(0, Math.floor(n || 0)).toLocaleString();
452}
Modifiedsrc/lib/ai-patch-generator.ts+13−0View fileUnifiedSplit
299299 { role: "user", content: buildPatchPrompt(finding, filePath, fileContent) },
300300 ],
301301 });
302 try {
303 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
304 const usage = extractUsage(message);
305 await recordAiCost({
306 model: MODEL_SONNET,
307 inputTokens: usage.input,
308 outputTokens: usage.output,
309 category: "ai_patch",
310 sourceKind: "gatetest_finding",
311 });
312 } catch {
313 /* swallow — best-effort */
314 }
302315 const text = extractText(message);
303316 const parsed = parseJsonResponse<ClaudePatchResponse>(text);
304317 if (!parsed) return null;
Modifiedsrc/lib/ai-review.ts+19−1View fileUnifiedSplit
1111import { pullRequests, prComments } from "../db/schema";
1212import { getRepoPath } from "../git/repository";
1313import { config } from "./config";
14import { recordAiCost, extractUsage } from "./ai-cost-tracker";
1415
1516interface ReviewComment {
1617 filePath: string;
5960): Promise<ReviewResult> {
6061 const client = getClient();
6162
63 const REVIEW_MODEL = "claude-sonnet-4-20250514";
6264 const message = await client.messages.create({
63 model: "claude-sonnet-4-20250514",
65 model: REVIEW_MODEL,
6466 max_tokens: 4096,
6567 messages: [
6668 {
106108 const text =
107109 message.content[0].type === "text" ? message.content[0].text : "";
108110
111 // Best-effort cost capture. Caller passes "owner/repo" as repoFullName,
112 // so we can't easily resolve the repo id here; the call site at
113 // `triggerAiReview` records with a repository_id below.
114 try {
115 const usage = extractUsage(message);
116 await recordAiCost({
117 model: REVIEW_MODEL,
118 inputTokens: usage.input,
119 outputTokens: usage.output,
120 category: "ai_review",
121 sourceKind: "pull_request",
122 });
123 } catch {
124 /* never escape — observational only */
125 }
126
109127 try {
110128 // Extract JSON from response (may be wrapped in markdown code block)
111129 const jsonMatch = text.match(/\{[\s\S]*\}/);
Modifiedsrc/lib/ai-standup.ts+13−0View fileUnifiedSplit
458458 max_tokens: 1500,
459459 messages: [{ role: "user", content: buildPrompt(scope, classified) }],
460460 });
461 try {
462 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
463 const usage = extractUsage(message);
464 await recordAiCost({
465 model: MODEL_SONNET,
466 inputTokens: usage.input,
467 outputTokens: usage.output,
468 category: "standup",
469 sourceKind: "standup",
470 });
471 } catch {
472 /* swallow — best-effort */
473 }
461474 const parsed = parseJsonResponse<{
462475 summary?: string;
463476 shippedItems?: unknown;
Addedsrc/lib/ai-test-generator.ts+866−0View fileUnifiedSplit
1/**
2 * AI test generator — when a PR opens, autopilot reads the diff, asks
3 * Claude to write tests for the new code, and either pushes a test
4 * commit onto the same branch or opens a follow-up PR against the PR's
5 * head branch. Every merged PR self-improves the test suite.
6 *
7 * Pipeline per PR:
8 * 1. Resolve the PR row + repo + owner.
9 * 2. Diff `base...head` to find files added/modified. Drop test
10 * files, configs, docs, and binary blobs.
11 * 3. For each surviving source file, ask Claude to write tests
12 * matching whatever framework sibling test files use. The model
13 * returns `{ patches: [{ path, new_content }] }` — the same envelope
14 * `ai-patch-generator.ts` uses, so the write path is shared.
15 * 4. `append-commit` mode — write each patch onto the PR's headBranch.
16 * `follow-up-pr` mode — write onto a fresh `ai-tests/<n>-<ts>`
17 * branch seeded from headBranch, then insert
18 * a new pullRequests row pointing at the new
19 * branch with base = original headBranch.
20 * 5. Mark with label `ai:added-tests` (created on the repo, surfaced
21 * via a marker comment on whichever PR is being decorated).
22 * 6. Audit `ai.tests.added` so operators can review uptake.
23 *
24 * Idempotent — if any prior tick already added the `ai:added-tests`
25 * marker (comment on the PR for append-commit; comment on the original
26 * PR for follow-up-pr) we skip. Callers fire-and-forget; we never throw.
27 */
28
29import { and, eq, like } from "drizzle-orm";
30import type Anthropic from "@anthropic-ai/sdk";
31import { db } from "../db";
32import {
33 labels,
34 prComments,
35 pullRequests,
36 repositories,
37 users,
38} from "../db/schema";
39import {
40 createOrUpdateFileOnBranch,
41 getBlob,
42 getRepoPath,
43 refExists,
44 resolveRef,
45 updateRef,
46} from "../git/repository";
47import { config } from "./config";
48import { audit } from "./notify";
49import {
50 getAnthropic,
51 MODEL_SONNET,
52 extractText,
53 parseJsonResponse,
54} from "./ai-client";
55import { detectLanguage, detectTestFramework } from "./ai-tests";
56
57/** Marker embedded in PR comments so subsequent ticks dedupe cleanly. */
58export const AI_TESTS_MARKER = "<!-- gluecron-ai-tests:added -->";
59
60/** Label name attached (and ensured present on the repo) for tagged PRs. */
61export const AI_TESTS_LABEL = "ai:added-tests";
62
63/** Existing PR-label marker we use to detect spec-generated PRs (avoid recursion). */
64const AI_SPEC_LABEL = "ai:spec-implementation";
65const AI_SPEC_PR_MARKER = "<!-- gluecron:ai-spec-implementation:v1 -->";
66
67/** Default per-PR cap on how many new files we ask Claude to test in one run. */
68export const MAX_FILES_PER_RUN = 5;
69
70/** Hard cap on Claude prompt size — large source files get truncated. */
71const MAX_SOURCE_BYTES_PER_FILE = 24_000;
72
73/** Test-file path heuristics — skip writing tests for things that already are tests. */
74const TEST_PATH_RX = /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[a-z0-9]+$|(^|\/)test_[^/]+\.py$|_test\.go$/i;
75
76/** Path prefixes / extensions we never propose to write tests for. */
77const SKIP_PREFIX = [
78 ".git/",
79 "node_modules/",
80 "dist/",
81 "build/",
82 ".github/",
83 "drizzle/",
84 "public/",
85 "docs/",
86 ".vscode/",
87 ".gluecron/",
88];
89
90/** File extensions worth generating tests for. */
91const CODE_EXT_RX = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts|py|go|rs|java|kt|rb)$/i;
92
93/** Doc/config extensions to skip. */
94const DOC_OR_CONFIG_RX = /\.(md|mdx|txt|json|yml|yaml|toml|ini|env|lock|lockb|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff|woff2|ttf|otf|css|scss)$/i;
95
96// ---------------------------------------------------------------------------
97// Public types
98// ---------------------------------------------------------------------------
99
100export type TestGenMode = "append-commit" | "follow-up-pr";
101
102export interface GenerateTestsForPrArgs {
103 prId: string;
104 mode: TestGenMode;
105 /** Optional Anthropic client override (tests). */
106 client?: Pick<Anthropic, "messages">;
107 /** Optional file-list override (tests). Bypasses git diff scan. */
108 changedFilesOverride?: string[];
109 /** Optional per-file source resolver (tests). Bypasses getBlob. */
110 resolveSource?: (path: string) => Promise<string | null>;
111 /** Optional cap on files per run. */
112 maxFiles?: number;
113}
114
115export interface GenerateTestsForPrResult {
116 ok: boolean;
117 /** Branch the test commit(s) landed on. */
118 branch?: string;
119 /** New PR number when mode='follow-up-pr'. */
120 prNumber?: number;
121 /** Reason for ok=false. */
122 error?: string;
123 /** Number of test files written. */
124 written?: number;
125 /** Whether we short-circuited via the idempotent dedupe. */
126 alreadyDone?: boolean;
127}
128
129interface ClaudeTestPatch {
130 path: string;
131 new_content: string;
132}
133
134interface ClaudeTestResponse {
135 patches?: ClaudeTestPatch[];
136}
137
138interface PrFacts {
139 pr: {
140 id: string;
141 number: number;
142 title: string;
143 body: string | null;
144 baseBranch: string;
145 headBranch: string;
146 repositoryId: string;
147 authorId: string;
148 state: string;
149 };
150 ownerName: string;
151 repoName: string;
152 repoOwnerId: string;
153 defaultBranch: string;
154}
155
156// ---------------------------------------------------------------------------
157// Pure helpers — exported so the test suite can pin invariants
158// ---------------------------------------------------------------------------
159
160/**
161 * Decide whether a given changed file path is worth asking Claude to test.
162 * Drops test files, configs, docs, build output, binaries, and anything
163 * outside the project's source layout heuristic.
164 */
165export function isCandidateSourceFile(path: string): boolean {
166 if (!path || typeof path !== "string") return false;
167 if (path.includes("..")) return false;
168 if (SKIP_PREFIX.some((p) => path.startsWith(p))) return false;
169 if (TEST_PATH_RX.test(path)) return false;
170 if (DOC_OR_CONFIG_RX.test(path)) return false;
171 if (!CODE_EXT_RX.test(path)) return false;
172 return true;
173}
174
175/**
176 * Build the per-file prompt that asks Claude to write tests. Pure so it's
177 * easy to assert against in unit tests.
178 */
179export function buildTestsForPrPrompt(args: {
180 filePath: string;
181 language: string;
182 framework: string;
183 sourceCode: string;
184 prTitle: string;
185}): string {
186 const trimmed =
187 args.sourceCode.length > MAX_SOURCE_BYTES_PER_FILE
188 ? args.sourceCode.slice(0, MAX_SOURCE_BYTES_PER_FILE) +
189 "\n// ... (truncated)"
190 : args.sourceCode;
191 return [
192 "Write tests for the new code below. Match the existing test framework",
193 "the repository already uses — look at the framework hint and write idioms",
194 "that fit (do not introduce a new test runner).",
195 "",
196 `**Pull request:** ${args.prTitle}`,
197 `**Source file:** \`${args.filePath}\``,
198 `**Language:** ${args.language}`,
199 `**Framework:** ${args.framework}`,
200 "",
201 "Source file contents:",
202 "```",
203 trimmed,
204 "```",
205 "",
206 "Respond ONLY with JSON of this exact shape:",
207 "{",
208 ' "patches": [',
209 ' { "path": "path/to/new/test/file", "new_content": "FULL file contents" }',
210 " ]",
211 "}",
212 "",
213 "Rules:",
214 "- Return an empty patches array if you cannot write meaningful tests safely.",
215 "- new_content MUST be the entire file (not a diff).",
216 "- Pick a sensible test-file path that matches the repo's conventions for",
217 ` framework \`${args.framework}\` (e.g. \`src/__tests__/<name>.test.ts\``,
218 " for bun:test, `test_<name>.py` for pytest, `<name>_test.go` for go test).",
219 "- Tests SHOULD compile/import cleanly and exercise the file's public surface.",
220 "- Use realistic assertions; avoid `expect(true).toBe(true)` placeholders.",
221 "- Do not modify the source file — only emit new test files.",
222 ].join("\n");
223}
224
225/**
226 * Compute a unique branch name for follow-up-pr mode. Caller can override
227 * for deterministic test output.
228 */
229export function testsBranchName(prNumber: number, override?: string): string {
230 if (override && override.trim()) return override.trim();
231 return `ai-tests/pr-${prNumber}-${Date.now()}`;
232}
233
234// ---------------------------------------------------------------------------
235// Internal helpers
236// ---------------------------------------------------------------------------
237
238/**
239 * Look up the PR + repo + owner row in one go. Returns null if any link
240 * is missing so callers bail before touching git.
241 */
242async function loadPrFacts(prId: string): Promise<PrFacts | null> {
243 try {
244 const [row] = await db
245 .select({
246 prId: pullRequests.id,
247 prNumber: pullRequests.number,
248 prTitle: pullRequests.title,
249 prBody: pullRequests.body,
250 baseBranch: pullRequests.baseBranch,
251 headBranch: pullRequests.headBranch,
252 repositoryId: pullRequests.repositoryId,
253 authorId: pullRequests.authorId,
254 state: pullRequests.state,
255 ownerName: users.username,
256 repoName: repositories.name,
257 repoOwnerId: repositories.ownerId,
258 defaultBranch: repositories.defaultBranch,
259 })
260 .from(pullRequests)
261 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
262 .innerJoin(users, eq(users.id, repositories.ownerId))
263 .where(eq(pullRequests.id, prId))
264 .limit(1);
265 if (!row) return null;
266 return {
267 pr: {
268 id: row.prId,
269 number: row.prNumber,
270 title: row.prTitle,
271 body: row.prBody,
272 baseBranch: row.baseBranch,
273 headBranch: row.headBranch,
274 repositoryId: row.repositoryId,
275 authorId: row.authorId,
276 state: row.state,
277 },
278 ownerName: row.ownerName,
279 repoName: row.repoName,
280 repoOwnerId: row.repoOwnerId,
281 defaultBranch: row.defaultBranch,
282 };
283 } catch (err) {
284 console.warn(
285 "[ai-test-generator] loadPrFacts failed:",
286 err instanceof Error ? err.message : err
287 );
288 return null;
289 }
290}
291
292/**
293 * Best-effort list of files changed between baseBranch...headBranch.
294 * Returns just paths (no patch text — we re-read each file from the
295 * head ref via getBlob so Claude sees the final state, not the diff).
296 */
297async function listChangedSourceFiles(
298 ownerName: string,
299 repoName: string,
300 baseBranch: string,
301 headBranch: string
302): Promise<string[]> {
303 try {
304 const cwd = getRepoPath(ownerName, repoName);
305 const proc = Bun.spawn(
306 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
307 { cwd, stdout: "pipe", stderr: "pipe" }
308 );
309 const text = await new Response(proc.stdout).text();
310 await proc.exited;
311 return text
312 .split("\n")
313 .map((s) => s.trim())
314 .filter(Boolean);
315 } catch (err) {
316 console.warn(
317 "[ai-test-generator] listChangedSourceFiles failed:",
318 err instanceof Error ? err.message : err
319 );
320 return [];
321 }
322}
323
324/**
325 * List every blob in the repo at `ref`. Used to derive the framework hint
326 * (jest vs vitest vs bun:test vs pytest, etc.) via `detectTestFramework`.
327 */
328async function listRepoFiles(
329 ownerName: string,
330 repoName: string,
331 ref: string,
332 cap = 2000
333): Promise<string[]> {
334 try {
335 const cwd = getRepoPath(ownerName, repoName);
336 const proc = Bun.spawn(
337 ["git", "ls-tree", "-r", "--name-only", ref],
338 { cwd, stdout: "pipe", stderr: "pipe" }
339 );
340 const text = await new Response(proc.stdout).text();
341 await proc.exited;
342 return text
343 .split("\n")
344 .map((s) => s.trim())
345 .filter(Boolean)
346 .slice(0, cap);
347 } catch {
348 return [];
349 }
350}
351
352/**
353 * Look for an existing `ai:added-tests` marker comment on the given PR.
354 * Returns true if found — caller short-circuits.
355 */
356async function alreadyTagged(pullRequestId: string): Promise<boolean> {
357 try {
358 const rows = await db
359 .select({ id: prComments.id })
360 .from(prComments)
361 .where(
362 and(
363 eq(prComments.pullRequestId, pullRequestId),
364 like(prComments.body, `%${AI_TESTS_MARKER}%`)
365 )
366 )
367 .limit(1);
368 return rows.length > 0;
369 } catch {
370 return false;
371 }
372}
373
374/**
375 * Look for an existing follow-up tests PR that targets this PR's head
376 * branch. The dedup is keyed off the PR body marker we always write, so
377 * a previously-opened follow-up never gets re-opened.
378 */
379async function alreadyHasFollowUpPr(
380 repositoryId: string,
381 originalPrNumber: number
382): Promise<boolean> {
383 try {
384 const needle = `${AI_TESTS_MARKER}\nfor PR #${originalPrNumber}`;
385 const rows = await db
386 .select({ id: pullRequests.id })
387 .from(pullRequests)
388 .where(
389 and(
390 eq(pullRequests.repositoryId, repositoryId),
391 like(pullRequests.body, `%${needle}%`)
392 )
393 )
394 .limit(1);
395 return rows.length > 0;
396 } catch {
397 return false;
398 }
399}
400
401/**
402 * Does the PR look AI-generated by spec-to-PR? We avoid recursing on
403 * those (the spec already produced both code + tests in most cases).
404 */
405export function looksLikeSpecPr(prBody: string | null | undefined): boolean {
406 if (!prBody) return false;
407 return prBody.includes(AI_SPEC_PR_MARKER) || prBody.includes(AI_SPEC_LABEL);
408}
409
410/**
411 * Ensure the `ai:added-tests` label row exists on the repo. Best-effort —
412 * label is a UX nicety, not load-bearing. Mirrors `ensurePatchLabel` from
413 * ai-patch-generator.
414 */
415async function ensureTestsLabel(repositoryId: string): Promise<void> {
416 try {
417 await db
418 .insert(labels)
419 .values({
420 repositoryId,
421 name: AI_TESTS_LABEL,
422 color: "#3fb950",
423 description:
424 "Tests auto-generated by Gluecron AI from a pull request diff",
425 })
426 .onConflictDoNothing?.();
427 } catch (err) {
428 console.warn(
429 "[ai-test-generator] ensureTestsLabel failed:",
430 err instanceof Error ? err.message : err
431 );
432 }
433}
434
435/**
436 * Ask Claude for tests. Returns parsed `{ patches }` or null on any
437 * failure (network, parse error, no key). Caller treats null as a skip.
438 */
439async function askClaudeForTests(
440 client: Pick<Anthropic, "messages">,
441 args: {
442 filePath: string;
443 language: string;
444 framework: string;
445 sourceCode: string;
446 prTitle: string;
447 }
448): Promise<ClaudeTestResponse | null> {
449 try {
450 const message = await client.messages.create({
451 model: MODEL_SONNET,
452 max_tokens: 4096,
453 messages: [
454 { role: "user", content: buildTestsForPrPrompt(args) },
455 ],
456 });
457 const text = extractText(message);
458 const parsed = parseJsonResponse<ClaudeTestResponse>(text);
459 if (!parsed) return null;
460 return parsed;
461 } catch (err) {
462 console.warn(
463 "[ai-test-generator] Claude call failed:",
464 err instanceof Error ? err.message : err
465 );
466 return null;
467 }
468}
469
470/**
471 * Seed `branch` from `parentSha` if it doesn't yet exist. Returns true on
472 * success or if it already exists.
473 */
474async function ensureBranchAt(
475 ownerName: string,
476 repoName: string,
477 branch: string,
478 parentSha: string
479): Promise<boolean> {
480 const fullRef = `refs/heads/${branch}`;
481 if (await refExists(ownerName, repoName, fullRef)) return true;
482 return updateRef(ownerName, repoName, fullRef, parentSha);
483}
484
485/**
486 * Drop the marker comment on `pullRequestId` so the next tick's dedupe
487 * sees us. Best-effort — failures are logged but don't stop the run.
488 */
489async function dropMarkerComment(
490 pullRequestId: string,
491 authorId: string,
492 bodySuffix: string
493): Promise<void> {
494 try {
495 await db.insert(prComments).values({
496 pullRequestId,
497 authorId,
498 isAiReview: true,
499 body: `${AI_TESTS_MARKER}\n${bodySuffix}`,
500 });
501 } catch (err) {
502 console.warn(
503 "[ai-test-generator] dropMarkerComment failed:",
504 err instanceof Error ? err.message : err
505 );
506 }
507}
508
509// ---------------------------------------------------------------------------
510// Main entry point
511// ---------------------------------------------------------------------------
512
513/**
514 * Generate tests for a PR. Returns a structured result; never throws.
515 *
516 * `mode='append-commit'`:
517 * Writes each generated test file onto the PR's existing headBranch.
518 * Surfaces the marker as a comment on the original PR.
519 *
520 * `mode='follow-up-pr'`:
521 * Branches off the PR's headBranch into `ai-tests/pr-<n>-<ts>`, writes
522 * each test file, then opens a new PR targeting the original headBranch
523 * as base. Surfaces the marker on both PRs.
524 */
525export async function generateTestsForPr(
526 args: GenerateTestsForPrArgs
527): Promise<GenerateTestsForPrResult> {
528 // 1. Resolve client. Lazy so tests can inject without an API key.
529 let client: Pick<Anthropic, "messages">;
530 if (args.client) {
531 client = args.client;
532 } else {
533 if (!config.anthropicApiKey) {
534 return { ok: false, error: "ANTHROPIC_API_KEY not configured" };
535 }
536 try {
537 client = getAnthropic();
538 } catch {
539 return { ok: false, error: "Failed to construct Anthropic client" };
540 }
541 }
542
543 // 2. Load PR row + repo + owner.
544 const facts = await loadPrFacts(args.prId);
545 if (!facts) return { ok: false, error: "PR not found" };
546
547 // 3. Avoid recursion on spec-driven PRs.
548 if (looksLikeSpecPr(facts.pr.body)) {
549 return { ok: false, error: "PR is AI-generated (ai:spec-implementation); skipping" };
550 }
551
552 // 4. Idempotency — both modes write a marker on the original PR.
553 if (await alreadyTagged(facts.pr.id)) {
554 return { ok: true, alreadyDone: true, written: 0 };
555 }
556 if (
557 args.mode === "follow-up-pr" &&
558 (await alreadyHasFollowUpPr(facts.pr.repositoryId, facts.pr.number))
559 ) {
560 return { ok: true, alreadyDone: true, written: 0 };
561 }
562
563 // 5. Discover changed source files.
564 const allChanged =
565 args.changedFilesOverride ??
566 (await listChangedSourceFiles(
567 facts.ownerName,
568 facts.repoName,
569 facts.pr.baseBranch,
570 facts.pr.headBranch
571 ));
572
573 const candidates = allChanged.filter(isCandidateSourceFile);
574 const cap = Math.max(1, args.maxFiles ?? MAX_FILES_PER_RUN);
575 const sliced = candidates.slice(0, cap);
576
577 if (sliced.length === 0) {
578 return { ok: false, error: "No candidate source files in diff" };
579 }
580
581 // 6. Framework hint via existing detector + repo tree listing.
582 const repoFiles = await listRepoFiles(
583 facts.ownerName,
584 facts.repoName,
585 facts.pr.headBranch
586 );
587
588 // 7. Resolve the head sha — needed both to seed a follow-up branch and
589 // to read source files at the head state.
590 const headSha = await resolveRef(
591 facts.ownerName,
592 facts.repoName,
593 facts.pr.headBranch
594 );
595 if (!headSha) {
596 return { ok: false, error: "Could not resolve head branch sha" };
597 }
598
599 // 8. Determine the branch we'll write commits to.
600 let writeBranch = facts.pr.headBranch;
601 if (args.mode === "follow-up-pr") {
602 writeBranch = testsBranchName(facts.pr.number);
603 const seeded = await ensureBranchAt(
604 facts.ownerName,
605 facts.repoName,
606 writeBranch,
607 headSha
608 );
609 if (!seeded) {
610 return { ok: false, error: `Could not seed branch ${writeBranch}` };
611 }
612 }
613
614 // 9. Per-file Claude loop. Each file's test patches are written
615 // individually so a single failure can't lose all the work.
616 await ensureTestsLabel(facts.pr.repositoryId);
617
618 const written: string[] = [];
619 const skipped: string[] = [];
620 for (const sourcePath of sliced) {
621 const sourceContent =
622 args.resolveSource !== undefined
623 ? await args.resolveSource(sourcePath)
624 : await readSourceContent(
625 facts.ownerName,
626 facts.repoName,
627 facts.pr.headBranch,
628 sourcePath
629 );
630 if (sourceContent == null || sourceContent === "") {
631 skipped.push(sourcePath);
632 continue;
633 }
634
635 const language = detectLanguage(sourcePath);
636 const framework = detectTestFramework(language, repoFiles);
637
638 const claudeRes = await askClaudeForTests(client, {
639 filePath: sourcePath,
640 language,
641 framework,
642 sourceCode: sourceContent,
643 prTitle: facts.pr.title,
644 });
645 if (!claudeRes || !Array.isArray(claudeRes.patches) || claudeRes.patches.length === 0) {
646 skipped.push(sourcePath);
647 continue;
648 }
649
650 for (const patch of claudeRes.patches) {
651 if (
652 !patch ||
653 typeof patch.path !== "string" ||
654 typeof patch.new_content !== "string"
655 ) {
656 continue;
657 }
658 // Safety: only allow new files inside the repo, with a test-file
659 // shaped path. We deliberately reject patches that try to rewrite
660 // the source file (Claude's job is to ADD tests, not edit code).
661 if (patch.path === sourcePath) continue;
662 if (patch.path.includes("..") || patch.path.startsWith("/")) continue;
663
664 const res = await createOrUpdateFileOnBranch({
665 owner: facts.ownerName,
666 name: facts.repoName,
667 branch: writeBranch,
668 filePath: patch.path,
669 bytes: new TextEncoder().encode(patch.new_content),
670 message: `test(ai): add tests for ${sourcePath}`,
671 authorName: "Gluecron AI",
672 authorEmail: "ai@gluecron.com",
673 });
674 if ("error" in res) {
675 skipped.push(patch.path);
676 continue;
677 }
678 written.push(patch.path);
679 }
680 }
681
682 if (written.length === 0) {
683 return {
684 ok: false,
685 error: "Claude produced no usable test patches",
686 written: 0,
687 };
688 }
689
690 // 10. Decorate the right PR(s) with the marker + label citation.
691 if (args.mode === "append-commit") {
692 await dropMarkerComment(
693 facts.pr.id,
694 facts.repoOwnerId,
695 [
696 `Applied label: \`${AI_TESTS_LABEL}\``,
697 `Added ${written.length} test file${written.length === 1 ? "" : "s"} on \`${writeBranch}\`:`,
698 ...written.map((p) => `- \`${p}\``),
699 ].join("\n")
700 );
701
702 await audit({
703 userId: null,
704 action: "ai.tests.added",
705 repositoryId: facts.pr.repositoryId,
706 targetType: "pull_request",
707 targetId: facts.pr.id,
708 metadata: {
709 mode: args.mode,
710 prNumber: facts.pr.number,
711 branch: writeBranch,
712 written,
713 },
714 });
715
716 return {
717 ok: true,
718 branch: writeBranch,
719 written: written.length,
720 };
721 }
722
723 // follow-up-pr mode — open the new PR targeting the original headBranch.
724 let newPrNumber: number | null = null;
725 let newPrId: string | null = null;
726 try {
727 const body = renderFollowUpPrBody({
728 originalPrNumber: facts.pr.number,
729 branch: writeBranch,
730 written,
731 });
732 const [pr] = await db
733 .insert(pullRequests)
734 .values({
735 repositoryId: facts.pr.repositoryId,
736 authorId: facts.repoOwnerId,
737 title: `[tests] +tests for #${facts.pr.number}`,
738 body,
739 baseBranch: facts.pr.headBranch,
740 headBranch: writeBranch,
741 isDraft: false,
742 })
743 .returning({ number: pullRequests.number, id: pullRequests.id });
744 if (pr) {
745 newPrNumber = pr.number;
746 newPrId = pr.id;
747 }
748 } catch (err) {
749 console.warn(
750 "[ai-test-generator] follow-up PR insert failed:",
751 err instanceof Error ? err.message : err
752 );
753 }
754
755 // Always drop the marker on the ORIGINAL PR so future ticks dedupe.
756 await dropMarkerComment(
757 facts.pr.id,
758 facts.repoOwnerId,
759 [
760 `Applied label: \`${AI_TESTS_LABEL}\``,
761 newPrNumber
762 ? `Opened follow-up tests PR #${newPrNumber} → \`${writeBranch}\``
763 : `Tests pushed to branch \`${writeBranch}\` (PR insert failed).`,
764 ...written.map((p) => `- \`${p}\``),
765 ].join("\n")
766 );
767
768 // Also drop a marker on the follow-up PR itself so direct lookups work.
769 if (newPrId) {
770 await dropMarkerComment(
771 newPrId,
772 facts.repoOwnerId,
773 `Applied label: \`${AI_TESTS_LABEL}\``
774 );
775 }
776
777 await audit({
778 userId: null,
779 action: "ai.tests.added",
780 repositoryId: facts.pr.repositoryId,
781 targetType: "pull_request",
782 targetId: facts.pr.id,
783 metadata: {
784 mode: args.mode,
785 prNumber: facts.pr.number,
786 followUpPrNumber: newPrNumber,
787 branch: writeBranch,
788 written,
789 },
790 });
791
792 return {
793 ok: true,
794 branch: writeBranch,
795 prNumber: newPrNumber ?? undefined,
796 written: written.length,
797 };
798}
799
800/**
801 * Render the PR body for the follow-up tests PR. Pure helper exported
802 * for tests.
803 */
804export function renderFollowUpPrBody(args: {
805 originalPrNumber: number;
806 branch: string;
807 written: string[];
808}): string {
809 const files = args.written.map((p) => `- \`${p}\``).join("\n");
810 return [
811 `${AI_TESTS_MARKER}`,
812 `for PR #${args.originalPrNumber}`,
813 "",
814 `## +tests for #${args.originalPrNumber}`,
815 "",
816 "Gluecron AI scanned the source changes in this PR's branch and added",
817 "tests that match the repository's existing test framework.",
818 "",
819 `Branch: \`${args.branch}\``,
820 "",
821 "### Files added",
822 files || "_(none)_",
823 "",
824 "---",
825 "",
826 `Labels: \`${AI_TESTS_LABEL}\``,
827 "",
828 "_Auto-generated by Gluecron AI. Review every assertion before merging._",
829 ].join("\n");
830}
831
832/**
833 * Read a file from the bare repo at the given ref, returning its text or
834 * null when missing/binary/too-large.
835 */
836async function readSourceContent(
837 ownerName: string,
838 repoName: string,
839 ref: string,
840 path: string
841): Promise<string | null> {
842 try {
843 const blob = await getBlob(ownerName, repoName, ref, path);
844 if (!blob) return null;
845 if (blob.isBinary) return null;
846 return blob.content;
847 } catch {
848 return null;
849 }
850}
851
852/**
853 * Test-only re-exports of internal helpers.
854 */
855export const __test = {
856 loadPrFacts,
857 listChangedSourceFiles,
858 listRepoFiles,
859 alreadyTagged,
860 alreadyHasFollowUpPr,
861 ensureTestsLabel,
862 ensureBranchAt,
863 readSourceContent,
864 askClaudeForTests,
865 dropMarkerComment,
866};
Addedsrc/lib/chat-bot.ts+893−0View fileUnifiedSplit
1/**
2 * Chat-bot command dispatcher — backs the Slack and Discord slash-command
3 * endpoints (`/api/v2/integrations/slack/events`,
4 * `/api/v2/integrations/discord/interactions`) and the outbound notification
5 * formatter consumed by `src/lib/chat-notifier.ts`.
6 *
7 * Design rules:
8 * - This file only does parsing + dispatch + presentation. The actual ops
9 * (listing PRs, creating issues, etc.) reuse the same DB queries the REST
10 * surface in `src/routes/api-v2.ts` uses. We pull from the same drizzle
11 * tables so a future change to the schema doesn't drift between Slack
12 * and the web.
13 * - Slash commands map 1:1 to a small command set: `pr list`, `pr open`,
14 * `issue list`, `issue create`, `spec ship`, `chat`, `help`. Anything
15 * unrecognised falls through to a help block.
16 * - All formatters return Slack `blocks` or Discord `embeds`, never raw
17 * strings — chat surfaces strip plain text aggressively and we want
18 * consistent rendering.
19 * - Signature verification for both providers lives here so the endpoint
20 * handlers stay thin and the test fixtures can exercise the core.
21 */
22
23import { eq, and, desc } from "drizzle-orm";
24import { db } from "../db";
25import {
26 users,
27 repositories,
28 issues,
29 pullRequests,
30} from "../db/schema";
31import type { PullRequest, Issue } from "../db/schema";
32
33// ---------------------------------------------------------------------------
34// Types
35// ---------------------------------------------------------------------------
36
37export type ChatKind = "slack" | "discord" | "teams";
38
39export interface ParsedCommand {
40 /** Bare command verb, lowercased — e.g. "pr", "issue", "spec", "chat", "help". */
41 command: string;
42 /** Subcommand or argv[1] — e.g. "list", "open", "create", "ship". May be "". */
43 subcommand: string;
44 /** Whitespace-trimmed rest of the input. May be "". */
45 args: string;
46 /** Original raw text (post-trim). Kept for echo / debug. */
47 raw: string;
48}
49
50export interface SlackBlock {
51 type: string;
52 text?: { type: string; text: string; [k: string]: unknown };
53 fields?: Array<{ type: string; text: string }>;
54 elements?: Array<Record<string, unknown>>;
55 [k: string]: unknown;
56}
57
58export interface DiscordEmbed {
59 title?: string;
60 description?: string;
61 color?: number;
62 url?: string;
63 fields?: Array<{ name: string; value: string; inline?: boolean }>;
64 footer?: { text: string };
65 timestamp?: string;
66}
67
68export interface BotResponseSlack {
69 kind: "slack";
70 blocks: SlackBlock[];
71 /** Slack expects `response_type: in_channel` for public posts. */
72 response_type?: "in_channel" | "ephemeral";
73 text?: string;
74}
75
76export interface BotResponseDiscord {
77 kind: "discord";
78 embeds: DiscordEmbed[];
79 /** When set, restricts visibility to the invoking user. */
80 ephemeral?: boolean;
81 content?: string;
82}
83
84export type BotResponse = BotResponseSlack | BotResponseDiscord;
85
86// ---------------------------------------------------------------------------
87// Parsers
88// ---------------------------------------------------------------------------
89
90/**
91 * Slack slash commands arrive as `application/x-www-form-urlencoded` with the
92 * whole user input squashed into one `text` field. We split on whitespace and
93 * synthesise the {command, subcommand, args} triple. Quotes in `args` are
94 * preserved verbatim — downstream code strips them if it needs to.
95 */
96export function parseSlackSlashCommand(text: string): ParsedCommand {
97 const raw = (text ?? "").trim();
98 if (!raw) return { command: "help", subcommand: "", args: "", raw };
99
100 const parts = raw.split(/\s+/);
101 const command = (parts[0] ?? "").toLowerCase();
102 const subcommand = (parts[1] ?? "").toLowerCase();
103
104 // For `pr open <title>` / `issue create <title>` / `spec ship <desc>` we
105 // want the rest verbatim, including quotes — the model that consumes it
106 // (spec→PR, ai-chat) handles its own escaping.
107 const rest = raw.slice(parts[0]!.length).trimStart();
108 // If the second token looks like a subcommand, strip it off; otherwise the
109 // first token IS the only thing and args is whatever followed it.
110 const hasSubcommand =
111 subcommand !== "" &&
112 /^[a-z][a-z0-9-]*$/.test(subcommand) &&
113 SUBCOMMAND_VERBS.has(`${command} ${subcommand}`);
114
115 const args = hasSubcommand
116 ? rest.slice(parts[1]!.length).trimStart()
117 : rest;
118
119 return {
120 command,
121 subcommand: hasSubcommand ? subcommand : "",
122 args: stripWrappingQuotes(args),
123 raw,
124 };
125}
126
127/**
128 * Discord delivers slash commands as a structured interaction body. We expect
129 * the upstream registration to declare one top-level command (`gluecron`)
130 * with a subcommand group ("pr", "issue", "spec", "chat") and a single
131 * string option carrying the free-text payload. This parser is lenient: if
132 * the caller passed only `data.name` and the literal text fell into an
133 * option we'll still find it.
134 */
135export function parseDiscordSlashCommand(
136 interaction: DiscordInteractionLike
137): ParsedCommand {
138 const root = interaction?.data?.name?.toLowerCase() ?? "";
139 const opts = interaction?.data?.options ?? [];
140
141 // The Discord SDK nests subcommands: data.options[0] = { name: 'pr',
142 // type: 1, options: [{ name: 'list', type: 1, options: [...] }] } or
143 // similar. We walk one level deep to find the verb + payload.
144 let command = root === "gluecron" ? "" : root;
145 let subcommand = "";
146 let args = "";
147
148 if (root === "gluecron" && opts.length > 0) {
149 const first = opts[0]!;
150 command = (first.name ?? "").toLowerCase();
151 if (Array.isArray(first.options) && first.options.length > 0) {
152 const sub = first.options[0]!;
153 if (sub.type === 1 || sub.type === 2) {
154 // subcommand / subcommand_group
155 subcommand = (sub.name ?? "").toLowerCase();
156 const payload = (sub.options ?? []).find(
157 (o: DiscordOptionLike) => typeof o.value === "string"
158 );
159 if (payload) args = String(payload.value ?? "").trim();
160 } else if (typeof sub.value === "string") {
161 // First-level string option (no subcommand layer).
162 args = String(sub.value ?? "").trim();
163 }
164 }
165 } else {
166 // Bare command — pull args from the first string option.
167 const payload = opts.find((o) => typeof o.value === "string");
168 if (payload) args = String(payload.value ?? "").trim();
169 }
170
171 const raw = [command, subcommand, args].filter(Boolean).join(" ").trim();
172 return {
173 command: command || "help",
174 subcommand,
175 args: stripWrappingQuotes(args),
176 raw,
177 };
178}
179
180const SUBCOMMAND_VERBS = new Set<string>([
181 "pr list",
182 "pr open",
183 "issue list",
184 "issue create",
185 "spec ship",
186]);
187
188function stripWrappingQuotes(s: string): string {
189 if (!s) return s;
190 if (
191 (s.startsWith('"') && s.endsWith('"')) ||
192 (s.startsWith("'") && s.endsWith("'")) ||
193 (s.startsWith("“") && s.endsWith("”"))
194 ) {
195 return s.slice(1, -1);
196 }
197 return s;
198}
199
200// ---------------------------------------------------------------------------
201// Signature verification
202// ---------------------------------------------------------------------------
203
204/**
205 * Slack signs every request with `v0=hex(hmac-sha256(secret, "v0:" + ts +
206 * ":" + body))` in `X-Slack-Signature`. Timestamp is in `X-Slack-Request-
207 * Timestamp` — we reject anything older than 5 minutes to deflect replay.
208 */
209export async function verifySlackSignature(opts: {
210 signingSecret: string;
211 timestamp: string;
212 signature: string;
213 body: string;
214 /** Inject Date.now()/1000 in tests; defaults to real time. */
215 now?: number;
216}): Promise<boolean> {
217 if (!opts.signingSecret || !opts.timestamp || !opts.signature) return false;
218
219 const tsNum = parseInt(opts.timestamp, 10);
220 if (!Number.isFinite(tsNum)) return false;
221 const nowSec = opts.now ?? Math.floor(Date.now() / 1000);
222 if (Math.abs(nowSec - tsNum) > 300) return false;
223
224 const base = `v0:${opts.timestamp}:${opts.body}`;
225 const key = await crypto.subtle.importKey(
226 "raw",
227 new TextEncoder().encode(opts.signingSecret),
228 { name: "HMAC", hash: "SHA-256" },
229 false,
230 ["sign"]
231 );
232 const sigBytes = await crypto.subtle.sign(
233 "HMAC",
234 key,
235 new TextEncoder().encode(base)
236 );
237 const expected =
238 "v0=" +
239 Array.from(new Uint8Array(sigBytes))
240 .map((b) => b.toString(16).padStart(2, "0"))
241 .join("");
242 return constantTimeEqual(expected, opts.signature);
243}
244
245/**
246 * Discord signs every interaction with Ed25519. The signature is hex; the
247 * message is `timestamp + body`. The public key (the bot's
248 * "Application Public Key") goes into `signing_secret` at install time.
249 *
250 * Bun's WebCrypto supports Ed25519 natively.
251 */
252export async function verifyDiscordSignature(opts: {
253 publicKeyHex: string;
254 signatureHex: string;
255 timestamp: string;
256 body: string;
257}): Promise<boolean> {
258 if (!opts.publicKeyHex || !opts.signatureHex || !opts.timestamp) return false;
259 try {
260 const pubBytes = hexToBytes(opts.publicKeyHex);
261 const sigBytes = hexToBytes(opts.signatureHex);
262 if (pubBytes.length !== 32 || sigBytes.length !== 64) return false;
263 const msg = new TextEncoder().encode(opts.timestamp + opts.body);
264 const key = await crypto.subtle.importKey(
265 "raw",
266 pubBytes,
267 { name: "Ed25519" } as AlgorithmIdentifier,
268 false,
269 ["verify"]
270 );
271 return await crypto.subtle.verify(
272 { name: "Ed25519" } as AlgorithmIdentifier,
273 key,
274 sigBytes,
275 msg
276 );
277 } catch {
278 return false;
279 }
280}
281
282function hexToBytes(hex: string): Uint8Array {
283 const clean = hex.trim();
284 if (clean.length % 2 !== 0) throw new Error("odd hex length");
285 const out = new Uint8Array(clean.length / 2);
286 for (let i = 0; i < out.length; i++) {
287 const byte = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
288 if (Number.isNaN(byte)) throw new Error("bad hex");
289 out[i] = byte;
290 }
291 return out;
292}
293
294function constantTimeEqual(a: string, b: string): boolean {
295 if (a.length !== b.length) return false;
296 let r = 0;
297 for (let i = 0; i < a.length; i++) {
298 r |= a.charCodeAt(i) ^ b.charCodeAt(i);
299 }
300 return r === 0;
301}
302
303// ---------------------------------------------------------------------------
304// Command handler
305// ---------------------------------------------------------------------------
306
307export interface HandleBotCommandInput {
308 kind: ChatKind;
309 /** Gluecron user id (resolved from the install record). May be null when
310 * the workspace has no linked user — we fall back to "help" with an install
311 * nudge in that case. */
312 userId: string | null;
313 command: string;
314 subcommand: string;
315 args: string;
316}
317
318/**
319 * Dispatch a parsed slash command. Always returns a BotResponse — errors
320 * become user-visible blocks/embeds rather than HTTP failures (Slack/Discord
321 * surface non-2xx as "service unavailable" which is uglier than a bot reply).
322 */
323export async function handleBotCommand(
324 input: HandleBotCommandInput
325): Promise<BotResponse> {
326 const verb = `${input.command} ${input.subcommand}`.trim();
327
328 if (!input.userId && verb !== "help" && input.command !== "help") {
329 return renderHelp(input.kind, {
330 note:
331 "This workspace isn't linked to a Gluecron account yet. Visit /settings/integrations on Gluecron to finish setup.",
332 });
333 }
334
335 try {
336 switch (verb) {
337 case "help":
338 case "":
339 return renderHelp(input.kind);
340 case "pr list":
341 return await cmdPrList(input);
342 case "pr open":
343 return await cmdPrOpen(input);
344 case "issue list":
345 return await cmdIssueList(input);
346 case "issue create":
347 return await cmdIssueCreate(input);
348 case "spec ship":
349 return await cmdSpecShip(input);
350 default:
351 // Single-verb commands.
352 if (input.command === "chat") return await cmdChat(input);
353 if (input.command === "help") return renderHelp(input.kind);
354 return renderHelp(input.kind, {
355 note: `Unknown command: \`${verb || input.command}\`.`,
356 });
357 }
358 } catch (err) {
359 const msg = err instanceof Error ? err.message : String(err);
360 return renderError(input.kind, msg);
361 }
362}
363
364// ---------------------------------------------------------------------------
365// Command implementations — thin wrappers around the same drizzle queries
366// the REST API uses. Heavy logic stays in lib/ai-chat / lib/autopilot.
367// ---------------------------------------------------------------------------
368
369async function cmdPrList(input: HandleBotCommandInput): Promise<BotResponse> {
370 // `args` is "owner/repo" (or empty → most-recent across the user's repos).
371 const target = await resolveTargetRepo(input.userId!, input.args);
372 if (!target) {
373 return renderError(
374 input.kind,
375 "No repo found. Usage: `pr list owner/repo`."
376 );
377 }
378
379 const rows = await db
380 .select()
381 .from(pullRequests)
382 .where(
383 and(
384 eq(pullRequests.repositoryId, target.repo.id),
385 eq(pullRequests.state, "open")
386 )
387 )
388 .orderBy(desc(pullRequests.createdAt))
389 .limit(10);
390
391 return formatPrList(input.kind, target.label, rows);
392}
393
394async function cmdPrOpen(input: HandleBotCommandInput): Promise<BotResponse> {
395 // Format: `pr open owner/repo title…` — but to keep slash-command UX
396 // simple we also accept just `pr open title…` and use the user's default
397 // repo (most-recent push).
398 const target = await resolveTargetRepo(input.userId!, input.args);
399 const title = target
400 ? input.args.slice(target.matchedSegment.length).trim()
401 : input.args;
402 if (!title) {
403 return renderError(input.kind, "Usage: `pr open owner/repo Add dark mode`.");
404 }
405 if (!target) {
406 return renderError(input.kind, "Couldn't resolve a repo. Add `owner/repo`.");
407 }
408
409 // We don't open the PR straight from chat (head/base branches need
410 // disambiguation); instead we return a deep-link to the compose page on
411 // the website with the title pre-filled.
412 const url = `/${target.owner.username}/${target.repo.name}/compare?title=${encodeURIComponent(title)}`;
413 return formatLinkCard(
414 input.kind,
415 `Open PR: ${title}`,
416 `Continue in Gluecron — head/base branch picker is on the next screen.`,
417 url
418 );
419}
420
421async function cmdIssueList(
422 input: HandleBotCommandInput
423): Promise<BotResponse> {
424 const target = await resolveTargetRepo(input.userId!, input.args);
425 if (!target) {
426 return renderError(
427 input.kind,
428 "No repo found. Usage: `issue list owner/repo`."
429 );
430 }
431
432 const rows = await db
433 .select()
434 .from(issues)
435 .where(
436 and(
437 eq(issues.repositoryId, target.repo.id),
438 eq(issues.state, "open")
439 )
440 )
441 .orderBy(desc(issues.createdAt))
442 .limit(10);
443
444 return formatIssueList(input.kind, target.label, rows);
445}
446
447async function cmdIssueCreate(
448 input: HandleBotCommandInput
449): Promise<BotResponse> {
450 const target = await resolveTargetRepo(input.userId!, input.args);
451 const title = target
452 ? input.args.slice(target.matchedSegment.length).trim()
453 : input.args;
454 if (!title) {
455 return renderError(
456 input.kind,
457 "Usage: `issue create owner/repo Bug in foo()`."
458 );
459 }
460 if (!target) {
461 return renderError(input.kind, "Couldn't resolve a repo. Add `owner/repo`.");
462 }
463
464 const [row] = await db
465 .insert(issues)
466 .values({
467 repositoryId: target.repo.id,
468 authorId: input.userId!,
469 title,
470 })
471 .returning();
472
473 const num = row?.number ?? 0;
474 return formatLinkCard(
475 input.kind,
476 `Issue #${num} opened — ${title}`,
477 `${target.label}`,
478 `/${target.owner.username}/${target.repo.name}/issues/${num}`
479 );
480}
481
482async function cmdSpecShip(
483 input: HandleBotCommandInput
484): Promise<BotResponse> {
485 // spec ship is a deep-link into the autopilot spec-to-PR flow — the
486 // actual PR-authoring happens server-side via lib/autopilot-spec-to-pr,
487 // and we don't want to block the slash command on it.
488 const description = input.args.trim();
489 if (!description) {
490 return renderError(
491 input.kind,
492 'Usage: `spec ship "add dark mode to the dashboard"`.'
493 );
494 }
495 const url = `/specs/new?description=${encodeURIComponent(description)}`;
496 return formatLinkCard(
497 input.kind,
498 "Spec → PR",
499 `Autopilot will draft a plan, open branches, and ship a PR for: ${description}`,
500 url
501 );
502}
503
504async function cmdChat(input: HandleBotCommandInput): Promise<BotResponse> {
505 const message = input.args.trim();
506 if (!message) {
507 return renderError(input.kind, "Usage: `chat How do I run the tests?`.");
508 }
509 // We return a deep-link rather than running the LLM inline — chat surfaces
510 // expect a sub-3s response and Anthropic latency blows past that.
511 const url = `/ask?q=${encodeURIComponent(message)}`;
512 return formatLinkCard(
513 input.kind,
514 "Ask Gluecron",
515 message,
516 url
517 );
518}
519
520// ---------------------------------------------------------------------------
521// Repo resolution
522// ---------------------------------------------------------------------------
523
524interface ResolvedTarget {
525 owner: { id: string; username: string };
526 repo: { id: string; name: string };
527 /** Substring of `args` that matched "owner/repo" — used to trim title. */
528 matchedSegment: string;
529 /** Display label: "owner/repo". */
530 label: string;
531}
532
533/**
534 * Pull "owner/repo" out of the args head. If absent, fall back to the
535 * caller's most-recently-updated repo (handy for ad-hoc Slack usage).
536 */
537async function resolveTargetRepo(
538 userId: string,
539 args: string
540): Promise<ResolvedTarget | null> {
541 const head = args.split(/\s+/, 1)[0] ?? "";
542 const m = head.match(/^([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)$/);
543 if (m) {
544 const [ownerName, repoName] = [m[1]!, m[2]!];
545 const [owner] = await db
546 .select({ id: users.id, username: users.username })
547 .from(users)
548 .where(eq(users.username, ownerName))
549 .limit(1);
550 if (!owner) return null;
551 const [repo] = await db
552 .select({ id: repositories.id, name: repositories.name })
553 .from(repositories)
554 .where(
555 and(
556 eq(repositories.ownerId, owner.id),
557 eq(repositories.name, repoName)
558 )
559 )
560 .limit(1);
561 if (!repo) return null;
562 return {
563 owner,
564 repo,
565 matchedSegment: head,
566 label: `${owner.username}/${repo.name}`,
567 };
568 }
569
570 // Fallback — most-recent repo owned by this user.
571 const [owner] = await db
572 .select({ id: users.id, username: users.username })
573 .from(users)
574 .where(eq(users.id, userId))
575 .limit(1);
576 if (!owner) return null;
577 const [repo] = await db
578 .select({ id: repositories.id, name: repositories.name })
579 .from(repositories)
580 .where(eq(repositories.ownerId, owner.id))
581 .orderBy(desc(repositories.updatedAt))
582 .limit(1);
583 if (!repo) return null;
584 return {
585 owner,
586 repo,
587 matchedSegment: "",
588 label: `${owner.username}/${repo.name}`,
589 };
590}
591
592// ---------------------------------------------------------------------------
593// Formatters
594// ---------------------------------------------------------------------------
595
596export function formatPrList(
597 kind: ChatKind,
598 repoLabel: string,
599 rows: PullRequest[]
600): BotResponse {
601 if (kind === "slack" || kind === "teams") {
602 const blocks: SlackBlock[] = [
603 {
604 type: "header",
605 text: { type: "plain_text", text: `Open PRs — ${repoLabel}` },
606 },
607 ];
608 if (rows.length === 0) {
609 blocks.push({
610 type: "section",
611 text: { type: "mrkdwn", text: "_No open pull requests._" },
612 });
613 } else {
614 for (const pr of rows) {
615 blocks.push({
616 type: "section",
617 text: {
618 type: "mrkdwn",
619 text: `*#${pr.number}* — ${escapeSlack(pr.title)}\n_${pr.headBranch}${pr.baseBranch}_${pr.isDraft ? " · draft" : ""}`,
620 },
621 });
622 }
623 }
624 return { kind: "slack", blocks, response_type: "in_channel" };
625 }
626 const embed: DiscordEmbed = {
627 title: `Open PRs — ${repoLabel}`,
628 color: 0x8c6dff,
629 fields: rows.length
630 ? rows.map((pr) => ({
631 name: `#${pr.number} ${pr.title}`,
632 value: `${pr.headBranch}${pr.baseBranch}${pr.isDraft ? " · draft" : ""}`,
633 }))
634 : [{ name: "No open pull requests", value: "—" }],
635 };
636 return { kind: "discord", embeds: [embed] };
637}
638
639export function formatIssueList(
640 kind: ChatKind,
641 repoLabel: string,
642 rows: Issue[]
643): BotResponse {
644 if (kind === "slack" || kind === "teams") {
645 const blocks: SlackBlock[] = [
646 {
647 type: "header",
648 text: { type: "plain_text", text: `Open issues — ${repoLabel}` },
649 },
650 ];
651 if (rows.length === 0) {
652 blocks.push({
653 type: "section",
654 text: { type: "mrkdwn", text: "_No open issues._" },
655 });
656 } else {
657 for (const it of rows) {
658 blocks.push({
659 type: "section",
660 text: {
661 type: "mrkdwn",
662 text: `*#${it.number}* — ${escapeSlack(it.title)}`,
663 },
664 });
665 }
666 }
667 return { kind: "slack", blocks, response_type: "in_channel" };
668 }
669 const embed: DiscordEmbed = {
670 title: `Open issues — ${repoLabel}`,
671 color: 0x36c5d6,
672 fields: rows.length
673 ? rows.map((it) => ({
674 name: `#${it.number}`,
675 value: it.title,
676 }))
677 : [{ name: "No open issues", value: "—" }],
678 };
679 return { kind: "discord", embeds: [embed] };
680}
681
682export function renderHelp(
683 kind: ChatKind,
684 opts?: { note?: string }
685): BotResponse {
686 const lines = [
687 "`/gluecron pr list owner/repo` — open PRs",
688 '`/gluecron pr open owner/repo "title"` — deep-link to compose a PR',
689 "`/gluecron issue list owner/repo` — open issues",
690 '`/gluecron issue create owner/repo "title"` — file a new issue',
691 '`/gluecron spec ship "description"` — autopilot drafts a PR',
692 '`/gluecron chat "question"` — ask Gluecron AI',
693 "`/gluecron help` — this message",
694 ];
695 if (kind === "slack" || kind === "teams") {
696 const blocks: SlackBlock[] = [
697 {
698 type: "header",
699 text: { type: "plain_text", text: "Gluecron slash commands" },
700 },
701 ];
702 if (opts?.note) {
703 blocks.push({
704 type: "section",
705 text: { type: "mrkdwn", text: `:warning: ${opts.note}` },
706 });
707 }
708 blocks.push({
709 type: "section",
710 text: { type: "mrkdwn", text: lines.join("\n") },
711 });
712 return { kind: "slack", blocks, response_type: "ephemeral" };
713 }
714 const embed: DiscordEmbed = {
715 title: "Gluecron slash commands",
716 description: (opts?.note ? `${opts.note}\n\n` : "") + lines.join("\n"),
717 color: 0x8c6dff,
718 };
719 return { kind: "discord", embeds: [embed], ephemeral: true };
720}
721
722export function renderError(kind: ChatKind, message: string): BotResponse {
723 if (kind === "slack" || kind === "teams") {
724 return {
725 kind: "slack",
726 response_type: "ephemeral",
727 blocks: [
728 {
729 type: "section",
730 text: { type: "mrkdwn", text: `:x: ${escapeSlack(message)}` },
731 },
732 ],
733 };
734 }
735 return {
736 kind: "discord",
737 ephemeral: true,
738 embeds: [
739 { title: "Error", description: message, color: 0xf87171 },
740 ],
741 };
742}
743
744function formatLinkCard(
745 kind: ChatKind,
746 title: string,
747 description: string,
748 url: string
749): BotResponse {
750 if (kind === "slack" || kind === "teams") {
751 return {
752 kind: "slack",
753 blocks: [
754 {
755 type: "section",
756 text: {
757 type: "mrkdwn",
758 text: `*${escapeSlack(title)}*\n${escapeSlack(description)}\n<${url}|Open in Gluecron>`,
759 },
760 },
761 ],
762 response_type: "in_channel",
763 };
764 }
765 return {
766 kind: "discord",
767 embeds: [
768 {
769 title,
770 description,
771 url,
772 color: 0x8c6dff,
773 },
774 ],
775 };
776}
777
778/**
779 * Slack uses a tiny markdown subset — only `*bold*`, `_italic_`, and
780 * `<url|text>` links matter. Escape the three reserved characters so user
781 * titles don't accidentally hijack the formatter.
782 */
783function escapeSlack(s: string): string {
784 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
785}
786
787// ---------------------------------------------------------------------------
788// Discord interaction surface — minimal shape so we don't pull the SDK.
789// ---------------------------------------------------------------------------
790
791export interface DiscordOptionLike {
792 name?: string;
793 type?: number;
794 value?: unknown;
795 options?: DiscordOptionLike[];
796}
797
798export interface DiscordInteractionLike {
799 type?: number;
800 data?: {
801 name?: string;
802 options?: DiscordOptionLike[];
803 };
804 guild_id?: string;
805 channel_id?: string;
806 member?: { user?: { id?: string } };
807 user?: { id?: string };
808}
809
810// ---------------------------------------------------------------------------
811// Outbound notification formatter — used by chat-notifier.ts.
812// ---------------------------------------------------------------------------
813
814export interface OutboundEvent {
815 /** "pr.opened" | "pr.merged" | "issue.opened" | "ai.review" | … */
816 event: string;
817 repo: string; // "owner/repo"
818 title: string;
819 url: string; // absolute URL into Gluecron
820 body?: string; // optional summary / AI digest
821 actor?: string;
822}
823
824/**
825 * Render an outbound event as either Slack blocks or Discord embeds. The
826 * caller posts the returned payload to the integration's webhook_url.
827 */
828export function formatOutboundEvent(
829 kind: ChatKind,
830 evt: OutboundEvent
831): Record<string, unknown> {
832 const headline = `[${evt.repo}] ${evt.title}`;
833 if (kind === "slack" || kind === "teams") {
834 const blocks: SlackBlock[] = [
835 {
836 type: "section",
837 text: {
838 type: "mrkdwn",
839 text: `*${escapeSlack(evt.event)}* — <${evt.url}|${escapeSlack(headline)}>`,
840 },
841 },
842 ];
843 if (evt.body) {
844 blocks.push({
845 type: "section",
846 text: { type: "mrkdwn", text: escapeSlack(truncate(evt.body, 1500)) },
847 });
848 }
849 if (evt.actor) {
850 blocks.push({
851 type: "context",
852 elements: [
853 { type: "mrkdwn", text: `by ${escapeSlack(evt.actor)}` },
854 ],
855 });
856 }
857 return { blocks };
858 }
859 const embed: DiscordEmbed = {
860 title: headline,
861 url: evt.url,
862 description: evt.body ? truncate(evt.body, 1800) : undefined,
863 color: colorForEvent(evt.event),
864 footer: evt.actor ? { text: `by ${evt.actor}` } : undefined,
865 timestamp: new Date().toISOString(),
866 };
867 return { embeds: [embed] };
868}
869
870function colorForEvent(event: string): number {
871 if (event.startsWith("pr.merge")) return 0x34d399;
872 if (event.startsWith("pr.")) return 0x8c6dff;
873 if (event.startsWith("issue.")) return 0x36c5d6;
874 if (event.startsWith("ai.")) return 0xfacc15;
875 return 0x9ca3af;
876}
877
878function truncate(s: string, n: number): string {
879 return s.length <= n ? s : s.slice(0, n - 1) + "…";
880}
881
882// ---------------------------------------------------------------------------
883// Test exports
884// ---------------------------------------------------------------------------
885
886export const __test = {
887 stripWrappingQuotes,
888 hexToBytes,
889 constantTimeEqual,
890 colorForEvent,
891 truncate,
892 escapeSlack,
893};
Addedsrc/lib/chat-notifier.ts+148−0View fileUnifiedSplit
1/**
2 * Outbound chat notifications — pipes PR / issue / AI-review events into
3 * Slack, Discord, and Teams via the existing `webhook_deliveries` retry
4 * queue (NO parallel queue).
5 *
6 * How it reuses the existing queue:
7 * - The retry queue (`src/lib/webhook-delivery.ts`) requires a `webhooks`
8 * row to point at. We therefore lazily create one synthetic "shadow"
9 * webhook per (repo, integration) on first use, with
10 * `events='chat-bridge'` so the user-facing /settings/webhooks UI can
11 * filter it out (`routes/webhooks.tsx` already does).
12 * - Subsequent events for the same (repo, integration) reuse the same
13 * shadow row. `enqueueWebhookDelivery` then schedules retries with
14 * the standard exponential backoff (30s → 6h → dead after 6 attempts).
15 *
16 * Why a shadow row rather than a separate table:
17 * - One source of truth for retries, signatures, and the worker.
18 * - Slack/Discord don't actually consume the `X-Gluecron-Signature`
19 * header, but having it set costs nothing and is harmless.
20 *
21 * Public API:
22 * - notifyChatChannels(ownerUserId, repositoryId, repoLabel, event)
23 * - Fire-and-forget; errors are swallowed/logged so a notification
24 * outage can never block a PR merge or issue create.
25 */
26
27import { and, eq } from "drizzle-orm";
28import { db } from "../db";
29import {
30 chatIntegrations,
31 webhooks,
32 type ChatIntegration,
33} from "../db/schema";
34import {
35 enqueueWebhookDelivery,
36 drainPendingDeliveries,
37} from "./webhook-delivery";
38import { formatOutboundEvent, type ChatKind, type OutboundEvent } from "./chat-bot";
39
40const SHADOW_EVENT = "chat-bridge";
41
42/**
43 * Fan out a single event to every enabled chat integration owned by
44 * `ownerUserId`. The event is rendered per-kind (Slack blocks vs Discord
45 * embeds) and queued via the shared `webhook_deliveries` table.
46 *
47 * Never throws — failures are logged but swallowed.
48 */
49export async function notifyChatChannels(opts: {
50 ownerUserId: string;
51 repositoryId: string;
52 event: OutboundEvent;
53}): Promise<void> {
54 try {
55 const integrations = await db
56 .select()
57 .from(chatIntegrations)
58 .where(
59 and(
60 eq(chatIntegrations.ownerUserId, opts.ownerUserId),
61 eq(chatIntegrations.enabled, true)
62 )
63 );
64
65 if (integrations.length === 0) return;
66
67 let enqueued = 0;
68 for (const integ of integrations) {
69 if (!integ.webhookUrl) continue;
70 const kind = integ.kind as ChatKind;
71 if (kind !== "slack" && kind !== "discord" && kind !== "teams") continue;
72
73 const payload = formatOutboundEvent(kind, opts.event);
74 const shadowId = await ensureShadowWebhook(
75 opts.repositoryId,
76 integ
77 );
78 if (!shadowId) continue;
79
80 const id = await enqueueWebhookDelivery({
81 webhookId: shadowId,
82 secret: integ.signingSecret,
83 event: opts.event.event,
84 payload,
85 });
86 if (id) {
87 enqueued++;
88 // Touch last_used_at — best-effort.
89 db.update(chatIntegrations)
90 .set({ lastUsedAt: new Date() })
91 .where(eq(chatIntegrations.id, integ.id))
92 .catch(() => {});
93 }
94 }
95
96 if (enqueued > 0) {
97 void drainPendingDeliveries().catch((err) => {
98 console.error("[chat-notifier] kick drain failed:", err);
99 });
100 }
101 } catch (err) {
102 console.error("[chat-notifier] notify failed:", err);
103 }
104}
105
106/**
107 * Find-or-create the synthetic webhook row that pipes events for a given
108 * (repo, integration) pair through the retry queue. Returns the row id, or
109 * null on insert failure.
110 *
111 * We key on URL+repository — if a user re-installs the bot with the same
112 * webhook URL the existing shadow row is reused so retry stats don't reset.
113 */
114async function ensureShadowWebhook(
115 repositoryId: string,
116 integ: ChatIntegration
117): Promise<string | null> {
118 if (!integ.webhookUrl) return null;
119 try {
120 const existing = await db
121 .select({ id: webhooks.id })
122 .from(webhooks)
123 .where(
124 and(
125 eq(webhooks.repositoryId, repositoryId),
126 eq(webhooks.url, integ.webhookUrl),
127 eq(webhooks.events, SHADOW_EVENT)
128 )
129 )
130 .limit(1);
131 if (existing[0]) return existing[0].id;
132
133 const [row] = await db
134 .insert(webhooks)
135 .values({
136 repositoryId,
137 url: integ.webhookUrl,
138 secret: integ.signingSecret,
139 events: SHADOW_EVENT,
140 isActive: true,
141 })
142 .returning({ id: webhooks.id });
143 return row?.id ?? null;
144 } catch (err) {
145 console.error("[chat-notifier] ensure shadow webhook failed:", err);
146 return null;
147 }
148}
Modifiedsrc/lib/repo-chat.ts+38−0View fileUnifiedSplit
543543 });
544544
545545 // The SDK's stream object is itself async-iterable over events.
546 // Anthropic emits `message_start` / `message_delta` events that carry
547 // usage data — sample those so we can attribute cost without buffering
548 // the whole response.
549 let inputTokens = 0;
550 let outputTokens = 0;
546551 for await (const event of stream as AsyncIterable<unknown>) {
552 const ev = event as Record<string, unknown> | null;
553 if (ev && typeof ev === "object") {
554 // message_start.message.usage.input_tokens
555 const msg = (ev as { message?: { usage?: { input_tokens?: number; output_tokens?: number } } }).message;
556 if (msg && msg.usage) {
557 if (typeof msg.usage.input_tokens === "number")
558 inputTokens = msg.usage.input_tokens;
559 if (typeof msg.usage.output_tokens === "number")
560 outputTokens = msg.usage.output_tokens;
561 }
562 // message_delta.usage.output_tokens (incremental output tokens)
563 const usage = (ev as { usage?: { input_tokens?: number; output_tokens?: number } }).usage;
564 if (usage) {
565 if (typeof usage.input_tokens === "number")
566 inputTokens = usage.input_tokens;
567 if (typeof usage.output_tokens === "number")
568 outputTokens = usage.output_tokens;
569 }
570 }
547571 const delta = extractTextDelta(event);
548572 if (delta) yield delta;
549573 }
574
575 // Record cost AFTER the stream completes so output_tokens is final.
576 try {
577 const { recordAiCost } = await import("./ai-cost-tracker");
578 await recordAiCost({
579 model: MODEL_SONNET,
580 inputTokens,
581 outputTokens,
582 category: "chat",
583 sourceKind: "repo_chat",
584 });
585 } catch {
586 /* swallow — best-effort */
587 }
550588}
551589
552590function extractTextDelta(event: unknown): string {
Modifiedsrc/lib/spec-ai.ts+15−0View fileUnifiedSplit
326326 system: systemPrompt,
327327 messages: [{ role: "user", content: userPrompt }],
328328 });
329 try {
330 const { recordAiCost, extractUsage } = await import(
331 "./ai-cost-tracker"
332 );
333 const usage = extractUsage(message);
334 await recordAiCost({
335 model,
336 inputTokens: usage.input,
337 outputTokens: usage.output,
338 category: "spec_to_pr",
339 sourceKind: "spec",
340 });
341 } catch {
342 /* swallow — best-effort */
343 }
329344 rawText = "";
330345 for (const block of message.content) {
331346 if (block.type === "text") {
Modifiedsrc/lib/voice-to-pr.ts+15−0View fileUnifiedSplit
269269 max_tokens: 1024,
270270 messages: [{ role: "user", content: prompt }],
271271 });
272 try {
273 const { recordAiCost, extractUsage } = await import(
274 "./ai-cost-tracker"
275 );
276 const usage = extractUsage(msg);
277 await recordAiCost({
278 model: MODEL_SONNET,
279 inputTokens: usage.input,
280 outputTokens: usage.output,
281 category: "voice",
282 sourceKind: "voice_transcript",
283 });
284 } catch {
285 /* swallow — best-effort */
286 }
272287 const text = extractText(msg);
273288 const parsed = safeParseJson(text);
274289 return {
Addedsrc/routes/integrations-chat.ts+292−0View fileUnifiedSplit
1/**
2 * Chat-bot endpoints — Slack events, Discord interactions, install
3 * callbacks. Mounted on `/api/v2/integrations/{slack,discord}/*`.
4 *
5 * Surface:
6 * POST /slack/events — Slack Events API + slash commands
7 * POST /discord/interactions — Discord interactions (slash commands)
8 * POST /slack/install — OAuth completion (placeholder)
9 * POST /discord/install — OAuth completion (placeholder)
10 *
11 * Verification:
12 * - Slack: X-Slack-Signature HMAC-SHA256 over `v0:<ts>:<body>`. The
13 * signing secret comes from the chat_integrations row that matches the
14 * incoming team_id.
15 * - Discord: X-Signature-Ed25519 + X-Signature-Timestamp. The Application
16 * Public Key is stored in chat_integrations.signing_secret. We look it
17 * up by guild_id when present, otherwise we accept any row of
18 * kind='discord' that verifies (multi-tenancy is exact-match on the
19 * public key, so this is safe).
20 *
21 * Dispatch:
22 * - Both endpoints decode the slash-command, call handleBotCommand(), and
23 * return the formatted blocks/embeds inline. We never await long ops
24 * here — anything that would block returns a deep-link instead (see
25 * cmdSpecShip / cmdChat in chat-bot.ts).
26 */
27
28import { Hono } from "hono";
29import { eq, and } from "drizzle-orm";
30import { db } from "../db";
31import { chatIntegrations } from "../db/schema";
32import {
33 parseSlackSlashCommand,
34 parseDiscordSlashCommand,
35 verifySlackSignature,
36 verifyDiscordSignature,
37 handleBotCommand,
38 type DiscordInteractionLike,
39} from "../lib/chat-bot";
40
41const r = new Hono();
42
43// ---------------------------------------------------------------------------
44// Slack — Events API + slash commands hit the same endpoint.
45// ---------------------------------------------------------------------------
46
47r.post("/api/v2/integrations/slack/events", async (c) => {
48 const body = await c.req.text();
49 const sig = c.req.header("X-Slack-Signature") ?? "";
50 const ts = c.req.header("X-Slack-Request-Timestamp") ?? "";
51
52 // url_verification challenge arrives as JSON BEFORE the integration is
53 // saved, so we can't reach into chat_integrations for a secret. We MAY
54 // accept it without verification because the response (`challenge`) is
55 // exactly what Slack sent us — it carries no privileged data. Slack's
56 // own docs recommend skipping signature on url_verification.
57 if (looksLikeJson(body)) {
58 try {
59 const parsed = JSON.parse(body) as {
60 type?: string;
61 challenge?: string;
62 };
63 if (parsed.type === "url_verification" && parsed.challenge) {
64 return c.json({ challenge: parsed.challenge });
65 }
66 } catch {
67 /* fall through */
68 }
69 }
70
71 // Slash commands and event subscriptions are form-encoded; pull team_id
72 // out to find the right signing secret.
73 const params = new URLSearchParams(body);
74 const teamId = params.get("team_id") ?? "";
75
76 // Find every Slack integration that could possibly own this request — we
77 // need the signing_secret to verify. We try each in turn (typical user
78 // has one Slack workspace).
79 const candidates = await db
80 .select()
81 .from(chatIntegrations)
82 .where(
83 and(
84 eq(chatIntegrations.kind, "slack"),
85 teamId
86 ? eq(chatIntegrations.teamId, teamId)
87 : eq(chatIntegrations.kind, "slack")
88 )
89 );
90
91 let matched = null;
92 for (const integ of candidates) {
93 if (!integ.signingSecret) continue;
94 const ok = await verifySlackSignature({
95 signingSecret: integ.signingSecret,
96 timestamp: ts,
97 signature: sig,
98 body,
99 });
100 if (ok) {
101 matched = integ;
102 break;
103 }
104 }
105 if (!matched) {
106 return c.json({ error: "invalid_signature" }, 401);
107 }
108
109 // Slash command path: dispatch + return blocks inline.
110 const text = params.get("text") ?? "";
111 const parsed = parseSlackSlashCommand(text);
112 const response = await handleBotCommand({
113 kind: "slack",
114 userId: matched.ownerUserId,
115 command: parsed.command,
116 subcommand: parsed.subcommand,
117 args: parsed.args,
118 });
119 return c.json(response);
120});
121
122// ---------------------------------------------------------------------------
123// Discord — interactions endpoint (Ed25519-signed)
124// ---------------------------------------------------------------------------
125
126r.post("/api/v2/integrations/discord/interactions", async (c) => {
127 const body = await c.req.text();
128 const sig = c.req.header("X-Signature-Ed25519") ?? "";
129 const ts = c.req.header("X-Signature-Timestamp") ?? "";
130
131 if (!sig || !ts) {
132 return c.json({ error: "missing_signature" }, 401);
133 }
134
135 // Look up by guild_id if present in body; else scan all discord rows
136 // (typical install volume is small per user — the constant-time verify
137 // ensures attackers can't enumerate).
138 let guildId: string | null = null;
139 try {
140 const parsed = JSON.parse(body) as { guild_id?: string };
141 guildId = parsed.guild_id ?? null;
142 } catch {
143 /* not JSON → fail below */
144 }
145
146 const candidates = guildId
147 ? await db
148 .select()
149 .from(chatIntegrations)
150 .where(
151 and(
152 eq(chatIntegrations.kind, "discord"),
153 eq(chatIntegrations.teamId, guildId)
154 )
155 )
156 : await db
157 .select()
158 .from(chatIntegrations)
159 .where(eq(chatIntegrations.kind, "discord"));
160
161 let matched = null;
162 for (const integ of candidates) {
163 if (!integ.signingSecret) continue;
164 const ok = await verifyDiscordSignature({
165 publicKeyHex: integ.signingSecret,
166 signatureHex: sig,
167 timestamp: ts,
168 body,
169 });
170 if (ok) {
171 matched = integ;
172 break;
173 }
174 }
175 if (!matched) return c.json({ error: "invalid_signature" }, 401);
176
177 let interaction: DiscordInteractionLike;
178 try {
179 interaction = JSON.parse(body) as DiscordInteractionLike;
180 } catch {
181 return c.json({ error: "bad_json" }, 400);
182 }
183
184 // PING (type 1) → PONG (type 1). Discord uses this every few minutes.
185 if (interaction.type === 1) {
186 return c.json({ type: 1 });
187 }
188
189 const parsed = parseDiscordSlashCommand(interaction);
190 const response = await handleBotCommand({
191 kind: "discord",
192 userId: matched.ownerUserId,
193 command: parsed.command,
194 subcommand: parsed.subcommand,
195 args: parsed.args,
196 });
197
198 // CHANNEL_MESSAGE_WITH_SOURCE (type 4).
199 if (response.kind === "discord") {
200 return c.json({
201 type: 4,
202 data: {
203 embeds: response.embeds,
204 content: response.content,
205 flags: response.ephemeral ? 1 << 6 : 0,
206 },
207 });
208 }
209 // Defensive — handleBotCommand should always return the matching kind.
210 return c.json({ type: 4, data: { content: "ok" } });
211});
212
213// ---------------------------------------------------------------------------
214// Install / OAuth completion stubs.
215//
216// These accept either:
217// * a JSON body with { team_id, channel_id, webhook_url, signing_secret }
218// for direct manual installs from /settings/integrations;
219// * an OAuth `code` exchange (when a real Slack/Discord app is wired up,
220// this code path is the one to extend).
221//
222// Auth: requires a Gluecron session cookie or PAT; we use the api-v2
223// surface's auth (apiAuth runs at the basePath). For the stub we trust the
224// caller's `ownerUserId` to be the session user.
225// ---------------------------------------------------------------------------
226
227interface InstallBody {
228 team_id?: string;
229 channel_id?: string;
230 webhook_url?: string;
231 signing_secret?: string;
232}
233
234async function handleInstall(
235 kind: "slack" | "discord",
236 c: import("hono").Context
237) {
238 // Minimal auth — read the session cookie / Bearer via api-v2 middleware
239 // (we're mounted under the same prefix). The shared apiAuth middleware
240 // populates c.get("user"); fall through to 401 if absent.
241 // To avoid pulling in the middleware import cycle here, we check both.
242 const user = (c.get as (k: string) => unknown)("user") as
243 | { id: string }
244 | undefined;
245 if (!user?.id) return c.json({ error: "auth_required" }, 401);
246
247 let body: InstallBody = {};
248 try {
249 body = (await c.req.json()) as InstallBody;
250 } catch {
251 /* allow empty body for placeholder */
252 }
253 const webhookUrl = (body.webhook_url ?? "").trim();
254 if (!webhookUrl) {
255 return c.json(
256 { error: "missing_webhook_url", hint: "POST a JSON body with webhook_url" },
257 400
258 );
259 }
260
261 const [row] = await db
262 .insert(chatIntegrations)
263 .values({
264 ownerUserId: user.id,
265 kind,
266 teamId: body.team_id?.trim() || null,
267 channelId: body.channel_id?.trim() || null,
268 webhookUrl,
269 signingSecret: body.signing_secret?.trim() || null,
270 enabled: true,
271 })
272 .onConflictDoNothing()
273 .returning();
274
275 return c.json({ ok: true, integration: row ?? null }, 201);
276}
277
278r.post("/api/v2/integrations/slack/install", (c) => handleInstall("slack", c));
279r.post("/api/v2/integrations/discord/install", (c) =>
280 handleInstall("discord", c)
281);
282
283// ---------------------------------------------------------------------------
284// Helpers
285// ---------------------------------------------------------------------------
286
287function looksLikeJson(body: string): boolean {
288 const t = body.trimStart();
289 return t.startsWith("{") || t.startsWith("[");
290}
291
292export default r;
Modifiedsrc/routes/webhooks.tsx+7−1View fileUnifiedSplit
494494 .limit(1);
495495 if (!repo) return c.notFound();
496496
497 const hooks = await db
497 // Exclude `chat-bridge` shadow rows — those are created lazily by
498 // src/lib/chat-notifier.ts to pipe events through the existing
499 // webhook_deliveries retry queue, and showing them here would let
500 // users accidentally delete a Slack/Discord install from the wrong
501 // surface (the source of truth lives at /settings/integrations).
502 const allHooks = await db
498503 .select()
499504 .from(webhooks)
500505 .where(eq(webhooks.repositoryId, repo.id));
506 const hooks = allHooks.filter((h) => h.events !== "chat-bridge");
501507
502508 return c.html(
503509 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
Modifiedsrc/views/landing.tsx+834−1View fileUnifiedSplit
12411241}catch(_){}})();
12421242`.trim();
12431243
1244// ============================================================
1245// Land2030 — 2030 homepage prelude (closed-loop showcase).
1246// ============================================================
1247//
1248// Renders BEFORE the existing LandingHero so every L10/U1/Q1/M1
1249// regression assertion keeps passing. CSS is scoped under
1250// `.land-2030-*` to avoid colliding with the older `.landing-*`
1251// styles. Inline SVG only — no extra deps.
1252
1253const ClosedLoopDiagram: FC = () => (
1254 <svg
1255 viewBox="0 0 1100 280"
1256 class="land-2030-loop-svg"
1257 role="img"
1258 aria-label="Closed loop: Spec to Code to AI Review to Tests to Merge to Deploy to Monitor to Patch"
1259 >
1260 <defs>
1261 <linearGradient id="land2030LoopLine" x1="0" y1="0" x2="1" y2="0">
1262 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.0" />
1263 <stop offset="20%" stop-color="#8c6dff" stop-opacity="0.6" />
1264 <stop offset="80%" stop-color="#36c5d6" stop-opacity="0.6" />
1265 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.0" />
1266 </linearGradient>
1267 <radialGradient id="land2030LoopNode" cx="0.5" cy="0.5" r="0.5">
1268 <stop offset="0%" stop-color="#a48bff" stop-opacity="0.95" />
1269 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.85" />
1270 </radialGradient>
1271 </defs>
1272 {/* connecting curve */}
1273 <path
1274 d="M 60 140 C 220 40, 880 40, 1040 140 C 880 240, 220 240, 60 140 Z"
1275 fill="none"
1276 stroke="url(#land2030LoopLine)"
1277 stroke-width="2"
1278 class="land-2030-loop-path"
1279 />
1280 {[
1281 { x: 70, y: 140, label: "Spec" },
1282 { x: 215, y: 70, label: "Code" },
1283 { x: 410, y: 50, label: "AI Review" },
1284 { x: 605, y: 50, label: "Tests" },
1285 { x: 800, y: 70, label: "Merge" },
1286 { x: 950, y: 140, label: "Deploy" },
1287 { x: 800, y: 210, label: "Monitor" },
1288 { x: 410, y: 230, label: "Patch" },
1289 ].map((n) => (
1290 <g class="land-2030-loop-node">
1291 <circle cx={n.x} cy={n.y} r="22" fill="url(#land2030LoopNode)" />
1292 <circle
1293 cx={n.x}
1294 cy={n.y}
1295 r="22"
1296 fill="none"
1297 stroke="rgba(255,255,255,0.18)"
1298 stroke-width="1"
1299 />
1300 <text
1301 x={n.x}
1302 y={n.y + (n.y > 140 ? 48 : -32)}
1303 text-anchor="middle"
1304 class="land-2030-loop-text"
1305 fill="currentColor"
1306 >
1307 {n.label}
1308 </text>
1309 </g>
1310 ))}
1311 </svg>
1312);
1313
1314interface Land2030CardProps {
1315 icon: any;
1316 title: string;
1317 desc: string;
1318 href: string;
1319 cta?: string;
1320}
1321const Land2030Card: FC<Land2030CardProps> = ({ icon, title, desc, href, cta }) => (
1322 <a href={href} class="land-2030-card">
1323 <div class="land-2030-card-icon" aria-hidden="true">
1324 {icon}
1325 </div>
1326 <h3 class="land-2030-card-title">{title}</h3>
1327 <p class="land-2030-card-desc">{desc}</p>
1328 <span class="land-2030-card-cta">
1329 {cta ?? "Try it"}
1330 <span aria-hidden="true">{" →"}</span>
1331 </span>
1332 </a>
1333);
1334
1335interface Land2030FeatureItem {
1336 title: string;
1337 desc: string;
1338 href: string;
1339 status: "live" | "beta" | "soon";
1340}
1341const LAND_2030_FEATURES: Land2030FeatureItem[] = [
1342 { title: "Spec-to-PR", desc: "Write English. Ship code.", href: "/specs", status: "live" },
1343 { title: "Voice-to-PR", desc: "Talk. Ship code.", href: "/voice-to-pr", status: "live" },
1344 { title: "Repo chat", desc: "Rubber-duck with a semantic index of your repo.", href: "/ask", status: "live" },
1345 { title: "AI CI healer", desc: "Broken CI? Claude opens a fix PR.", href: "/inbox", status: "live" },
1346 { title: "AI patch generator", desc: "Security finding → patch PR, signed by Claude.", href: "/code-scanning", status: "live" },
1347 { title: "AI proactive monitor", desc: "Claude opens issues unprompted when it spots smells.", href: "/standups", status: "live" },
1348 { title: "AI commit messages", desc: "`gluecron commit` writes a great message for the staged diff.", href: "/help", status: "live" },
1349 { title: "AI release notes", desc: "Claude reads merged PRs and writes the changelog.", href: "/ai-changelog", status: "live" },
1350 { title: "Multi-repo refactor", desc: "One English request → coordinated PRs across N repos.", href: "/refactors", status: "live" },
1351 { title: "Migration assistant", desc: "Major-version upgrades drafted PR-by-PR.", href: "/migration-assistant", status: "live" },
1352 { title: "AI test generator", desc: "Every PR auto-gets tests for the new diff.", href: "/ai-tests", status: "beta" },
1353 { title: "PR slash commands", desc: "/merge, /rebase, /explain, /test — Claude runs them.", href: "/help", status: "live" },
1354 { title: "Live co-editing on PRs", desc: "Figma-style cursors on PR descriptions and reviews.", href: "/pulls", status: "beta" },
1355 { title: "Branch preview URLs", desc: "Every push → a sharable preview URL.", href: "/previews", status: "live" },
1356 { title: "Agent multiplayer", desc: "Per-agent sessions, budgets, and branch namespacing.", href: "/settings/agents", status: "live" },
1357 { title: "Continuous semantic index", desc: "Push-time embeddings keep search and chat fresh.", href: "/semantic-search", status: "live" },
1358 { title: "VS Code extension", desc: "Inbox, PRs, and repo chat — in your editor.", href: "/help", status: "soon" },
1359 { title: "Slack / Discord bot", desc: "Mentions, reviews, deploys — in your team chat.", href: "/help", status: "soon" },
1360];
1361
1362const Land2030: FC = () => (
1363 <>
1364 <style dangerouslySetInnerHTML={{ __html: land2030Css }} />
1365 <div class="land-2030-root">
1366 {/* ---------- 2030 HERO ---------- */}
1367 <section class="land-2030-hero" aria-label="Gluecron 2030">
1368 <div class="land-2030-hairline" aria-hidden="true" />
1369 <div class="land-2030-orb" aria-hidden="true" />
1370 <div class="land-2030-hero-inner">
1371 <div class="land-2030-eyebrow">
1372 <span class="land-2030-pulse" aria-hidden="true" />
1373 Gluecron — built for 2030
1374 </div>
1375 <h1 class="land-2030-display">
1376 <span class="land-2030-grad-1">Write</span>{" "}
1377 <span class="land-2030-grad-2">English.</span>{" "}
1378 <span class="land-2030-grad-3">Ship</span>{" "}
1379 <span class="land-2030-grad-4">code.</span>{" "}
1380 <span class="land-2030-grad-5">Gluecron.</span>
1381 </h1>
1382 <p class="land-2030-sub">
1383 The git platform built for the era when AI ships most of the code.
1384 </p>
1385 <div class="land-2030-cta-row">
1386 <a href="/register" class="btn btn-primary btn-xl land-2030-cta-primary">
1387 Start free
1388 <span aria-hidden="true">{" →"}</span>
1389 </a>
1390 <a href="#land-2030-loop" class="btn btn-xl land-2030-cta-secondary">
1391 Watch the loop
1392 <span aria-hidden="true">{" ↓"}</span>
1393 </a>
1394 </div>
1395 </div>
1396 </section>
1397
1398 {/* ---------- THE CLOSED LOOP ---------- */}
1399 <section id="land-2030-loop" class="land-2030-section">
1400 <div class="land-2030-section-head">
1401 <div class="land-2030-eyebrow land-2030-eyebrow-mini">The closed loop</div>
1402 <h2 class="land-2030-h2">One platform. No glue code.</h2>
1403 <p class="land-2030-lede">
1404 Spec → Code → AI Review → Tests → Merge → Deploy → Monitor → Patch.
1405 All on Gluecron. No GitHub + Copilot + Vercel + Sentry stitching
1406 needed.
1407 </p>
1408 </div>
1409 <div class="land-2030-loop-wrap">
1410 <ClosedLoopDiagram />
1411 </div>
1412 </section>
1413
1414 {/* ---------- THE 6 THINGS NOBODY ELSE CAN DO ---------- */}
1415 <section class="land-2030-section">
1416 <div class="land-2030-section-head">
1417 <div class="land-2030-eyebrow land-2030-eyebrow-mini">Unfair advantages</div>
1418 <h2 class="land-2030-h2">What Gluecron does that nobody else can.</h2>
1419 </div>
1420 <div class="land-2030-card-grid">
1421 <Land2030Card
1422 href="/voice-to-pr"
1423 title="Voice-to-PR"
1424 desc="Speak the change you want. Claude opens the PR. Works on your commute."
1425 icon={
1426 <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 10v2a7 7 0 0 0 14 0v-2" /><path d="M12 19v3" /></svg>
1427 }
1428 />
1429 <Land2030Card
1430 href="/specs"
1431 title="Spec-to-PR"
1432 desc="Write the spec in plain English. Claude implements it, opens a PR, and asks for review."
1433 icon={
1434 <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /><path d="M9 13h6M9 17h4" /></svg>
1435 }
1436 />
1437 <Land2030Card
1438 href="/workflows"
1439 title="AI CI self-healing"
1440 desc="Tests go red? Claude reads the log, finds the cause, and pushes the fix to your PR branch."
1441 icon={
1442 <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
1443 }
1444 />
1445 <Land2030Card
1446 href="/refactors"
1447 title="Multi-repo refactor agent"
1448 desc="One English request → coordinated PRs across every repo that uses the symbol."
1449 icon={
1450 <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="18" cy="6" r="3" /><circle cx="12" cy="18" r="3" /><path d="M6 9v3a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V9" /></svg>
1451 }
1452 />
1453 <Land2030Card
1454 href="/ask"
1455 title="Repo chat with semantic search"
1456 desc="Continuous push-time embeddings. Ask the repo anything; it cites real files and commits."
1457 icon={
1458 <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /><circle cx="9" cy="10" r="1" fill="currentColor" /><circle cx="13" cy="10" r="1" fill="currentColor" /><circle cx="17" cy="10" r="1" fill="currentColor" /></svg>
1459 }
1460 />
1461 <Land2030Card
1462 href="/pulls"
1463 title="Per-PR live co-editing"
1464 desc="Figma-style cursors and presence on PR descriptions and reviews. Goodbye stale tabs."
1465 icon={
1466 <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /></svg>
1467 }
1468 />
1469 </div>
1470 </section>
1471
1472 {/* ---------- GLOBAL DASHBOARDS ---------- */}
1473 <section class="land-2030-section">
1474 <div class="land-2030-section-head">
1475 <div class="land-2030-eyebrow land-2030-eyebrow-mini">Mission control</div>
1476 <h2 class="land-2030-h2">Every signal, one inbox.</h2>
1477 <p class="land-2030-lede">
1478 Five global dashboards that span every repo you touch — no more
1479 tab-juggling across orgs.
1480 </p>
1481 </div>
1482 <div class="land-2030-dash-grid">
1483 <a href="/pulls" class="land-2030-dash">
1484 <span class="land-2030-dash-name">/pulls</span>
1485 <span class="land-2030-dash-desc">PR command center across every repo you can touch.</span>
1486 </a>
1487 <a href="/issues" class="land-2030-dash">
1488 <span class="land-2030-dash-name">/issues</span>
1489 <span class="land-2030-dash-desc">Global issue dashboard. Triage from one screen.</span>
1490 </a>
1491 <a href="/inbox" class="land-2030-dash">
1492 <span class="land-2030-dash-name">/inbox</span>
1493 <span class="land-2030-dash-desc">Unified mentions, reviews, CI, and AI events.</span>
1494 </a>
1495 <a href="/activity" class="land-2030-dash">
1496 <span class="land-2030-dash-name">/activity</span>
1497 <span class="land-2030-dash-desc">A timeline of everything that moved on your repos.</span>
1498 </a>
1499 <a href="/standups" class="land-2030-dash">
1500 <span class="land-2030-dash-name">/standups</span>
1501 <span class="land-2030-dash-desc">Daily AI-generated brief of what shipped and what's stuck.</span>
1502 </a>
1503 </div>
1504 </section>
1505
1506 {/* ---------- FULL AI FEATURE GRID ---------- */}
1507 <section class="land-2030-section">
1508 <div class="land-2030-section-head">
1509 <div class="land-2030-eyebrow land-2030-eyebrow-mini">Closed-loop AI</div>
1510 <h2 class="land-2030-h2">18 AI features. One platform.</h2>
1511 <p class="land-2030-lede">
1512 Most of these don't exist anywhere else. None of them require a
1513 second SaaS subscription.
1514 </p>
1515 </div>
1516 <div class="land-2030-feat-grid">
1517 {LAND_2030_FEATURES.map((f) => (
1518 <a href={f.href} class="land-2030-feat">
1519 <div class="land-2030-feat-head">
1520 <span class="land-2030-feat-title">{f.title}</span>
1521 <span class={`land-2030-pill land-2030-pill-${f.status}`}>
1522 {f.status === "live" ? "live" : f.status === "beta" ? "beta" : "soon"}
1523 </span>
1524 </div>
1525 <p class="land-2030-feat-desc">{f.desc}</p>
1526 </a>
1527 ))}
1528 </div>
1529 </section>
1530
1531 {/* ---------- DEVELOPER EXPERIENCE ---------- */}
1532 <section class="land-2030-section">
1533 <div class="land-2030-section-head">
1534 <div class="land-2030-eyebrow land-2030-eyebrow-mini">Developer surface</div>
1535 <h2 class="land-2030-h2">Built where you already are.</h2>
1536 </div>
1537 <div class="land-2030-dx-grid">
1538 <div class="land-2030-dx">
1539 <h3 class="land-2030-dx-title">gluecron CLI</h3>
1540 <pre class="land-2030-code"><code>$ gluecron spec "add CSV export to /api/orders"
1541{"→ Drafting PR…"}
1542{"→ Opened #482 with 3 commits"}
1543{"→ AI review queued"}</code></pre>
1544 </div>
1545 <div class="land-2030-dx">
1546 <h3 class="land-2030-dx-title">PR slash commands</h3>
1547 <pre class="land-2030-code"><code>/merge — squash + merge when checks pass
1548/rebase — rebase onto base, push --force-with-lease
1549/explain — Claude explains the diff in plain English
1550/test — Claude writes tests for the new code</code></pre>
1551 </div>
1552 <div class="land-2030-dx">
1553 <h3 class="land-2030-dx-title">Branch preview URLs</h3>
1554 <pre class="land-2030-code"><code>{"→ git push gluecron HEAD"}
1555{"→ preview: https://pr-482.preview.gluecron.com"}
1556{"→ commented on PR #482"}</code></pre>
1557 </div>
1558 </div>
1559 </section>
1560
1561 {/* ---------- BUILT FOR AGENTS ---------- */}
1562 <section class="land-2030-section land-2030-section-dark">
1563 <div class="land-2030-section-head">
1564 <div class="land-2030-eyebrow land-2030-eyebrow-mini">Agent era</div>
1565 <h2 class="land-2030-h2">Built for agents, not just humans.</h2>
1566 <p class="land-2030-lede">
1567 Per-agent tokens, per-agent budgets, per-agent branch namespaces,
1568 and a lease primitive so 50 agents don't trample one repo.
1569 </p>
1570 </div>
1571 <div class="land-2030-agent-wrap">
1572 <pre class="land-2030-code land-2030-code-wide"><code>{'# agent gets a scoped token + lease before writing'}
1573{'curl -H "Authorization: Bearer agt_3p9x…" \\\\'}
1574{' -X POST https://gluecron.com/api/v2/leases \\\\'}
1575{' -d \'{"repo":"acme/api","branch":"agent/jules/checkout-fix","ttl":300}\''}
1576{''}
1577{'{ "lease_id": "lse_8a2f", "expires_at": "2030-05-25T14:05:11Z" }'}</code></pre>
1578 <div class="land-2030-agent-stat">
1579 <div class="land-2030-agent-big">10,000</div>
1580 <div class="land-2030-agent-label">
1581 agents pushing to your repo per day. Welcome to 2030.
1582 </div>
1583 <a href="/docs/build-agent-integration" class="land-2030-agent-link">
1584 Build an agent integration{" →"}
1585 </a>
1586 </div>
1587 </div>
1588 </section>
1589
1590 {/* ---------- VS GITHUB ---------- */}
1591 <section class="land-2030-section">
1592 <div class="land-2030-section-head">
1593 <div class="land-2030-eyebrow land-2030-eyebrow-mini">vs GitHub</div>
1594 <h2 class="land-2030-h2">One platform replaces five.</h2>
1595 </div>
1596 <div class="land-2030-vs">
1597 <div class="land-2030-vs-row land-2030-vs-head">
1598 <div>Today</div>
1599 <div>On Gluecron</div>
1600 </div>
1601 <div class="land-2030-vs-row">
1602 <div>GitHub + Copilot + Vercel + Sentry + Linear</div>
1603 <div class="land-2030-vs-us">Gluecron</div>
1604 </div>
1605 <a href="/vs-github" class="land-2030-vs-link">
1606 See the full comparison{" →"}
1607 </a>
1608 </div>
1609 </section>
1610 </div>
1611 </>
1612);
1613
12441614// Backwards-compatible default — web.tsx imports `LandingPage`.
1615// The 2030 prelude is rendered above the existing LandingHero so every
1616// existing L10/U1/Q1/M1 regression assertion keeps passing untouched.
12451617export const LandingPage: FC<LandingPageProps> = (props) => (
1246 <LandingHero {...props} />
1618 <>
1619 <Land2030 />
1620 <LandingHero {...props} />
1621 </>
12471622);
12481623
12491624export default LandingPage;
30093384 } catch (_) { /* swallow */ }
30103385})();
30113386`;
3387
3388// ============================================================
3389// land2030Css — scoped CSS for the 2030 homepage prelude.
3390// Every selector is prefixed `.land-2030-*` so it can't bleed
3391// into the older `.landing-*` (L10/U1/Q1/M1) styles below.
3392// ============================================================
3393const land2030Css = `
3394 .land-2030-root {
3395 position: relative;
3396 max-width: 1240px;
3397 margin: 0 auto;
3398 padding: 0 16px var(--space-6, 32px);
3399 color: var(--text, #e6e6f0);
3400 }
3401
3402 /* ---------- Hero ---------- */
3403 .land-2030-hero {
3404 position: relative;
3405 padding: clamp(48px, 8vw, 120px) 8px clamp(40px, 6vw, 80px);
3406 text-align: center;
3407 overflow: hidden;
3408 }
3409 .land-2030-hairline {
3410 position: absolute;
3411 top: 0; left: 0; right: 0;
3412 height: 2px;
3413 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3414 opacity: 0.75;
3415 pointer-events: none;
3416 }
3417 .land-2030-orb {
3418 position: absolute;
3419 top: -10%;
3420 left: 50%;
3421 width: 720px;
3422 height: 720px;
3423 transform: translateX(-50%);
3424 background: radial-gradient(circle, rgba(140,109,255,0.30), rgba(54,197,214,0.16) 40%, transparent 70%);
3425 filter: blur(90px);
3426 pointer-events: none;
3427 z-index: 0;
3428 }
3429 .land-2030-hero-inner {
3430 position: relative;
3431 z-index: 1;
3432 max-width: 1080px;
3433 margin: 0 auto;
3434 }
3435 .land-2030-eyebrow {
3436 display: inline-flex;
3437 align-items: center;
3438 gap: 8px;
3439 padding: 6px 12px;
3440 border-radius: 999px;
3441 background: rgba(140,109,255,0.10);
3442 color: #cbb7ff;
3443 font-size: 12px;
3444 letter-spacing: 0.04em;
3445 text-transform: uppercase;
3446 border: 1px solid rgba(140,109,255,0.30);
3447 margin-bottom: 24px;
3448 }
3449 .land-2030-eyebrow-mini {
3450 margin-bottom: 12px;
3451 }
3452 .land-2030-pulse {
3453 width: 8px; height: 8px;
3454 border-radius: 50%;
3455 background: #8c6dff;
3456 box-shadow: 0 0 12px rgba(140,109,255,0.8);
3457 animation: land2030-pulse 2s ease-in-out infinite;
3458 }
3459 @keyframes land2030-pulse {
3460 0%, 100% { opacity: 0.5; transform: scale(1); }
3461 50% { opacity: 1; transform: scale(1.25); }
3462 }
3463 .land-2030-display {
3464 font-size: clamp(60px, 9vw, 96px);
3465 font-family: var(--font-display, inherit);
3466 font-weight: 800;
3467 line-height: 1.02;
3468 letter-spacing: -0.035em;
3469 margin: 0 auto 24px;
3470 max-width: 1040px;
3471 }
3472 .land-2030-display span { background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; color: transparent; }
3473 .land-2030-grad-1 { background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 100%); }
3474 .land-2030-grad-2 { background-image: linear-gradient(135deg, #c4b1ff 0%, #36c5d6 100%); }
3475 .land-2030-grad-3 { background-image: linear-gradient(135deg, #36c5d6 0%, #5be0a9 100%); }
3476 .land-2030-grad-4 { background-image: linear-gradient(135deg, #5be0a9 0%, #ffd16b 100%); }
3477 .land-2030-grad-5 { background-image: linear-gradient(135deg, #ffd16b 0%, #ff6bd1 50%, #8c6dff 100%); }
3478 .land-2030-sub {
3479 font-size: clamp(17px, 1.6vw, 22px);
3480 color: var(--text-muted, #a0a0b8);
3481 max-width: 720px;
3482 margin: 0 auto 36px;
3483 line-height: 1.45;
3484 }
3485 .land-2030-cta-row {
3486 display: flex;
3487 flex-wrap: wrap;
3488 justify-content: center;
3489 gap: 12px;
3490 }
3491 .land-2030-cta-primary,
3492 .land-2030-cta-secondary {
3493 min-width: 200px;
3494 }
3495
3496 /* ---------- Section scaffold ---------- */
3497 .land-2030-section {
3498 margin: clamp(56px, 8vw, 96px) 0;
3499 position: relative;
3500 }
3501 .land-2030-section-dark {
3502 padding: 48px 32px;
3503 background: linear-gradient(180deg, rgba(15,17,26,0.6), rgba(8,9,15,0.6));
3504 border: 1px solid rgba(140,109,255,0.18);
3505 border-radius: 20px;
3506 }
3507 .land-2030-section-head {
3508 text-align: center;
3509 max-width: 760px;
3510 margin: 0 auto 40px;
3511 }
3512 .land-2030-h2 {
3513 font-size: clamp(28px, 4vw, 44px);
3514 font-family: var(--font-display, inherit);
3515 font-weight: 700;
3516 letter-spacing: -0.025em;
3517 line-height: 1.1;
3518 margin: 0 0 12px;
3519 }
3520 .land-2030-lede {
3521 font-size: 16px;
3522 color: var(--text-muted, #a0a0b8);
3523 line-height: 1.55;
3524 margin: 0;
3525 }
3526
3527 /* ---------- Closed loop diagram ---------- */
3528 .land-2030-loop-wrap {
3529 max-width: 1100px;
3530 margin: 0 auto;
3531 padding: 16px;
3532 background: rgba(255,255,255,0.02);
3533 border: 1px solid rgba(140,109,255,0.16);
3534 border-radius: 20px;
3535 color: var(--text-muted, #a0a0b8);
3536 }
3537 .land-2030-loop-svg {
3538 display: block;
3539 width: 100%;
3540 height: auto;
3541 }
3542 .land-2030-loop-path {
3543 stroke-dasharray: 6 4;
3544 animation: land2030-loop-dash 30s linear infinite;
3545 }
3546 @keyframes land2030-loop-dash {
3547 from { stroke-dashoffset: 0; }
3548 to { stroke-dashoffset: -400; }
3549 }
3550 .land-2030-loop-text {
3551 font-size: 14px;
3552 font-weight: 600;
3553 letter-spacing: -0.005em;
3554 }
3555
3556 /* ---------- 6-card "unfair advantages" grid ---------- */
3557 .land-2030-card-grid {
3558 display: grid;
3559 grid-template-columns: repeat(3, minmax(0, 1fr));
3560 gap: 16px;
3561 max-width: 1180px;
3562 margin: 0 auto;
3563 }
3564 .land-2030-card {
3565 display: flex;
3566 flex-direction: column;
3567 gap: 10px;
3568 padding: 24px;
3569 background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.015));
3570 border: 1px solid rgba(255,255,255,0.08);
3571 border-radius: 16px;
3572 text-decoration: none;
3573 color: inherit;
3574 transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
3575 }
3576 .land-2030-card:hover {
3577 transform: translateY(-2px);
3578 border-color: rgba(140,109,255,0.45);
3579 box-shadow: 0 8px 30px -8px rgba(140,109,255,0.40);
3580 }
3581 .land-2030-card-icon {
3582 width: 44px; height: 44px;
3583 display: inline-flex;
3584 align-items: center;
3585 justify-content: center;
3586 border-radius: 12px;
3587 background: rgba(140,109,255,0.12);
3588 color: #cbb7ff;
3589 border: 1px solid rgba(140,109,255,0.30);
3590 margin-bottom: 4px;
3591 }
3592 .land-2030-card-title {
3593 margin: 0;
3594 font-size: 18px;
3595 font-weight: 700;
3596 letter-spacing: -0.01em;
3597 }
3598 .land-2030-card-desc {
3599 margin: 0;
3600 font-size: 14.5px;
3601 color: var(--text-muted, #a0a0b8);
3602 line-height: 1.55;
3603 }
3604 .land-2030-card-cta {
3605 margin-top: auto;
3606 color: #b69dff;
3607 font-size: 14px;
3608 font-weight: 600;
3609 }
3610
3611 /* ---------- Global dashboards grid ---------- */
3612 .land-2030-dash-grid {
3613 display: grid;
3614 grid-template-columns: repeat(5, minmax(0, 1fr));
3615 gap: 12px;
3616 max-width: 1180px;
3617 margin: 0 auto;
3618 }
3619 .land-2030-dash {
3620 display: flex;
3621 flex-direction: column;
3622 gap: 6px;
3623 padding: 16px 18px;
3624 background: rgba(255,255,255,0.03);
3625 border: 1px solid rgba(255,255,255,0.08);
3626 border-radius: 12px;
3627 text-decoration: none;
3628 color: inherit;
3629 transition: transform 180ms ease, border-color 180ms ease;
3630 }
3631 .land-2030-dash:hover {
3632 transform: translateY(-1px);
3633 border-color: rgba(54,197,214,0.45);
3634 }
3635 .land-2030-dash-name {
3636 font-family: var(--font-mono, ui-monospace, monospace);
3637 font-size: 14px;
3638 color: #36c5d6;
3639 font-weight: 600;
3640 }
3641 .land-2030-dash-desc {
3642 font-size: 13px;
3643 color: var(--text-muted, #a0a0b8);
3644 line-height: 1.45;
3645 }
3646
3647 /* ---------- 18-feature grid + status pills ---------- */
3648 .land-2030-feat-grid {
3649 display: grid;
3650 grid-template-columns: repeat(3, minmax(0, 1fr));
3651 gap: 10px;
3652 max-width: 1180px;
3653 margin: 0 auto;
3654 }
3655 .land-2030-feat {
3656 display: flex;
3657 flex-direction: column;
3658 gap: 6px;
3659 padding: 14px 16px;
3660 background: rgba(255,255,255,0.025);
3661 border: 1px solid rgba(255,255,255,0.07);
3662 border-radius: 10px;
3663 text-decoration: none;
3664 color: inherit;
3665 transition: border-color 160ms ease, background 160ms ease;
3666 }
3667 .land-2030-feat:hover {
3668 border-color: rgba(140,109,255,0.35);
3669 background: rgba(140,109,255,0.05);
3670 }
3671 .land-2030-feat-head {
3672 display: flex;
3673 align-items: center;
3674 justify-content: space-between;
3675 gap: 10px;
3676 }
3677 .land-2030-feat-title {
3678 font-size: 14.5px;
3679 font-weight: 700;
3680 letter-spacing: -0.005em;
3681 }
3682 .land-2030-feat-desc {
3683 margin: 0;
3684 font-size: 13px;
3685 color: var(--text-muted, #a0a0b8);
3686 line-height: 1.5;
3687 }
3688 .land-2030-pill {
3689 font-size: 10.5px;
3690 letter-spacing: 0.04em;
3691 text-transform: uppercase;
3692 padding: 3px 8px;
3693 border-radius: 999px;
3694 border: 1px solid currentColor;
3695 font-weight: 700;
3696 line-height: 1;
3697 }
3698 .land-2030-pill-live { color: #5be0a9; border-color: rgba(91,224,169,0.5); background: rgba(91,224,169,0.10); }
3699 .land-2030-pill-beta { color: #ffd16b; border-color: rgba(255,209,107,0.5); background: rgba(255,209,107,0.10); }
3700 .land-2030-pill-soon { color: #a0a0b8; border-color: rgba(160,160,184,0.4); background: rgba(160,160,184,0.08); }
3701
3702 /* ---------- Developer surface ---------- */
3703 .land-2030-dx-grid {
3704 display: grid;
3705 grid-template-columns: repeat(3, minmax(0, 1fr));
3706 gap: 16px;
3707 max-width: 1180px;
3708 margin: 0 auto;
3709 }
3710 .land-2030-dx {
3711 padding: 18px;
3712 background: linear-gradient(180deg, rgba(15,17,26,0.7), rgba(8,9,15,0.7));
3713 border: 1px solid rgba(140,109,255,0.18);
3714 border-radius: 14px;
3715 }
3716 .land-2030-dx-title {
3717 margin: 0 0 10px;
3718 font-size: 15px;
3719 font-weight: 700;
3720 color: var(--text-strong, #fff);
3721 }
3722 .land-2030-code {
3723 margin: 0;
3724 padding: 12px 14px;
3725 background: rgba(0,0,0,0.30);
3726 border: 1px solid rgba(255,255,255,0.06);
3727 border-radius: 10px;
3728 font-family: var(--font-mono, ui-monospace, monospace);
3729 font-size: 12.5px;
3730 color: #cbb7ff;
3731 line-height: 1.6;
3732 overflow-x: auto;
3733 }
3734 .land-2030-code code { color: inherit; background: transparent; padding: 0; }
3735 .land-2030-code-wide {
3736 font-size: 13px;
3737 color: #d6d6e4;
3738 }
3739
3740 /* ---------- Agent multiplayer ---------- */
3741 .land-2030-agent-wrap {
3742 display: grid;
3743 grid-template-columns: 1.4fr 1fr;
3744 gap: 24px;
3745 max-width: 1080px;
3746 margin: 0 auto;
3747 align-items: center;
3748 }
3749 .land-2030-agent-stat { text-align: left; }
3750 .land-2030-agent-big {
3751 font-size: clamp(48px, 7vw, 80px);
3752 font-weight: 800;
3753 line-height: 1;
3754 background-image: linear-gradient(135deg, #a48bff 0%, #36c5d6 100%);
3755 background-clip: text;
3756 -webkit-background-clip: text;
3757 -webkit-text-fill-color: transparent;
3758 color: transparent;
3759 letter-spacing: -0.03em;
3760 }
3761 .land-2030-agent-label {
3762 margin-top: 12px;
3763 color: var(--text-muted, #a0a0b8);
3764 font-size: 15px;
3765 line-height: 1.5;
3766 }
3767 .land-2030-agent-link {
3768 display: inline-block;
3769 margin-top: 16px;
3770 color: #36c5d6;
3771 text-decoration: none;
3772 font-weight: 600;
3773 font-size: 14px;
3774 }
3775 .land-2030-agent-link:hover { text-decoration: underline; }
3776
3777 /* ---------- vs GitHub strip ---------- */
3778 .land-2030-vs {
3779 max-width: 880px;
3780 margin: 0 auto;
3781 background: rgba(255,255,255,0.03);
3782 border: 1px solid rgba(255,255,255,0.08);
3783 border-radius: 14px;
3784 overflow: hidden;
3785 text-align: center;
3786 padding: 0 0 18px;
3787 }
3788 .land-2030-vs-row {
3789 display: grid;
3790 grid-template-columns: 1fr 1fr;
3791 gap: 0;
3792 padding: 16px 18px;
3793 border-bottom: 1px solid rgba(255,255,255,0.06);
3794 font-size: 15px;
3795 }
3796 .land-2030-vs-row:last-of-type { border-bottom: 0; }
3797 .land-2030-vs-head {
3798 font-size: 12px;
3799 letter-spacing: 0.04em;
3800 text-transform: uppercase;
3801 color: var(--text-muted, #a0a0b8);
3802 background: rgba(255,255,255,0.02);
3803 }
3804 .land-2030-vs-us {
3805 color: #5be0a9;
3806 font-weight: 700;
3807 }
3808 .land-2030-vs-link {
3809 display: inline-block;
3810 margin-top: 12px;
3811 color: #b69dff;
3812 text-decoration: none;
3813 font-weight: 600;
3814 font-size: 14px;
3815 }
3816 .land-2030-vs-link:hover { text-decoration: underline; }
3817
3818 /* ---------- Responsive ---------- */
3819 @media (max-width: 960px) {
3820 .land-2030-card-grid,
3821 .land-2030-feat-grid,
3822 .land-2030-dx-grid,
3823 .land-2030-dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
3824 .land-2030-agent-wrap { grid-template-columns: 1fr; }
3825 }
3826 @media (max-width: 640px) {
3827 .land-2030-card-grid,
3828 .land-2030-feat-grid,
3829 .land-2030-dx-grid,
3830 .land-2030-dash-grid { grid-template-columns: 1fr; }
3831 .land-2030-section-dark { padding: 28px 16px; }
3832 .land-2030-vs-row { grid-template-columns: 1fr; gap: 4px; }
3833 }
3834 @media (max-width: 375px) {
3835 .land-2030-display { font-size: 44px; }
3836 .land-2030-cta-primary,
3837 .land-2030-cta-secondary { width: 100%; min-width: 0; }
3838 }
3839
3840 @media (prefers-reduced-motion: reduce) {
3841 .land-2030-loop-path,
3842 .land-2030-pulse { animation: none; }
3843 }
3844`;
30123845