Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

pr-size.tsx

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

pr-size.tsxBlame336 lines · 1 contributor
28bc555Claude1/**
2 * Block J32 — PR size distribution metric.
3 *
4 * GET /:owner/:repo/insights/pr-size[?window=7|30|90|365|0&top=N]
5 *
6 * Renders five KPI cards (total / median / mean / p90 / small-PR ratio),
7 * a five-class histogram (XS / S / M / L / XL), and the largest N PRs
8 * in the window. Git numstat is computed per-PR against base..head via
9 * `diffNumstat`, capped to 500 PRs per request so one repo can't pin a
10 * whole server.
11 *
12 * softAuth; private repos 404 for non-owner viewers. Git failures on a
13 * single PR yield zero lines changed for that PR (it still renders in
14 * the XS bucket) rather than taking the whole page out.
15 */
16
17import { Hono } from "hono";
18import { and, desc, eq } from "drizzle-orm";
19import { db } from "../db";
20import { pullRequests, repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { diffNumstat } from "../git/repository";
26import {
27 DEFAULT_TOP_N,
28 VALID_WINDOWS,
29 buildPrSizeReport,
30 parseWindow,
31 type PrSizeInput,
32} from "../lib/pr-size";
33import { formatPercent } from "../lib/language-stats";
34
35const MAX_PRS = 500;
36const MAX_TOP_N = 50;
37
38const prSizeRoutes = new Hono<AuthEnv>();
39
40prSizeRoutes.use("*", softAuth);
41
42async function resolveRepo(ownerName: string, repoName: string) {
43 try {
44 const [owner] = await db
45 .select()
46 .from(users)
47 .where(eq(users.username, ownerName))
48 .limit(1);
49 if (!owner) return null;
50 const [repo] = await db
51 .select()
52 .from(repositories)
53 .where(
54 and(
55 eq(repositories.ownerId, owner.id),
56 eq(repositories.name, repoName)
57 )
58 )
59 .limit(1);
60 if (!repo) return null;
61 return { owner, repo };
62 } catch {
63 return null;
64 }
65}
66
67function parseTopN(raw: string | undefined): number {
68 if (!raw) return DEFAULT_TOP_N;
69 const n = Number(raw);
70 if (!Number.isFinite(n) || n <= 0) return DEFAULT_TOP_N;
71 return Math.min(Math.floor(n), MAX_TOP_N);
72}
73
74const SIZE_CLASS_COLORS: Record<string, string> = {
75 xs: "#4caf50",
76 s: "#8bc34a",
77 m: "#ffc107",
78 l: "#ff9800",
79 xl: "#f44336",
80};
81
82prSizeRoutes.get("/:owner/:repo/insights/pr-size", async (c) => {
83 const { owner: ownerName, repo: repoName } = c.req.param();
84 const user = c.get("user");
85 const windowDays = parseWindow(c.req.query("window"));
86 const topN = parseTopN(c.req.query("top"));
87
88 const resolved = await resolveRepo(ownerName, repoName);
89 if (!resolved) {
90 return c.html(
91 <Layout title="Not Found" user={user}>
92 <div class="empty-state">
93 <h2>Repository not found</h2>
94 </div>
95 </Layout>,
96 404
97 );
98 }
99
100 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
101 return c.html(
102 <Layout title="Not Found" user={user}>
103 <div class="empty-state">
104 <h2>Repository not found</h2>
105 </div>
106 </Layout>,
107 404
108 );
109 }
110
111 let prRows: {
112 id: string;
113 number: number;
114 title: string;
115 state: string;
116 isDraft: boolean;
117 baseBranch: string;
118 headBranch: string;
119 createdAt: Date;
120 mergedAt: Date | null;
121 closedAt: Date | null;
122 }[] = [];
123 try {
124 prRows = await db
125 .select({
126 id: pullRequests.id,
127 number: pullRequests.number,
128 title: pullRequests.title,
129 state: pullRequests.state,
130 isDraft: pullRequests.isDraft,
131 baseBranch: pullRequests.baseBranch,
132 headBranch: pullRequests.headBranch,
133 createdAt: pullRequests.createdAt,
134 mergedAt: pullRequests.mergedAt,
135 closedAt: pullRequests.closedAt,
136 })
137 .from(pullRequests)
138 .where(eq(pullRequests.repositoryId, resolved.repo.id))
139 .orderBy(desc(pullRequests.createdAt))
140 .limit(MAX_PRS);
141 } catch {
142 // empty → empty report
143 }
144
145 // Diff each PR's base..head in parallel but bounded.
146 const diffs = await Promise.all(
147 prRows.map(async (pr) => {
148 try {
149 const r = await diffNumstat(
150 ownerName,
151 repoName,
152 pr.baseBranch,
153 pr.headBranch
154 );
155 return r ?? { additions: 0, deletions: 0, files: 0 };
156 } catch {
157 return { additions: 0, deletions: 0, files: 0 };
158 }
159 })
160 );
161
162 const inputs: PrSizeInput[] = prRows.map((r, i) => ({
163 id: r.id,
164 number: r.number,
165 title: r.title,
166 state: r.state,
167 isDraft: r.isDraft,
168 createdAt: r.createdAt,
169 mergedAt: r.mergedAt,
170 closedAt: r.closedAt,
171 additions: diffs[i]!.additions,
172 deletions: diffs[i]!.deletions,
173 files: diffs[i]!.files,
174 }));
175
176 const report = buildPrSizeReport({
177 prs: inputs,
178 windowDays,
179 topN,
180 });
181
182 const windowLabel = windowDays === 0 ? "All time" : `Last ${windowDays} days`;
183 const empty = report.summary.total === 0;
184
185 const kpi = (label: string, value: string) => (
186 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
187 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
188 {label}
189 </div>
190 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
191 {value}
192 </div>
193 </div>
194 );
195
196 return c.html(
197 <Layout title={`PR size — ${ownerName}/${repoName}`} user={user}>
198 <RepoHeader owner={ownerName} repo={repoName} />
199 <div style="max-width: 920px">
200 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
201 <h2 style="margin: 0">PR size distribution</h2>
202 <form
203 method="GET"
204 action={`/${ownerName}/${repoName}/insights/pr-size`}
205 style="display: flex; gap: 6px; align-items: center"
206 >
207 <label for="window" style="font-size: 12px; color: var(--text-muted)">
208 Window:
209 </label>
210 <select
211 id="window"
212 name="window"
213 onchange="this.form.submit()"
214 style="padding: 4px 8px; font-size: 12px"
215 >
216 {VALID_WINDOWS.map((w) => (
217 <option value={String(w)} selected={w === windowDays}>
218 {w === 0 ? "All time" : `Last ${w} days`}
219 </option>
220 ))}
221 </select>
222 </form>
223 </div>
224 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
225 <strong>{windowLabel}</strong>. Size = additions + deletions
226 (binaries counted as 0). Merged PRs are anchored on their merge
227 date, unmerged PRs on creation date. Classes: XS ≤10, S ≤50, M
228 ≤250, L ≤1000, XL &gt;1000.
229 </p>
230
231 {empty ? (
232 <div class="empty-state">
233 <h3>No PRs in window</h3>
234 <p>Try widening the time range.</p>
235 </div>
236 ) : (
237 <>
238 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 24px">
239 {kpi("Total", String(report.summary.total))}
240 {kpi("Merged", String(report.summary.merged))}
241 {kpi("Open", String(report.summary.open))}
242 {kpi("Median", `${report.summary.medianLines} lines`)}
243 {kpi("Mean", `${report.summary.meanLines} lines`)}
244 {kpi("p90", `${report.summary.p90Lines} lines`)}
245 {kpi("Largest", `${report.summary.largestLines} lines`)}
246 {kpi("Small-PR ratio", formatPercent(report.summary.smallPrRatio))}
247 </div>
248
249 <h3 style="margin-bottom: 10px">Size distribution</h3>
250 <div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 24px">
251 {report.buckets.map((b) => (
252 <div
253 style={`border: 1px solid var(--border); border-left: 4px solid ${SIZE_CLASS_COLORS[b.key]}; border-radius: var(--radius); padding: 12px; text-align: center`}
254 >
255 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
256 {b.label}
257 </div>
258 <div style="font-size: 22px; font-weight: 600">
259 {b.count}
260 </div>
261 <div style="font-size: 11px; color: var(--text-muted); margin-top: 2px">
262 {b.description}
263 </div>
264 </div>
265 ))}
266 </div>
267
268 <h3 style="margin-bottom: 10px">
269 Largest PRs ({report.largest.length})
270 </h3>
271 {report.largest.length === 0 ? (
272 <div class="empty-state">
273 <p>No PRs matched the filter.</p>
274 </div>
275 ) : (
276 <table style="width: 100%; border-collapse: collapse">
277 <thead>
278 <tr>
279 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
280 PR
281 </th>
282 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 90px">
283 Files
284 </th>
285 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 90px">
286 +/-
287 </th>
288 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 90px">
289 Size
290 </th>
291 <th style="text-align: center; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 60px">
292 Class
293 </th>
294 </tr>
295 </thead>
296 <tbody>
297 {report.largest.map((p) => (
298 <tr>
299 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
300 <a href={`/${ownerName}/${repoName}/pulls/${p.number}`}>
301 <span style="color: var(--text-muted)">
302 #{p.number}
303 </span>{" "}
304 {p.title}
305 </a>
306 </td>
307 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
308 {p.files}
309 </td>
310 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
311 <span style="color: var(--green)">+{p.additions}</span>{" "}
312 <span style="color: var(--red)">-{p.deletions}</span>
313 </td>
314 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
315 {p.linesChanged}
316 </td>
317 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: center">
318 <span
319 style={`display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #fff; background: ${SIZE_CLASS_COLORS[p.sizeClass]}`}
320 >
321 {p.sizeClass.toUpperCase()}
322 </span>
323 </td>
324 </tr>
325 ))}
326 </tbody>
327 </table>
328 )}
329 </>
330 )}
331 </div>
332 </Layout>
333 );
334});
335
336export default prSizeRoutes;