Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

public-stats.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

public-stats.tsBlame363 lines · 1 contributor
52ad8b1Claude1/**
2 * Block L4 — Public stats counters.
3 *
4 * Site-wide, PUBLIC-ONLY counters that power the marketing landing-page
5 * social-proof tiles ("X PRs auto-merged this week", "Y deploys shipped
6 * overnight", etc.).
7 *
8 * Hard contract — PUBLIC repos only.
9 * Every counter that touches per-repo data joins through `repositories`
10 * and filters `is_private = false AND is_archived = false`. Private
11 * repos must NEVER leak into these numbers. The whole point of the
12 * widget is honest, public social proof.
13 *
14 * Never throws. On any DB error every counter degrades to zero and the
15 * report is returned with `asOf = now`. The landing page would rather
16 * render zeros than 500.
17 *
18 * Caching. The marketing page is a hot path; recomputing eight queries
19 * on every render would hammer the DB. Results are memoised in a tiny
20 * `LRUCache` with a 5-minute TTL (`publicStatsCache`).
21 *
22 * DI seam (`PublicStatsDeps`) mirrors the L9 pattern so tests inject
23 * deterministic counters without spinning up Postgres.
24 */
25
26import { and, eq, gte, sql } from "drizzle-orm";
27import { db } from "../db";
28import {
29 auditLog,
30 deployments,
31 gateRuns,
32 issues,
33 prComments,
34 pullRequests,
35 repositories,
36 users,
37} from "../db/schema";
38import { LRUCache } from "./cache";
39import { computeHoursSaved } from "./ai-hours-saved";
40
41// ───────────────────────────────────────────────────────────────────
42// Types
43// ───────────────────────────────────────────────────────────────────
44
45export type PublicStats = {
46 // Lifetime counters
47 totalPublicRepos: number;
48 totalUsers: number;
49 totalPublicPullRequests: number;
50 totalPublicIssues: number;
51 // Trailing-7-days "AI did this" highlights
52 weeklyPrsAutoMerged: number;
53 weeklyIssuesBuiltByAi: number;
54 weeklyAiReviewsPosted: number;
55 weeklySecretsAutoFixed: number;
56 weeklyDeploysShipped: number;
57 // Derived
58 weeklyHoursSaved: number;
59 asOf: Date;
60};
61
62/** Zero-valued stats — used as the fallback on any DB error. */
63export function emptyPublicStats(asOf: Date): PublicStats {
64 return {
65 totalPublicRepos: 0,
66 totalUsers: 0,
67 totalPublicPullRequests: 0,
68 totalPublicIssues: 0,
69 weeklyPrsAutoMerged: 0,
70 weeklyIssuesBuiltByAi: 0,
71 weeklyAiReviewsPosted: 0,
72 weeklySecretsAutoFixed: 0,
73 weeklyDeploysShipped: 0,
74 weeklyHoursSaved: 0,
75 asOf,
76 };
77}
78
79// ───────────────────────────────────────────────────────────────────
80// Audit-log action constants. Importing from `auto-merge.ts` /
81// `ai-build-tasks.ts` would risk a circular-import in some test
82// setups, so the literals are duplicated here. Mirrors the same
83// trade-off `ai-hours-saved.ts` made.
84// ───────────────────────────────────────────────────────────────────
85
86export const PUBLIC_STATS_ACTIONS = {
87 AUTO_MERGE_MERGED: "auto_merge.merged",
88 AI_BUILD_DISPATCHED: "ai_build.dispatched",
89} as const;
90
91const SECRET_GATE_NAME_PATTERNS = ["%secret%", "%Secret%"];
92
93// ───────────────────────────────────────────────────────────────────
94// DI seam
95// ───────────────────────────────────────────────────────────────────
96
97export interface PublicStatsDeps {
98 countTotalPublicRepos: () => Promise<number>;
99 countTotalUsers: () => Promise<number>;
100 countTotalPublicPullRequests: () => Promise<number>;
101 countTotalPublicIssues: () => Promise<number>;
102 countWeeklyPrsAutoMerged: (since: Date) => Promise<number>;
103 countWeeklyIssuesBuiltByAi: (since: Date) => Promise<number>;
104 countWeeklyAiReviewsPosted: (since: Date) => Promise<number>;
105 countWeeklySecretsAutoFixed: (since: Date) => Promise<number>;
106 countWeeklyDeploysShipped: (since: Date) => Promise<number>;
107}
108
109// ───────────────────────────────────────────────────────────────────
110// Default DB-backed implementations.
111//
112// Every per-repo counter JOINs through `repositories` with a hard
113// filter on `is_private = false AND is_archived = false`. That join
114// is the privacy boundary — under no circumstance should it be removed.
115// ───────────────────────────────────────────────────────────────────
116
117async function defaultCountTotalPublicRepos(): Promise<number> {
118 const rows = await db
119 .select({ n: sql<number>`count(*)::int` })
120 .from(repositories)
121 .where(
122 and(
123 eq(repositories.isPrivate, false),
124 eq(repositories.isArchived, false)
125 )
126 );
127 return Number(rows[0]?.n ?? 0);
128}
129
130async function defaultCountTotalUsers(): Promise<number> {
131 const rows = await db
132 .select({ n: sql<number>`count(*)::int` })
133 .from(users);
134 return Number(rows[0]?.n ?? 0);
135}
136
137async function defaultCountTotalPublicPullRequests(): Promise<number> {
138 const rows = await db
139 .select({ n: sql<number>`count(*)::int` })
140 .from(pullRequests)
141 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
142 .where(eq(repositories.isPrivate, false));
143 return Number(rows[0]?.n ?? 0);
144}
145
146async function defaultCountTotalPublicIssues(): Promise<number> {
147 const rows = await db
148 .select({ n: sql<number>`count(*)::int` })
149 .from(issues)
150 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
151 .where(eq(repositories.isPrivate, false));
152 return Number(rows[0]?.n ?? 0);
153}
154
155async function defaultCountWeeklyPrsAutoMerged(since: Date): Promise<number> {
156 const rows = await db
157 .select({ n: sql<number>`count(*)::int` })
158 .from(auditLog)
159 .innerJoin(repositories, eq(auditLog.repositoryId, repositories.id))
160 .where(
161 and(
162 eq(auditLog.action, PUBLIC_STATS_ACTIONS.AUTO_MERGE_MERGED),
163 eq(repositories.isPrivate, false),
164 gte(auditLog.createdAt, since)
165 )
166 );
167 return Number(rows[0]?.n ?? 0);
168}
169
170async function defaultCountWeeklyIssuesBuiltByAi(since: Date): Promise<number> {
171 const rows = await db
172 .select({ n: sql<number>`count(*)::int` })
173 .from(auditLog)
174 .innerJoin(repositories, eq(auditLog.repositoryId, repositories.id))
175 .where(
176 and(
177 eq(auditLog.action, PUBLIC_STATS_ACTIONS.AI_BUILD_DISPATCHED),
178 eq(repositories.isPrivate, false),
179 gte(auditLog.createdAt, since)
180 )
181 );
182 return Number(rows[0]?.n ?? 0);
183}
184
185async function defaultCountWeeklyAiReviewsPosted(since: Date): Promise<number> {
186 const rows = await db
187 .select({ n: sql<number>`count(*)::int` })
188 .from(prComments)
189 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
190 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
191 .where(
192 and(
193 eq(prComments.isAiReview, true),
194 eq(repositories.isPrivate, false),
195 gte(prComments.createdAt, since)
196 )
197 );
198 return Number(rows[0]?.n ?? 0);
199}
200
201async function defaultCountWeeklySecretsAutoFixed(since: Date): Promise<number> {
202 // `gate_name LIKE '%secret%'` — case-insensitive via ILIKE so we catch
203 // "Secret scan", "Secret Scan", "secret-scan", etc. Restrict to
204 // `status = 'repaired'` so we only count auto-repair successes.
205 const rows = await db
206 .select({ n: sql<number>`count(*)::int` })
207 .from(gateRuns)
208 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
209 .where(
210 and(
211 eq(gateRuns.status, "repaired"),
212 sql`${gateRuns.gateName} ILIKE ${SECRET_GATE_NAME_PATTERNS[0]}`,
213 eq(repositories.isPrivate, false),
214 gte(gateRuns.createdAt, since)
215 )
216 );
217 return Number(rows[0]?.n ?? 0);
218}
219
220async function defaultCountWeeklyDeploysShipped(since: Date): Promise<number> {
221 // Spec says `status='succeeded'`; the schema currently emits `success`
222 // (see `src/db/schema.ts` deployments.status enum comment + every call
223 // site in `src/lib/deploy-pipeline.ts`). Accept BOTH so the counter
224 // remains correct whether a future migration normalises the spelling.
225 const rows = await db
226 .select({ n: sql<number>`count(*)::int` })
227 .from(deployments)
228 .innerJoin(repositories, eq(deployments.repositoryId, repositories.id))
229 .where(
230 and(
231 sql`${deployments.status} IN ('success','succeeded')`,
232 eq(repositories.isPrivate, false),
233 gte(deployments.createdAt, since)
234 )
235 );
236 return Number(rows[0]?.n ?? 0);
237}
238
239const DEFAULT_DEPS: PublicStatsDeps = {
240 countTotalPublicRepos: defaultCountTotalPublicRepos,
241 countTotalUsers: defaultCountTotalUsers,
242 countTotalPublicPullRequests: defaultCountTotalPublicPullRequests,
243 countTotalPublicIssues: defaultCountTotalPublicIssues,
244 countWeeklyPrsAutoMerged: defaultCountWeeklyPrsAutoMerged,
245 countWeeklyIssuesBuiltByAi: defaultCountWeeklyIssuesBuiltByAi,
246 countWeeklyAiReviewsPosted: defaultCountWeeklyAiReviewsPosted,
247 countWeeklySecretsAutoFixed: defaultCountWeeklySecretsAutoFixed,
248 countWeeklyDeploysShipped: defaultCountWeeklyDeploysShipped,
249};
250
251// ───────────────────────────────────────────────────────────────────
252// Caching
253// ───────────────────────────────────────────────────────────────────
254
255const CACHE_TTL_MS = 5 * 60 * 1000;
256
257/** In-memory cache for the computed PublicStats. Single key: "public". */
258export const publicStatsCache = new LRUCache<PublicStats>(4, CACHE_TTL_MS);
259
260/** Clear the cache. Test-only. */
261export function __resetPublicStatsCache(): void {
262 publicStatsCache.clear();
263}
264
265// ───────────────────────────────────────────────────────────────────
266// Public orchestrator
267// ───────────────────────────────────────────────────────────────────
268
269export interface ComputePublicStatsOpts {
270 now?: Date;
271 deps?: PublicStatsDeps;
272 /** When true, skip the in-memory cache layer (test seam). */
273 noCache?: boolean;
274}
275
276/**
277 * Compute the site-wide public stats report.
278 *
279 * Trailing window for the "weekly" counters is 7 days. The lifetime
280 * counters are unbounded. Never throws — DB errors degrade to
281 * `emptyPublicStats(now)`.
282 */
283export async function computePublicStats(
284 opts: ComputePublicStatsOpts = {}
285): Promise<PublicStats> {
286 const now = opts.now ?? new Date();
287 const deps = opts.deps ?? DEFAULT_DEPS;
288 const useCache = !opts.noCache && !opts.deps;
289
290 if (useCache) {
291 const hit = publicStatsCache.get("public");
292 if (hit) return hit;
293 }
294
295 const since = new Date(now.getTime() - 7 * 24 * 3600 * 1000);
296
297 try {
298 const [
299 totalPublicRepos,
300 totalUsers,
301 totalPublicPullRequests,
302 totalPublicIssues,
303 weeklyPrsAutoMerged,
304 weeklyIssuesBuiltByAi,
305 weeklyAiReviewsPosted,
306 weeklySecretsAutoFixed,
307 weeklyDeploysShipped,
308 ] = await Promise.all([
309 deps.countTotalPublicRepos(),
310 deps.countTotalUsers(),
311 deps.countTotalPublicPullRequests(),
312 deps.countTotalPublicIssues(),
313 deps.countWeeklyPrsAutoMerged(since),
314 deps.countWeeklyIssuesBuiltByAi(since),
315 deps.countWeeklyAiReviewsPosted(since),
316 deps.countWeeklySecretsAutoFixed(since),
317 deps.countWeeklyDeploysShipped(since),
318 ]);
319
320 // Reuse the L9 hours-saved formula so the public number stays in
321 // lock-step with the per-user dashboard widget. The triage / commit-
322 // message / non-secret-gate buckets aren't surfaced site-wide, so
323 // they're zeroed here — the formula degrades gracefully.
324 const weeklyHoursSaved = computeHoursSaved({
325 prsAutoMerged: weeklyPrsAutoMerged,
326 issuesBuiltByAi: weeklyIssuesBuiltByAi,
327 aiReviewsPosted: weeklyAiReviewsPosted,
328 aiTriagesPosted: 0,
329 aiCommitMsgs: 0,
330 secretsAutoRepaired: weeklySecretsAutoFixed,
331 gateAutoRepairs: 0,
332 });
333
334 const stats: PublicStats = {
335 totalPublicRepos,
336 totalUsers,
337 totalPublicPullRequests,
338 totalPublicIssues,
339 weeklyPrsAutoMerged,
340 weeklyIssuesBuiltByAi,
341 weeklyAiReviewsPosted,
342 weeklySecretsAutoFixed,
343 weeklyDeploysShipped,
344 weeklyHoursSaved,
345 asOf: now,
346 };
347
348 if (useCache) publicStatsCache.set("public", stats);
349 return stats;
350 } catch (err) {
351 console.error("[public-stats] degraded to zeros:", err);
352 return emptyPublicStats(now);
353 }
354}
355
356// ───────────────────────────────────────────────────────────────────
357// Test-only seam
358// ───────────────────────────────────────────────────────────────────
359
360export const __test = {
361 DEFAULT_DEPS,
362 CACHE_TTL_MS,
363};