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

ai-hours-saved.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.

ai-hours-saved.tsBlame413 lines · 1 contributor
46d6165Claude1/**
2 * Block L9 — AI hours-saved counter.
3 *
4 * Pure compute layer for the "Claude saved you X hours this week" widget
5 * on the Command Center dashboard. Tallies AI-driven events across repos
6 * the user owns and translates them into a transparent, audit-friendly
7 * hours-saved estimate.
8 *
9 * ─── Formula (conservative, intentionally) ──────────────────────────
10 *
11 * hoursSaved =
12 * prsAutoMerged * 0.30 // avoid a click + a refresh
13 * + issuesBuiltByAi * 1.50 // AI did the writing
14 * + aiReviewsPosted * 0.25 // saved a manual review pass
15 * + aiTriagesPosted * 0.10 // labels + reviewer suggestions
16 * + aiCommitMsgs * 0.05 // tiny but counts
17 * + secretsAutoRepaired * 0.50 // would've been a panic
18 * + gateAutoRepairs * 0.40 // would've been a re-run
19 *
20 * Round the final number to 1 decimal place. Constants are heuristics
21 * — keep them conservative so users trust the counter. Audit-friendly
22 * is the brand.
23 *
24 * `computeHoursSaved` is exported so L1 Sleep Mode (and anyone else
25 * who needs the same value) can reuse the identical formula.
26 *
27 * Every async function in this module **never throws**: on DB error,
28 * the report falls back to all-zero counts so the dashboard widget
29 * always renders.
30 */
31
32import { and, eq, gte, inArray, like, or, sql } from "drizzle-orm";
33import { db } from "../db";
34import {
35 auditLog,
36 gateRuns,
37 issueComments,
38 issues,
39 prComments,
40 pullRequests,
41 repositories,
42 users,
43} from "../db/schema";
44
45// ───────────────────────────────────────────────────────────────────
46// Types
47// ───────────────────────────────────────────────────────────────────
48
49export type AiSavingsBreakdown = {
50 prsAutoMerged: number;
51 issuesBuiltByAi: number;
52 aiReviewsPosted: number;
53 aiTriagesPosted: number;
54 aiCommitMsgs: number;
55 secretsAutoRepaired: number;
56 gateAutoRepairs: number;
57};
58
59export type AiSavingsReport = {
60 windowHours: number;
61 breakdown: AiSavingsBreakdown;
62 hoursSaved: number;
63};
64
65export type AiSavingsLifetimeReport = {
66 hoursSaved: number;
67 breakdown: AiSavingsBreakdown;
68 sinceCreatedAt: Date;
69};
70
71/**
72 * Marker substrings used to identify AI-authored content in tables
73 * that don't have a dedicated `is_ai_*` flag. Importing from each
74 * module would create a circular dependency in some test setups,
75 * so we duplicate the small string constants here. If they ever
76 * drift, the search will under-count — preferable to over-counting.
77 */
78const PR_TRIAGE_MARKER_FRAGMENT = "gluecron-pr-triage:summary";
79const ISSUE_TRIAGE_MARKER_FRAGMENT = "gluecron-issue-triage:summary";
80
81/** Audit-log action constants we look for. Public so callers (and
82 * tests) don't have to repeat string literals. */
83export const AI_AUDIT_ACTIONS = {
84 AUTO_MERGE_MERGED: "auto_merge.merged",
85 AI_BUILD_DISPATCHED: "ai_build.dispatched",
86 AI_COMMIT_MESSAGE: "ai.commit_message.generated",
87} as const;
88
89// ───────────────────────────────────────────────────────────────────
90// Pure formula
91// ───────────────────────────────────────────────────────────────────
92
93/**
94 * Pure decision helper. Translates an event breakdown into hours
95 * saved using the documented formula. Synchronous + deterministic.
96 *
97 * Re-exported so Block L1 (Sleep Mode) can call the same function
98 * and stay in lock-step with the dashboard widget number.
99 */
100export function computeHoursSaved(breakdown: AiSavingsBreakdown): number {
101 const raw =
102 breakdown.prsAutoMerged * 0.30 +
103 breakdown.issuesBuiltByAi * 1.50 +
104 breakdown.aiReviewsPosted * 0.25 +
105 breakdown.aiTriagesPosted * 0.10 +
106 breakdown.aiCommitMsgs * 0.05 +
107 breakdown.secretsAutoRepaired * 0.50 +
108 breakdown.gateAutoRepairs * 0.40;
109 // Round to 1dp without floating-point drift surfacing in the UI.
110 return Math.round(raw * 10) / 10;
111}
112
113/** Zero-valued breakdown — used as the fallback on DB error. */
114export function emptyBreakdown(): AiSavingsBreakdown {
115 return {
116 prsAutoMerged: 0,
117 issuesBuiltByAi: 0,
118 aiReviewsPosted: 0,
119 aiTriagesPosted: 0,
120 aiCommitMsgs: 0,
121 secretsAutoRepaired: 0,
122 gateAutoRepairs: 0,
123 };
124}
125
126// ───────────────────────────────────────────────────────────────────
127// DI seam — collaborator interface for the counters
128// ───────────────────────────────────────────────────────────────────
129
130/**
131 * One async function per breakdown counter, plus a `getRepoIds` helper
132 * that resolves the set of repos a user owns. The default implementations
133 * hit the DB; tests inject deterministic fakes.
134 */
135export interface AiSavingsDeps {
136 getRepoIds: (userId: string) => Promise<string[]>;
137 countPrsAutoMerged: (repoIds: string[], since: Date) => Promise<number>;
138 countIssuesBuiltByAi: (repoIds: string[], since: Date) => Promise<number>;
139 countAiReviewsPosted: (repoIds: string[], since: Date) => Promise<number>;
140 countAiTriagesPosted: (repoIds: string[], since: Date) => Promise<number>;
141 countAiCommitMsgs: (repoIds: string[], since: Date) => Promise<number>;
142 countSecretsAutoRepaired: (repoIds: string[], since: Date) => Promise<number>;
143 countGateAutoRepairs: (repoIds: string[], since: Date) => Promise<number>;
144 getUserCreatedAt: (userId: string) => Promise<Date | null>;
145}
146
147// ───────────────────────────────────────────────────────────────────
148// Default DB-backed implementations
149// ───────────────────────────────────────────────────────────────────
150
151const SECRET_GATE_NAMES = ["Secret scan", "Secret Scan", "Security scan", "Security Scan"];
152
153async function defaultGetRepoIds(userId: string): Promise<string[]> {
154 const rows = await db
155 .select({ id: repositories.id })
156 .from(repositories)
157 .where(eq(repositories.ownerId, userId));
158 return rows.map((r) => r.id);
159}
160
161async function defaultGetUserCreatedAt(userId: string): Promise<Date | null> {
162 const rows = await db
163 .select({ createdAt: users.createdAt })
164 .from(users)
165 .where(eq(users.id, userId))
166 .limit(1);
167 return rows[0]?.createdAt ?? null;
168}
169
170async function countAuditAction(
171 repoIds: string[],
172 since: Date,
173 action: string
174): Promise<number> {
175 if (repoIds.length === 0) return 0;
176 const rows = await db
177 .select({ n: sql<number>`count(*)::int` })
178 .from(auditLog)
179 .where(
180 and(
181 eq(auditLog.action, action),
182 inArray(auditLog.repositoryId, repoIds),
183 gte(auditLog.createdAt, since)
184 )
185 );
186 return Number(rows[0]?.n ?? 0);
187}
188
189async function defaultCountPrsAutoMerged(repoIds: string[], since: Date): Promise<number> {
190 return countAuditAction(repoIds, since, AI_AUDIT_ACTIONS.AUTO_MERGE_MERGED);
191}
192
193async function defaultCountIssuesBuiltByAi(repoIds: string[], since: Date): Promise<number> {
194 return countAuditAction(repoIds, since, AI_AUDIT_ACTIONS.AI_BUILD_DISPATCHED);
195}
196
197async function defaultCountAiCommitMsgs(repoIds: string[], since: Date): Promise<number> {
198 // FOLLOW-UP: no producer currently emits this action — `ai-generators.ts
199 // generateCommitMessage` is wired into routes but doesn't audit. Count is
200 // always zero until a producer is added. Keep the formula honest by leaving
201 // the constant at the smallest weight (0.05) so its omission rounds away.
202 return countAuditAction(repoIds, since, AI_AUDIT_ACTIONS.AI_COMMIT_MESSAGE);
203}
204
205async function defaultCountAiReviewsPosted(repoIds: string[], since: Date): Promise<number> {
206 if (repoIds.length === 0) return 0;
207 const rows = await db
208 .select({ n: sql<number>`count(*)::int` })
209 .from(prComments)
210 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
211 .where(
212 and(
213 eq(prComments.isAiReview, true),
214 inArray(pullRequests.repositoryId, repoIds),
215 gte(prComments.createdAt, since)
216 )
217 );
218 return Number(rows[0]?.n ?? 0);
219}
220
221async function defaultCountAiTriagesPosted(repoIds: string[], since: Date): Promise<number> {
222 if (repoIds.length === 0) return 0;
223 const [prRows, issueRows] = await Promise.all([
224 db
225 .select({ n: sql<number>`count(*)::int` })
226 .from(prComments)
227 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
228 .where(
229 and(
230 like(prComments.body, `%${PR_TRIAGE_MARKER_FRAGMENT}%`),
231 inArray(pullRequests.repositoryId, repoIds),
232 gte(prComments.createdAt, since)
233 )
234 ),
235 db
236 .select({ n: sql<number>`count(*)::int` })
237 .from(issueComments)
238 .innerJoin(issues, eq(issueComments.issueId, issues.id))
239 .where(
240 and(
241 like(issueComments.body, `%${ISSUE_TRIAGE_MARKER_FRAGMENT}%`),
242 inArray(issues.repositoryId, repoIds),
243 gte(issueComments.createdAt, since)
244 )
245 ),
246 ]);
247 return Number(prRows[0]?.n ?? 0) + Number(issueRows[0]?.n ?? 0);
248}
249
250async function defaultCountSecretsAutoRepaired(
251 repoIds: string[],
252 since: Date
253): Promise<number> {
254 if (repoIds.length === 0) return 0;
255 const rows = await db
256 .select({ n: sql<number>`count(*)::int` })
257 .from(gateRuns)
258 .where(
259 and(
260 eq(gateRuns.status, "repaired"),
261 inArray(gateRuns.gateName, SECRET_GATE_NAMES),
262 inArray(gateRuns.repositoryId, repoIds),
263 gte(gateRuns.createdAt, since)
264 )
265 );
266 return Number(rows[0]?.n ?? 0);
267}
268
269async function defaultCountGateAutoRepairs(
270 repoIds: string[],
271 since: Date
272): Promise<number> {
273 if (repoIds.length === 0) return 0;
274 // All gates EXCEPT the secret/security ones (those are counted separately).
275 const rows = await db
276 .select({ n: sql<number>`count(*)::int` })
277 .from(gateRuns)
278 .where(
279 and(
280 eq(gateRuns.status, "repaired"),
281 inArray(gateRuns.repositoryId, repoIds),
282 gte(gateRuns.createdAt, since),
283 sql`${gateRuns.gateName} NOT IN ('Secret scan','Secret Scan','Security scan','Security Scan')`
284 )
285 );
286 return Number(rows[0]?.n ?? 0);
287}
288
289const DEFAULT_DEPS: AiSavingsDeps = {
290 getRepoIds: defaultGetRepoIds,
291 countPrsAutoMerged: defaultCountPrsAutoMerged,
292 countIssuesBuiltByAi: defaultCountIssuesBuiltByAi,
293 countAiReviewsPosted: defaultCountAiReviewsPosted,
294 countAiTriagesPosted: defaultCountAiTriagesPosted,
295 countAiCommitMsgs: defaultCountAiCommitMsgs,
296 countSecretsAutoRepaired: defaultCountSecretsAutoRepaired,
297 countGateAutoRepairs: defaultCountGateAutoRepairs,
298 getUserCreatedAt: defaultGetUserCreatedAt,
299};
300
301// ───────────────────────────────────────────────────────────────────
302// Public orchestrators
303// ───────────────────────────────────────────────────────────────────
304
305/**
306 * Compute the rolling-window savings report for a single user.
307 *
308 * `windowHours` defaults to one week (168h). `now` lets tests pin
309 * the cutoff deterministically. Never throws — DB errors degrade
310 * to an all-zero breakdown.
311 */
312export async function computeAiSavingsForUser(
313 userId: string,
314 opts: { windowHours?: number; now?: Date; deps?: AiSavingsDeps } = {}
315): Promise<AiSavingsReport> {
316 const windowHours = opts.windowHours ?? 168;
317 const now = opts.now ?? new Date();
318 const since = new Date(now.getTime() - windowHours * 3600 * 1000);
319 const deps = opts.deps ?? DEFAULT_DEPS;
320
321 try {
322 const repoIds = await deps.getRepoIds(userId);
323 const [
324 prsAutoMerged,
325 issuesBuiltByAi,
326 aiReviewsPosted,
327 aiTriagesPosted,
328 aiCommitMsgs,
329 secretsAutoRepaired,
330 gateAutoRepairs,
331 ] = await Promise.all([
332 deps.countPrsAutoMerged(repoIds, since),
333 deps.countIssuesBuiltByAi(repoIds, since),
334 deps.countAiReviewsPosted(repoIds, since),
335 deps.countAiTriagesPosted(repoIds, since),
336 deps.countAiCommitMsgs(repoIds, since),
337 deps.countSecretsAutoRepaired(repoIds, since),
338 deps.countGateAutoRepairs(repoIds, since),
339 ]);
340
341 const breakdown: AiSavingsBreakdown = {
342 prsAutoMerged,
343 issuesBuiltByAi,
344 aiReviewsPosted,
345 aiTriagesPosted,
346 aiCommitMsgs,
347 secretsAutoRepaired,
348 gateAutoRepairs,
349 };
350
351 return {
352 windowHours,
353 breakdown,
354 hoursSaved: computeHoursSaved(breakdown),
355 };
356 } catch (err) {
357 console.error("[ai-hours-saved] degraded to zeros:", err);
358 return {
359 windowHours,
360 breakdown: emptyBreakdown(),
361 hoursSaved: 0,
362 };
363 }
364}
365
366/**
367 * Compute the lifetime savings report for a single user — same shape
368 * as the windowed version but the cutoff is the user's `created_at`.
369 * Falls back to "30 days ago" if the user row can't be fetched.
370 */
371export async function computeLifetimeAiSavingsForUser(
372 userId: string,
373 opts: { deps?: AiSavingsDeps; now?: Date } = {}
374): Promise<AiSavingsLifetimeReport> {
375 const deps = opts.deps ?? DEFAULT_DEPS;
376 const now = opts.now ?? new Date();
377 try {
378 const createdAt =
379 (await deps.getUserCreatedAt(userId)) ??
380 new Date(now.getTime() - 30 * 24 * 3600 * 1000);
381 const windowHours = Math.max(
382 1,
383 Math.ceil((now.getTime() - createdAt.getTime()) / (3600 * 1000))
384 );
385 const report = await computeAiSavingsForUser(userId, {
386 windowHours,
387 now,
388 deps,
389 });
390 return {
391 hoursSaved: report.hoursSaved,
392 breakdown: report.breakdown,
393 sinceCreatedAt: createdAt,
394 };
395 } catch (err) {
396 console.error("[ai-hours-saved] lifetime degraded to zeros:", err);
397 return {
398 hoursSaved: 0,
399 breakdown: emptyBreakdown(),
400 sinceCreatedAt: now,
401 };
402 }
403}
404
405// ───────────────────────────────────────────────────────────────────
406// Test-only seam
407// ───────────────────────────────────────────────────────────────────
408
409export const __test = {
410 DEFAULT_DEPS,
411 PR_TRIAGE_MARKER_FRAGMENT,
412 ISSUE_TRIAGE_MARKER_FRAGMENT,
413};