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

Add repo AI stats strip below file tree on repo overview

Add repo AI stats strip below file tree on repo overview

Renders a compact one-line strip below the FileTable on every repo home
page showing AI-merged PRs this week, estimated hours saved, and open
security alert count. Stats are fetched via getRepoAiStats() which runs
three fast, 7-day-bounded queries against activity_feed, pr_comments, and
issues/labels. Falls back to a "no activity" message when all counts are
zero, and degrades gracefully on any DB error.

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 6, 2026Parent: b218e63
1 file changed+1432ebaae0f2bac5142ba51dd749ed3a456d79ecff49
1 changed file+143−2
Modifiedsrc/routes/web.tsx+143−2View fileUnifiedSplit
55
66import { Hono } from "hono";
77import { html } from "hono/html";
8import { eq, and, desc, inArray, sql } from "drizzle-orm";
8import { eq, and, desc, inArray, sql, gte, count } from "drizzle-orm";
99import { db } from "../db";
1010import { config } from "../lib/config";
1111import {
1414 stars,
1515 commitVerifications,
1616 activityFeed,
17 pullRequests,
18 prComments,
19 issues,
20 labels,
21 issueLabels,
1722} from "../db/schema";
1823import { Layout } from "../views/layout";
1924import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
6469 countAiReviewsSince,
6570 listDemoActivityFeed,
6671} from "../lib/demo-activity";
67import { computeHealthScore, type HealthScore } from "../lib/health-score";
6872
6973const web = new Hono<AuthEnv>();
7074
7175// Soft auth on all web routes — c.get("user") available but may be null
7276web.use("*", softAuth);
7377
78// ---------------------------------------------------------------------------
79// Repo AI stats — computed once per repo home page load, best-effort.
80// Fast because every WHERE clause is bounded to a 7-day window.
81// ---------------------------------------------------------------------------
82interface RepoAiStats {
83 mergedCount: number;
84 reviewCount: number;
85 securityAlertCount: number;
86 hoursSaved: string;
87}
88
89async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
90 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
91
92 // 1. AI-merged PRs this week: activity_feed entries with action =
93 // 'auto_merge.merged' in the last 7 days for this repo.
94 // This is cheaper than a JOIN on pull_requests and covers both the
95 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
96 const [mergedRow] = await db
97 .select({ n: count() })
98 .from(activityFeed)
99 .where(
100 and(
101 eq(activityFeed.repositoryId, repoId),
102 eq(activityFeed.action, "auto_merge.merged"),
103 gte(activityFeed.createdAt, since)
104 )
105 );
106 const mergedCount = Number(mergedRow?.n ?? 0);
107
108 // 2. AI reviews this week: pr_comments where is_ai_review = true in
109 // the last 7 days, joined through pull_requests to scope to this repo.
110 const [reviewRow] = await db
111 .select({ n: count() })
112 .from(prComments)
113 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
114 .where(
115 and(
116 eq(pullRequests.repositoryId, repoId),
117 eq(prComments.isAiReview, true),
118 gte(prComments.createdAt, since)
119 )
120 );
121 const reviewCount = Number(reviewRow?.n ?? 0);
122
123 // 3. Open security alerts: open issues that carry a label whose name
124 // contains 'security' (case-insensitive) for this repo.
125 const [secRow] = await db
126 .select({ n: count() })
127 .from(issues)
128 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
129 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
130 .where(
131 and(
132 eq(issues.repositoryId, repoId),
133 eq(issues.state, "open"),
134 sql`lower(${labels.name}) like '%security%'`
135 )
136 );
137 const securityAlertCount = Number(secRow?.n ?? 0);
138
139 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
140 const hours = mergedCount * 1.5 + reviewCount * 0.5;
141 const hoursSaved = hours.toFixed(1);
142
143 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
144}
145
74146/**
75147 * Query the most recent push to a repo from the activity_feed table.
76148 * Returns a RecentPush if there's been a push in the last 24 hours,
24742546 ? await getRecentPush(repoId)
24752547 : null;
24762548
2549 // AI stats strip — best-effort, fail-open to zero values.
2550 let aiStats: RepoAiStats = {
2551 mergedCount: 0,
2552 reviewCount: 0,
2553 securityAlertCount: 0,
2554 hoursSaved: "0.0",
2555 };
2556 if (repoId) {
2557 try {
2558 aiStats = await getRepoAiStats(repoId);
2559 } catch {
2560 /* swallow — show zero values */
2561 }
2562 }
2563
24772564 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
24782565 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
24792566 const repoHomeCss = `
29273014 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
29283015 .repo-home-side-card { padding: var(--space-3); }
29293016 }
3017
3018 /* AI stats strip */
3019 .repo-ai-stats-strip {
3020 display: flex;
3021 align-items: center;
3022 gap: 6px;
3023 margin-top: 12px;
3024 margin-bottom: 4px;
3025 font-size: 12px;
3026 color: var(--text-muted);
3027 flex-wrap: wrap;
3028 }
3029 .repo-ai-stats-strip a {
3030 color: var(--text-muted);
3031 text-decoration: underline;
3032 text-decoration-color: transparent;
3033 transition: color 120ms ease, text-decoration-color 120ms ease;
3034 }
3035 .repo-ai-stats-strip a:hover {
3036 color: var(--accent);
3037 text-decoration-color: currentColor;
3038 }
3039 .repo-ai-stats-sep {
3040 opacity: 0.4;
3041 user-select: none;
3042 }
29303043 `;
29313044 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
29323045 // SSH URL — port-aware:
34273540 ref={defaultBranch}
34283541 path=""
34293542 />
3543 {/* AI stats strip — one-line summary of AI value for this repo */}
3544 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
3545 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3546 <span>{"⚡"}</span>
3547 {aiStats.mergedCount > 0 ? (
3548 <a href={`/${owner}/${repo}/pulls?state=merged`}>
3549 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
3550 </a>
3551 ) : (
3552 <span>0 AI merges this week</span>
3553 )}
3554 <span class="repo-ai-stats-sep">{"·"}</span>
3555 <span>Saved ~{aiStats.hoursSaved} hrs</span>
3556 <span class="repo-ai-stats-sep">{"·"}</span>
3557 {aiStats.securityAlertCount > 0 ? (
3558 <a href={`/${owner}/${repo}/issues?label=security`}>
3559 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
3560 </a>
3561 ) : (
3562 <span>0 open security alerts</span>
3563 )}
3564 </div>
3565 ) : (
3566 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3567 <span>{"⚡"}</span>
3568 <span>AI is watching — no activity this week</span>
3569 </div>
3570 )}
34303571 {readme && (() => {
34313572 const readmeHtml = renderMarkdown(readme);
34323573 return (
34333574