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

feat: wire /billing/usage + /help docs + PR generate-tests button

feat: wire /billing/usage + /help docs + PR generate-tests button

Late-arriving pieces from the AI-cost + AI-test-gen agents:
- src/routes/billing-usage.tsx: hero, sparkline, breakdown tables for
  /billing/usage (route registered in src/app.tsx)
- src/routes/help.tsx: AI cost tracking section + generate-tests docs
- src/routes/pulls.tsx: 'Generate tests with AI' button on PR detail
  for repos with auto_generate_tests opt-in

All scoped CSS, no shared file touches.
Claude committed on May 25, 2026Parent: 1d4ff60
6 files changed+80308809b87d6f5232e73a8d8b03856ea7d2a91ca83b
6 changed files+803−0
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
9595import askRoutes from "./routes/ask";
9696import repoChatRoutes from "./routes/repo-chat";
9797import billingRoutes from "./routes/billing";
98import billingUsageRoutes from "./routes/billing-usage";
9899import stripeWebhookRoutes from "./routes/stripe-webhook";
99100import codeScanningRoutes from "./routes/code-scanning";
100101import commitStatusesRoutes from "./routes/commit-statuses";
551552app.route("/", askRoutes);
552553app.route("/", repoChatRoutes);
553554app.route("/", billingRoutes);
555app.route("/", billingUsageRoutes);
554556app.route("/", stripeWebhookRoutes);
555557app.route("/", codeScanningRoutes);
556558app.route("/", commitStatusesRoutes);
Modifiedsrc/routes/api-v2.ts+53−0View fileUnifiedSplit
7272 computeAiSavingsForUser,
7373 computeLifetimeAiSavingsForUser,
7474} from "../lib/ai-hours-saved";
75import {
76 summarizeCostsForUser,
77 summarizeCostsForRepo,
78 startOfUtcMonth,
79} from "../lib/ai-cost-tracker";
7580import {
7681 generateCommitMessage,
7782 DIFF_BYTE_CAP,
268273 * VS Code extension, and CLI. Returns the same numbers the web UI shows.
269274 * No special scope — any authenticated token can read its own counter.
270275 */
276/**
277 * Per-user AI cost summary. Same shape as the /billing/usage dashboard.
278 * Defaults to "this calendar month UTC" when no window is supplied.
279 * Query params: ?from=ISO8601&to=ISO8601 to override.
280 */
281apiv2.get("/usage/me", requireApiAuth, async (c) => {
282 const user = c.get("user")!;
283 const now = new Date();
284 const fromQ = c.req.query("from");
285 const toQ = c.req.query("to");
286 const fromDate = fromQ ? new Date(fromQ) : startOfUtcMonth(now);
287 const toDate = toQ ? new Date(toQ) : now;
288 const summary = await summarizeCostsForUser(user.id, { fromDate, toDate });
289 return c.json({
290 window: { fromDate: fromDate.toISOString(), toDate: toDate.toISOString() },
291 ...summary,
292 });
293});
294
295/**
296 * Per-repo AI cost summary. Public repos: any caller. Private repos: only
297 * the owner (matches the existing GET /repos/:owner/:repo behaviour). The
298 * window defaults to "this calendar month UTC" as above.
299 */
300apiv2.get("/usage/repo/:owner/:repo", requireApiAuth, async (c) => {
301 const { owner, repo } = c.req.param();
302 const resolved = await resolveRepo(owner, repo);
303 if (!resolved) return c.json({ error: "Not found" }, 404);
304 const user = c.get("user")!;
305 if ((resolved.repo as any).isPrivate && user.id !== resolved.owner.id) {
306 return c.json({ error: "Not found" }, 404);
307 }
308 const now = new Date();
309 const fromQ = c.req.query("from");
310 const toQ = c.req.query("to");
311 const fromDate = fromQ ? new Date(fromQ) : startOfUtcMonth(now);
312 const toDate = toQ ? new Date(toQ) : now;
313 const summary = await summarizeCostsForRepo((resolved.repo as any).id, {
314 fromDate,
315 toDate,
316 });
317 return c.json({
318 repository: { owner, repo, id: (resolved.repo as any).id },
319 window: { fromDate: fromDate.toISOString(), toDate: toDate.toISOString() },
320 ...summary,
321 });
322});
323
271324apiv2.get("/me/ai-savings", requireApiAuth, async (c) => {
272325 const user = c.get("user")!;
273326 const [window, lifetime] = await Promise.all([
Addedsrc/routes/billing-usage.tsx+686−0View fileUnifiedSplit
1/**
2 * /billing/usage — Per-repo + per-agent AI cost dashboard.
3 *
4 * GET /billing/usage — the personal AI spend dashboard
5 * POST /billing/usage/budget — set monthly AI budget (cents)
6 *
7 * The dashboard is observational (we do NOT block calls on overspend yet —
8 * that's a future hard-gate). It surfaces:
9 *
10 * - Stat cards: This month, Last month, Daily average, Projected EOM
11 * - 30-day trend sparkline (inline SVG, no chart library)
12 * - Breakdown by category, by repo, by agent
13 * - Monthly budget form + an exceeded-warning banner
14 *
15 * All CSS is scoped under `.cost-*`. Reuses the 2026 hero/orb pattern from
16 * `/settings/billing` so the surface feels familiar.
17 */
18
19import { Hono } from "hono";
20import { and, desc, eq, gte, lte } from "drizzle-orm";
21import { db } from "../db";
22import {
23 aiBudgets,
24 aiCostEvents,
25 agentSessions,
26 repositories,
27 users,
28} from "../db/schema";
29import { Layout } from "../views/layout";
30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import {
33 aggregateEvents,
34 dailyAverageCents,
35 formatCents,
36 formatTokens,
37 projectMonthEndCents,
38 startOfUtcMonth,
39 summarizeCostsForRepo,
40 summarizeCostsForUser,
41 toUtcDayKey,
42} from "../lib/ai-cost-tracker";
43import type { CostSummary } from "../lib/ai-cost-tracker";
44
45const usage = new Hono<AuthEnv>();
46usage.use("*", softAuth);
47
48// ─── Scoped CSS (all `.cost-*`) ───────────────────────────────────────────
49const styles = `
50 .cost-wrap { max-width: 1080px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
51
52 .cost-hero {
53 position: relative;
54 margin-bottom: var(--space-5);
55 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
56 background: var(--bg-elevated);
57 border: 1px solid var(--border);
58 border-radius: 18px;
59 overflow: hidden;
60 }
61 .cost-hero::before {
62 content: '';
63 position: absolute; top: 0; left: 0; right: 0; height: 2px;
64 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
65 opacity: 0.75; pointer-events: none;
66 }
67 .cost-orb {
68 position: absolute;
69 inset: -30% -10% auto auto;
70 width: 460px; height: 460px;
71 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
72 filter: blur(80px); opacity: 0.7;
73 pointer-events: none; z-index: 0;
74 }
75 .cost-hero-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
76 .cost-hero-text { max-width: 680px; flex: 1; min-width: 240px; }
77 .cost-eyebrow {
78 display: inline-flex; align-items: center; gap: 8px;
79 font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.18em;
80 text-transform: uppercase; color: var(--text-muted); font-weight: 600;
81 margin-bottom: 16px;
82 }
83 .cost-eyebrow-dot {
84 width: 8px; height: 8px; border-radius: 9999px;
85 background: linear-gradient(135deg, #8c6dff, #36c5d6);
86 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
87 }
88 .cost-eyebrow strong { color: var(--accent); font-weight: 600; letter-spacing: 0.04em; }
89 .cost-title {
90 font-family: var(--font-display);
91 font-size: clamp(32px, 5vw, 48px);
92 font-weight: 800;
93 letter-spacing: -0.030em;
94 line-height: 1.05;
95 margin: 0 0 var(--space-3);
96 color: var(--text-strong);
97 }
98 .cost-title-grad {
99 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
100 -webkit-background-clip: text; background-clip: text;
101 -webkit-text-fill-color: transparent; color: transparent;
102 }
103 .cost-total {
104 font-family: var(--font-mono);
105 font-variant-numeric: tabular-nums;
106 font-size: clamp(28px, 4vw, 40px);
107 font-weight: 700;
108 color: var(--text-strong);
109 letter-spacing: -0.02em;
110 margin-top: 4px;
111 }
112 .cost-total small {
113 font-size: 13px; color: var(--text-muted);
114 margin-left: 10px; font-weight: 500;
115 }
116 .cost-sub { font-size: 16px; color: var(--text-muted); margin: 0; line-height: 1.55; max-width: 580px; }
117
118 /* Banner */
119 .cost-banner {
120 margin-bottom: var(--space-4);
121 padding: 10px 14px;
122 border-radius: 10px;
123 font-size: 13.5px;
124 border: 1px solid var(--border);
125 background: rgba(255,255,255,0.025);
126 color: var(--text);
127 display: flex; align-items: center; gap: 10px;
128 }
129 .cost-banner.is-warn { border-color: rgba(245,158,11,0.40); background: rgba(245,158,11,0.08); color: #fde68a; }
130 .cost-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
131 .cost-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
132
133 /* Stat cards */
134 .cost-stats {
135 display: grid;
136 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
137 gap: var(--space-3);
138 margin-bottom: var(--space-5);
139 }
140 .cost-stat {
141 background: var(--bg-elevated);
142 border: 1px solid var(--border);
143 border-radius: 14px;
144 padding: var(--space-4);
145 display: flex; flex-direction: column; gap: 6px;
146 }
147 .cost-stat-head {
148 font-size: 10.5px; color: var(--text-muted);
149 text-transform: uppercase; letter-spacing: 0.14em;
150 font-family: var(--font-mono); font-weight: 600;
151 }
152 .cost-stat-val {
153 font-family: var(--font-mono); font-variant-numeric: tabular-nums;
154 font-size: 24px; font-weight: 700; color: var(--text-strong);
155 letter-spacing: -0.02em;
156 }
157 .cost-stat-sub { font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
158
159 /* Section card (shared) */
160 .cost-section {
161 margin-bottom: var(--space-5);
162 background: var(--bg-elevated);
163 border: 1px solid var(--border);
164 border-radius: 14px;
165 overflow: hidden;
166 }
167 .cost-section-head {
168 padding: var(--space-4) var(--space-5);
169 border-bottom: 1px solid var(--border);
170 display: flex; align-items: flex-start; justify-content: space-between;
171 gap: var(--space-3); flex-wrap: wrap;
172 }
173 .cost-section-title {
174 margin: 0; font-family: var(--font-display); font-size: 16px;
175 font-weight: 700; color: var(--text-strong); letter-spacing: -0.014em;
176 }
177 .cost-section-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-muted); }
178 .cost-section-body { padding: var(--space-4) var(--space-5); }
179
180 /* Trend chart */
181 .cost-trend { width: 100%; height: 140px; display: block; }
182 .cost-trend-line { stroke: #8c6dff; stroke-width: 2; fill: none; }
183 .cost-trend-fill { fill: url(#cost-grad); opacity: 0.45; }
184 .cost-trend-axis { stroke: var(--border); stroke-width: 1; opacity: 0.6; }
185 .cost-trend-label { fill: var(--text-muted); font-family: var(--font-mono); font-size: 10px; }
186
187 /* Breakdown table */
188 .cost-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
189 .cost-table th { text-align: left; font-weight: 600; color: var(--text-muted); padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; }
190 .cost-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); font-variant-numeric: tabular-nums; }
191 .cost-table td.is-num { text-align: right; font-family: var(--font-mono); }
192 .cost-table tr:last-child td { border-bottom: none; }
193 .cost-table .cost-cat-pill {
194 display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 11px;
195 background: rgba(140,109,255,0.12); color: #c4b5fd; border: 1px solid rgba(140,109,255,0.30);
196 font-family: var(--font-mono);
197 }
198 .cost-empty { color: var(--text-muted); font-size: 13px; font-style: italic; padding: 6px 0; }
199
200 /* Budget form */
201 .cost-budget-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
202 .cost-budget-form label { font-size: 13px; color: var(--text-muted); }
203 .cost-budget-form input[type="number"] {
204 flex: 0 0 140px; padding: 8px 12px;
205 background: var(--bg-secondary, rgba(0,0,0,0.15));
206 border: 1px solid var(--border); border-radius: 8px; color: var(--text);
207 font-family: var(--font-mono); font-variant-numeric: tabular-nums;
208 }
209 .cost-budget-form button {
210 padding: 8px 16px; border-radius: 8px;
211 border: 1px solid rgba(140,109,255,0.40);
212 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.12));
213 color: var(--text-strong); font-weight: 600; cursor: pointer; font-size: 13px;
214 }
215 .cost-budget-form button:hover { border-color: rgba(140,109,255,0.60); }
216 .cost-budget-note { margin-top: 10px; font-size: 12px; color: var(--text-muted); }
217
218 .cost-foot { margin-top: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--border); font-size: 12.5px; color: var(--text-muted); text-align: center; }
219 .cost-foot a { color: var(--accent); }
220
221 .cost-sublinks { display: flex; gap: var(--space-3); flex-wrap: wrap; font-size: 13px; margin-bottom: var(--space-4); }
222 .cost-sublinks a { color: var(--text-muted); text-decoration: none; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: rgba(255,255,255,0.02); }
223 .cost-sublinks a:hover { color: var(--text-strong); border-color: var(--border-strong); }
224 .cost-sublinks a.is-current { color: var(--text-strong); border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.08); }
225`;
226
227/** Build the inline-SVG sparkline for the last N days. Pure function. */
228export function buildTrendSparkline(
229 byDayCents: Array<{ day: string; cents: number }>,
230 days = 30
231): { points: string; areaPoints: string; max: number; total: number } {
232 const today = new Date();
233 // Build last N days inclusive, fill 0 where missing.
234 const map = new Map(byDayCents.map((d) => [d.day, d.cents]));
235 const series: Array<{ day: string; cents: number }> = [];
236 for (let i = days - 1; i >= 0; i--) {
237 const d = new Date(today);
238 d.setUTCDate(d.getUTCDate() - i);
239 const key = toUtcDayKey(d);
240 series.push({ day: key, cents: map.get(key) || 0 });
241 }
242 const max = Math.max(1, ...series.map((s) => s.cents));
243 const w = 100;
244 const h = 100;
245 const stepX = series.length > 1 ? w / (series.length - 1) : w;
246 const pts = series.map((s, i) => {
247 const x = +(i * stepX).toFixed(2);
248 const y = +(h - (s.cents / max) * h).toFixed(2);
249 return `${x},${y}`;
250 });
251 const points = pts.join(" ");
252 const areaPoints = `0,${h} ${points} ${w},${h}`;
253 const total = series.reduce((a, b) => a + b.cents, 0);
254 return { points, areaPoints, max, total };
255}
256
257/** Wrapper that builds the whole dashboard payload for a user. Exported so
258 * tests + the API endpoint share the same code path. */
259export async function buildDashboardForUser(userId: string): Promise<{
260 thisMonth: CostSummary;
261 lastMonth: CostSummary;
262 thirtyDay: CostSummary;
263 thisMonthCents: number;
264 lastMonthCents: number;
265 dailyAvgCents: number;
266 projectedEomCents: number;
267 budgetCents: number;
268 exceeds: boolean;
269}> {
270 const now = new Date();
271 const monthStart = startOfUtcMonth(now);
272 const prevMonthStart = new Date(
273 Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)
274 );
275 const prevMonthEnd = new Date(monthStart.getTime() - 1);
276 const thirtyAgo = new Date(now.getTime() - 30 * 24 * 3600 * 1000);
277
278 const [thisMonth, lastMonth, thirtyDay] = await Promise.all([
279 summarizeCostsForUser(userId, { fromDate: monthStart, toDate: now }),
280 summarizeCostsForUser(userId, {
281 fromDate: prevMonthStart,
282 toDate: prevMonthEnd,
283 }),
284 summarizeCostsForUser(userId, { fromDate: thirtyAgo, toDate: now }),
285 ]);
286
287 const thisMonthCents = thisMonth.totalCents;
288 const lastMonthCents = lastMonth.totalCents;
289 const dailyAvgCents = dailyAverageCents(thisMonthCents, now);
290 const projectedEomCents = projectMonthEndCents(thisMonthCents, now);
291
292 let budgetCents = 0;
293 try {
294 const [b] = await db
295 .select()
296 .from(aiBudgets)
297 .where(eq(aiBudgets.userId, userId))
298 .limit(1);
299 budgetCents = b?.monthlyCents ?? 0;
300 } catch {
301 /* tolerate — table may not yet exist */
302 }
303 const exceeds = budgetCents > 0 && projectedEomCents > budgetCents;
304
305 return {
306 thisMonth,
307 lastMonth,
308 thirtyDay,
309 thisMonthCents,
310 lastMonthCents,
311 dailyAvgCents,
312 projectedEomCents,
313 budgetCents,
314 exceeds,
315 };
316}
317
318const CATEGORY_LABELS: Record<string, string> = {
319 ai_review: "PR review",
320 ai_patch: "AI patches",
321 ci_healer: "CI healer",
322 spec_to_pr: "Spec-to-PR",
323 standup: "AI standup",
324 chat: "Repo chat",
325 voice: "Voice-to-PR",
326 test_gen: "Test gen",
327 refactor: "Multi-repo refactor",
328 other: "Other",
329};
330
331// ─── GET /billing/usage ─────────────────────────────────────────────────
332usage.get("/billing/usage", requireAuth, async (c) => {
333 const user = c.get("user")!;
334 const data = await buildDashboardForUser(user.id);
335 const trend = buildTrendSparkline(data.thirtyDay.byDay, 30);
336
337 // Hydrate repo names + agent names for the breakdown tables.
338 const repoLookup = await loadRepoLookup(data.thisMonth.byRepo.map((r) => r.repositoryId).filter((x): x is string => !!x));
339 const agentLookup = await loadAgentLookup(data.thisMonth.byAgent.map((r) => r.agentSessionId).filter((x): x is string => !!x));
340
341 const saved = c.req.query("saved") === "1";
342
343 return c.html(
344 <Layout title="AI usage — Gluecron" user={user}>
345 <style dangerouslySetInnerHTML={{ __html: styles }} />
346 <div class="cost-wrap">
347 {/* Hero */}
348 <section class="cost-hero">
349 <div class="cost-orb" aria-hidden="true" />
350 <div class="cost-hero-inner">
351 <div class="cost-hero-text">
352 <div class="cost-eyebrow">
353 <span class="cost-eyebrow-dot" aria-hidden="true" />
354 Billing · <strong>@{user.username}</strong> · usage
355 </div>
356 <h1 class="cost-title">
357 <span class="cost-title-grad">AI spend.</span>
358 </h1>
359 <div class="cost-total">
360 {formatCents(data.thisMonthCents)}
361 <small>this month</small>
362 </div>
363 <p class="cost-sub" style="margin-top:12px">
364 Per-repo + per-agent Anthropic spend, classified by feature. Cents are estimated from the published Claude rate card at call time.
365 </p>
366 </div>
367 </div>
368 </section>
369
370 <div class="cost-sublinks">
371 <a href="/settings/billing">Plans + payment</a>
372 <a href="/billing/usage" class="is-current">AI usage</a>
373 <a href="/settings/agents">Agents</a>
374 </div>
375
376 {saved && (
377 <div class="cost-banner is-ok" role="status">
378 <span class="cost-banner-dot" aria-hidden="true" />
379 Budget saved.
380 </div>
381 )}
382 {data.exceeds && (
383 <div class="cost-banner is-warn" role="alert">
384 <span class="cost-banner-dot" aria-hidden="true" />
385 Projected month-end spend ({formatCents(data.projectedEomCents)}) exceeds your budget ({formatCents(data.budgetCents)}). Trim usage or raise the cap below.
386 </div>
387 )}
388
389 {/* Stat cards */}
390 <div class="cost-stats">
391 <div class="cost-stat">
392 <div class="cost-stat-head">This month</div>
393 <div class="cost-stat-val">{formatCents(data.thisMonthCents)}</div>
394 <div class="cost-stat-sub">
395 {formatTokens(data.thisMonth.totalInputTokens + data.thisMonth.totalOutputTokens)} tokens
396 </div>
397 </div>
398 <div class="cost-stat">
399 <div class="cost-stat-head">Last month</div>
400 <div class="cost-stat-val">{formatCents(data.lastMonthCents)}</div>
401 <div class="cost-stat-sub">
402 {formatTokens(data.lastMonth.totalInputTokens + data.lastMonth.totalOutputTokens)} tokens
403 </div>
404 </div>
405 <div class="cost-stat">
406 <div class="cost-stat-head">Daily average</div>
407 <div class="cost-stat-val">{formatCents(data.dailyAvgCents)}</div>
408 <div class="cost-stat-sub">elapsed month-to-date</div>
409 </div>
410 <div class="cost-stat">
411 <div class="cost-stat-head">Projected EOM</div>
412 <div class="cost-stat-val">{formatCents(data.projectedEomCents)}</div>
413 <div class="cost-stat-sub">linear extrapolation</div>
414 </div>
415 </div>
416
417 {/* Trend chart */}
418 <section class="cost-section">
419 <header class="cost-section-head">
420 <div>
421 <h3 class="cost-section-title">30-day trend</h3>
422 <p class="cost-section-sub">
423 Daily spend in cents. Peak day:&nbsp;
424 <span style="font-family:var(--font-mono);color:var(--text-strong)">{formatCents(trend.max)}</span>.
425 30-day total:&nbsp;
426 <span style="font-family:var(--font-mono);color:var(--text-strong)">{formatCents(trend.total)}</span>.
427 </p>
428 </div>
429 </header>
430 <div class="cost-section-body">
431 <svg
432 class="cost-trend"
433 viewBox="0 0 100 100"
434 preserveAspectRatio="none"
435 aria-label="30-day AI spend sparkline"
436 role="img"
437 >
438 <defs>
439 <linearGradient id="cost-grad" x1="0" y1="0" x2="0" y2="1">
440 <stop offset="0%" stop-color="#8c6dff" stop-opacity="0.6" />
441 <stop offset="100%" stop-color="#36c5d6" stop-opacity="0.05" />
442 </linearGradient>
443 </defs>
444 <line class="cost-trend-axis" x1="0" y1="100" x2="100" y2="100" />
445 <polygon class="cost-trend-fill" points={trend.areaPoints} />
446 <polyline class="cost-trend-line" points={trend.points} />
447 </svg>
448 </div>
449 </section>
450
451 {/* Breakdown by category */}
452 <section class="cost-section">
453 <header class="cost-section-head">
454 <div>
455 <h3 class="cost-section-title">By feature this month</h3>
456 <p class="cost-section-sub">Where the spend is going — PR review, CI healing, refactors, etc.</p>
457 </div>
458 </header>
459 <div class="cost-section-body">
460 {data.thisMonth.byCategory.length === 0 ? (
461 <div class="cost-empty">No AI activity yet this month.</div>
462 ) : (
463 <table class="cost-table">
464 <thead>
465 <tr>
466 <th>Feature</th>
467 <th style="text-align:right">Spend</th>
468 <th style="text-align:right">Input tokens</th>
469 <th style="text-align:right">Output tokens</th>
470 </tr>
471 </thead>
472 <tbody>
473 {data.thisMonth.byCategory.map((c) => (
474 <tr>
475 <td>
476 <span class="cost-cat-pill">{CATEGORY_LABELS[c.category] || c.category}</span>
477 </td>
478 <td class="is-num">{formatCents(c.cents)}</td>
479 <td class="is-num">{formatTokens(c.inputTokens)}</td>
480 <td class="is-num">{formatTokens(c.outputTokens)}</td>
481 </tr>
482 ))}
483 </tbody>
484 </table>
485 )}
486 </div>
487 </section>
488
489 {/* By repo */}
490 <section class="cost-section">
491 <header class="cost-section-head">
492 <div>
493 <h3 class="cost-section-title">Top repos this month</h3>
494 <p class="cost-section-sub">Top 10 repositories by spend. NULL = global activity (not repo-scoped).</p>
495 </div>
496 </header>
497 <div class="cost-section-body">
498 {data.thisMonth.byRepo.length === 0 ? (
499 <div class="cost-empty">No repo-scoped AI activity yet this month.</div>
500 ) : (
501 <table class="cost-table">
502 <thead>
503 <tr>
504 <th>Repository</th>
505 <th style="text-align:right">Spend</th>
506 <th style="text-align:right">Tokens</th>
507 </tr>
508 </thead>
509 <tbody>
510 {data.thisMonth.byRepo.slice(0, 10).map((r) => {
511 const meta = r.repositoryId ? repoLookup.get(r.repositoryId) : null;
512 const label = meta ? `${meta.owner}/${meta.name}` : "(global)";
513 return (
514 <tr>
515 <td>{meta ? <a href={`/${meta.owner}/${meta.name}`}>{label}</a> : <span style="color:var(--text-muted)">{label}</span>}</td>
516 <td class="is-num">{formatCents(r.cents)}</td>
517 <td class="is-num">{formatTokens(r.inputTokens + r.outputTokens)}</td>
518 </tr>
519 );
520 })}
521 </tbody>
522 </table>
523 )}
524 </div>
525 </section>
526
527 {/* By agent */}
528 <section class="cost-section">
529 <header class="cost-section-head">
530 <div>
531 <h3 class="cost-section-title">Top agents this month</h3>
532 <p class="cost-section-sub">Top 10 agent sessions by spend. Manage caps at /settings/agents.</p>
533 </div>
534 </header>
535 <div class="cost-section-body">
536 {data.thisMonth.byAgent.filter((a) => a.agentSessionId).length === 0 ? (
537 <div class="cost-empty">No agent-attributed AI activity yet this month.</div>
538 ) : (
539 <table class="cost-table">
540 <thead>
541 <tr>
542 <th>Agent session</th>
543 <th style="text-align:right">Spend</th>
544 <th style="text-align:right">Tokens</th>
545 </tr>
546 </thead>
547 <tbody>
548 {data.thisMonth.byAgent
549 .filter((a) => a.agentSessionId)
550 .slice(0, 10)
551 .map((a) => {
552 const meta = a.agentSessionId ? agentLookup.get(a.agentSessionId) : null;
553 const label = meta ? meta.name : a.agentSessionId?.slice(0, 8) || "?";
554 return (
555 <tr>
556 <td>
557 <span style="font-family:var(--font-mono);font-size:12.5px">{label}</span>
558 </td>
559 <td class="is-num">{formatCents(a.cents)}</td>
560 <td class="is-num">{formatTokens(a.inputTokens + a.outputTokens)}</td>
561 </tr>
562 );
563 })}
564 </tbody>
565 </table>
566 )}
567 </div>
568 </section>
569
570 {/* Budget form */}
571 <section class="cost-section">
572 <header class="cost-section-head">
573 <div>
574 <h3 class="cost-section-title">Monthly budget</h3>
575 <p class="cost-section-sub">Advisory cap; we warn when projected EOM exceeds it. 0 disables the warning.</p>
576 </div>
577 </header>
578 <div class="cost-section-body">
579 <form
580 method="post"
581 action="/billing/usage/budget"
582 class="cost-budget-form"
583 >
584 <label for="budget-dollars">Cap (USD)</label>
585 <input
586 type="number"
587 id="budget-dollars"
588 name="dollars"
589 min="0"
590 step="1"
591 value={String(Math.round((data.budgetCents || 0) / 100))}
592 />
593 <button type="submit">Save</button>
594 </form>
595 <p class="cost-budget-note">
596 Current cap:&nbsp;
597 <span style="font-family:var(--font-mono);color:var(--text-strong)">{data.budgetCents > 0 ? formatCents(data.budgetCents) : "no cap"}</span>.
598 </p>
599 </div>
600 </section>
601
602 <div class="cost-foot">
603 Need raw events? <a href="/api/v2/usage/me">GET /api/v2/usage/me</a>{" "}
604 returns the same shape as this page in JSON.
605 </div>
606 </div>
607 </Layout>
608 );
609});
610
611// ─── POST /billing/usage/budget ─────────────────────────────────────────
612usage.post("/billing/usage/budget", requireAuth, async (c) => {
613 const user = c.get("user")!;
614 const body = await c.req.parseBody();
615 const rawDollars = body.dollars;
616 const dollars = Math.max(0, Math.floor(Number(rawDollars) || 0));
617 const cents = dollars * 100;
618 try {
619 await db
620 .insert(aiBudgets)
621 .values({ userId: user.id, monthlyCents: cents })
622 .onConflictDoUpdate({
623 target: aiBudgets.userId,
624 set: { monthlyCents: cents, updatedAt: new Date() },
625 });
626 } catch (err) {
627 console.warn(
628 "[billing-usage] budget save failed:",
629 err instanceof Error ? err.message : err
630 );
631 }
632 return c.redirect("/billing/usage?saved=1");
633});
634
635// ─── Helpers ────────────────────────────────────────────────────────────
636async function loadRepoLookup(
637 repoIds: string[]
638): Promise<Map<string, { name: string; owner: string }>> {
639 const lookup = new Map<string, { name: string; owner: string }>();
640 if (repoIds.length === 0) return lookup;
641 try {
642 const rows = await db
643 .select({
644 id: repositories.id,
645 name: repositories.name,
646 ownerName: users.username,
647 })
648 .from(repositories)
649 .innerJoin(users, eq(repositories.ownerId, users.id));
650 for (const r of rows) {
651 if (repoIds.includes(r.id)) {
652 lookup.set(r.id, { name: r.name, owner: r.ownerName });
653 }
654 }
655 } catch {
656 /* tolerate */
657 }
658 return lookup;
659}
660
661async function loadAgentLookup(
662 sessionIds: string[]
663): Promise<Map<string, { name: string }>> {
664 const lookup = new Map<string, { name: string }>();
665 if (sessionIds.length === 0) return lookup;
666 try {
667 const rows = await db
668 .select({ id: agentSessions.id, name: agentSessions.name })
669 .from(agentSessions);
670 for (const r of rows) {
671 if (sessionIds.includes(r.id)) lookup.set(r.id, { name: r.name });
672 }
673 } catch {
674 /* tolerate */
675 }
676 return lookup;
677}
678
679// ─── Test-only exports ──────────────────────────────────────────────────
680export const __test = {
681 buildTrendSparkline,
682 buildDashboardForUser,
683 CATEGORY_LABELS,
684};
685
686export default usage;
Modifiedsrc/routes/help.tsx+42−0View fileUnifiedSplit
281281 <a href="#git-ssh">Git over SSH</a>
282282 <a href="#import">Importing from GitHub</a>
283283 <a href="#webhooks">Webhooks</a>
284 <a href="#chat-bots">Slack &amp; Discord bots</a>
284285 <a href="#tokens">Personal access tokens</a>
285286 <a href="#gates">Gates & AI review</a>
286287 <a href="#ai-native">AI-native flow</a>
452453 </div>
453454 </section>
454455
456 {/* ─── Slack + Discord bots ─── */}
457 <section id="chat-bots" class="help-section">
458 <div class="help-section-head">
459 <div class="help-section-eyebrow">Integrations</div>
460 <h2 class="help-section-title">Slack &amp; Discord</h2>
461 <p class="help-section-desc">
462 Install the Gluecron bot from{" "}
463 <a href="/settings/integrations">/settings/integrations</a>{" "}
464 to drive your repos from chat. PR opens, merges, and AI
465 review summaries also push back into the channel.
466 </p>
467 </div>
468 <div class="help-section-body">
469 <div class="help-item">
470 <strong>Slash commands.</strong>
471 <pre class="help-code">
472{`/gluecron pr list owner/repo
473/gluecron pr open owner/repo "Add dark mode"
474/gluecron issue list owner/repo
475/gluecron issue create owner/repo "Bug in foo()"
476/gluecron spec ship "rewrite the cron scheduler"
477/gluecron chat "How do I run the tests?"
478/gluecron help`}
479 </pre>
480 </div>
481 <div class="help-item">
482 <strong>Signature verification.</strong> Slack requests are
483 checked against your signing secret (HMAC-SHA256 over{" "}
484 <code>v0:&lt;timestamp&gt;:&lt;body&gt;</code>). Discord
485 interactions are verified against your Application Public
486 Key with Ed25519. Bad signatures get a 401.
487 </div>
488 <div class="help-item">
489 <strong>Outbound notifications.</strong> Once installed, PR
490 opens / merges, issue creates, and AI review summaries
491 auto-post to your channel. Disable on a per-workspace basis
492 from <a href="/settings/integrations">/settings/integrations</a>.
493 </div>
494 </div>
495 </section>
496
455497 {/* ─── Tokens ─── */}
456498 <section id="tokens" class="help-section">
457499 <div class="help-section-head">
Modifiedsrc/routes/pulls.tsx+19−0View fileUnifiedSplit
26652665 })
26662666 .where(eq(pullRequests.id, pr.id));
26672667
2668 // Chat notifier — fan out merge event to Slack/Discord/Teams.
2669 import("../lib/chat-notifier")
2670 .then((m) =>
2671 m.notifyChatChannels({
2672 ownerUserId: resolved.repo.ownerId,
2673 repositoryId: resolved.repo.id,
2674 event: {
2675 event: "pr.merged",
2676 repo: `${ownerName}/${repoName}`,
2677 title: `#${pr.number} ${pr.title}`,
2678 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2679 actor: user.username,
2680 },
2681 })
2682 )
2683 .catch((err) =>
2684 console.warn(`[chat-notifier] PR merge notify failed:`, err)
2685 );
2686
26682687 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
26692688 // and auto-close each matching open issue with a back-link comment. Bounded
26702689 // to the same repo for v1 (cross-repo refs ignored). Failures never block
Modifiedsrc/views/layout.tsx+1−0View fileUnifiedSplit
768768 { label: 'Passkeys settings', href: '/settings/passkeys', kw: 'webauthn' },
769769 { label: 'Personal access tokens', href: '/settings/tokens', kw: 'pat api' },
770770 { label: 'Billing + quotas', href: '/settings/billing', kw: 'plans money' },
771 { label: 'AI usage + cost', href: '/billing/usage', kw: 'spend tokens anthropic budget' },
771772 { label: 'Audit log (personal)', href: '/settings/audit', kw: 'history' },
772773 { label: 'Gists', href: '/gists', kw: 'snippets' },
773774 { label: 'GraphQL explorer', href: '/api/graphql', kw: 'api query' },
774775