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-lead-time.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-lead-time.tsxBlame252 lines · 1 contributor
8c5346cClaude1/**
2 * Block J29 — PR lead-time insights page.
3 *
4 * GET /:owner/:repo/insights/lead-time[?window=7|30|90|365|0]
5 *
6 * softAuth, read-only. Fetches PRs for the repo, runs the pure
7 * `buildLeadTimeReport`, renders a KPI grid (p50/mean/p90/fastest/slowest),
8 * four latency buckets, and the oldest still-open PRs.
9 */
10
11import { Hono } from "hono";
12import { eq, and } from "drizzle-orm";
13import { db } from "../db";
14import { repositories, users, pullRequests } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader } from "../views/components";
17import { softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 buildLeadTimeReport,
21 formatDuration,
22 parseWindow,
23 VALID_WINDOWS,
24 type PrLeadTimeInput,
25} from "../lib/pr-lead-time";
26
27const prLeadTimeRoutes = new Hono<AuthEnv>();
28
29prLeadTimeRoutes.use("*", softAuth);
30
31async function resolveRepo(ownerName: string, repoName: string) {
32 try {
33 const [owner] = await db
34 .select()
35 .from(users)
36 .where(eq(users.username, ownerName))
37 .limit(1);
38 if (!owner) return null;
39 const [repo] = await db
40 .select()
41 .from(repositories)
42 .where(
43 and(
44 eq(repositories.ownerId, owner.id),
45 eq(repositories.name, repoName)
46 )
47 )
48 .limit(1);
49 if (!repo) return null;
50 return { owner, repo };
51 } catch {
52 return null;
53 }
54}
55
56prLeadTimeRoutes.get(
57 "/:owner/:repo/insights/lead-time",
58 async (c) => {
59 const { owner: ownerName, repo: repoName } = c.req.param();
60 const user = c.get("user");
61 const windowDays = parseWindow(c.req.query("window"));
62
63 const resolved = await resolveRepo(ownerName, repoName);
64 if (!resolved) {
65 return c.html(
66 <Layout title="Not Found" user={user}>
67 <div class="empty-state">
68 <h2>Repository not found</h2>
69 </div>
70 </Layout>,
71 404
72 );
73 }
74
75 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
76 return c.html(
77 <Layout title="Not Found" user={user}>
78 <div class="empty-state">
79 <h2>Repository not found</h2>
80 </div>
81 </Layout>,
82 404
83 );
84 }
85
86 let prRows: {
87 id: string;
88 number: number;
89 title: string;
90 state: string;
91 isDraft: boolean;
92 createdAt: Date;
93 mergedAt: Date | null;
94 }[] = [];
95 try {
96 prRows = await db
97 .select({
98 id: pullRequests.id,
99 number: pullRequests.number,
100 title: pullRequests.title,
101 state: pullRequests.state,
102 isDraft: pullRequests.isDraft,
103 createdAt: pullRequests.createdAt,
104 mergedAt: pullRequests.mergedAt,
105 })
106 .from(pullRequests)
107 .where(eq(pullRequests.repositoryId, resolved.repo.id))
108 .limit(2000);
109 } catch {
110 // empty → empty report
111 }
112
113 const inputs: PrLeadTimeInput[] = prRows.map((r) => ({
114 id: r.id,
115 number: r.number,
116 title: r.title,
117 state: r.state,
118 isDraft: r.isDraft,
119 createdAt: r.createdAt,
120 mergedAt: r.mergedAt,
121 }));
122 const report = buildLeadTimeReport({ prs: inputs, windowDays });
123
124 const oldestOpen = report.oldestOpenIds
125 .map((id) => report.perPr.find((p) => p.id === id))
126 .filter((p): p is NonNullable<typeof p> => !!p)
127 .slice(0, 25);
128
129 const windowLabel =
130 windowDays === 0 ? "All time" : `Last ${windowDays} days`;
131
132 const kpi = (label: string, value: string) => (
133 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
134 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
135 {label}
136 </div>
137 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
138 {value}
139 </div>
140 </div>
141 );
142
143 return c.html(
144 <Layout title={`Lead time — ${ownerName}/${repoName}`} user={user}>
145 <RepoHeader owner={ownerName} repo={repoName} />
146 <div style="max-width: 920px">
147 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
148 <h2 style="margin: 0">PR lead time</h2>
149 <form
150 method="GET"
151 action={`/${ownerName}/${repoName}/insights/lead-time`}
152 style="display: flex; gap: 6px; align-items: center"
153 >
154 <label for="window" style="font-size: 12px; color: var(--text-muted)">
155 Window:
156 </label>
157 <select
158 id="window"
159 name="window"
160 onchange="this.form.submit()"
161 style="padding: 4px 8px; font-size: 12px"
162 >
163 {VALID_WINDOWS.map((w) => (
164 <option value={String(w)} selected={w === windowDays}>
165 {w === 0 ? "All time" : `Last ${w} days`}
166 </option>
167 ))}
168 </select>
169 </form>
170 </div>
171 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
172 <strong>{windowLabel}</strong>. Lead time = time from PR opened to
173 merged. Open PRs show as "in-flight" and roll into a separate
174 counter so the percentiles aren't skewed by stale drafts.
175 </p>
176
177 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px">
178 {kpi("Total PRs", String(report.summary.total))}
179 {kpi("Merged", String(report.summary.merged))}
180 {kpi("Open (non-draft)", String(report.summary.openNonDraft))}
181 {kpi("Drafts", String(report.summary.openDraft))}
182 {kpi("Median (p50)", formatDuration(report.summary.medianMs))}
183 {kpi("Mean", formatDuration(report.summary.meanMs))}
184 {kpi("p90", formatDuration(report.summary.p90Ms))}
185 {kpi("Fastest", formatDuration(report.summary.fastestMs))}
186 {kpi("Slowest", formatDuration(report.summary.slowestMs))}
187 </div>
188
189 <h3 style="margin-bottom: 10px">Merge-time distribution</h3>
190 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 24px">
191 {[
192 ["≤ 1 hour", report.buckets.within1h],
193 ["1h – 1 day", report.buckets.within1d],
194 ["1d – 1 week", report.buckets.within1w],
195 ["> 1 week", report.buckets.over1w],
196 ].map(([label, count]) => (
197 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center">
198 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
199 {label}
200 </div>
201 <div style="font-size: 18px; font-weight: 600">{count}</div>
202 </div>
203 ))}
204 </div>
205
206 <h3 style="margin-bottom: 10px">
207 Oldest open PRs ({oldestOpen.length}
208 {report.oldestOpenIds.length > oldestOpen.length
209 ? ` of ${report.oldestOpenIds.length}`
210 : ""}
211 )
212 </h3>
213 {oldestOpen.length === 0 ? (
214 <div class="empty-state">
215 <p>No open PRs. Nice.</p>
216 </div>
217 ) : (
218 <table style="width: 100%; border-collapse: collapse">
219 <thead>
220 <tr>
221 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
222 PR
223 </th>
224 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 140px">
225 In flight
226 </th>
227 </tr>
228 </thead>
229 <tbody>
230 {oldestOpen.map((p) => (
231 <tr>
232 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
233 <a href={`/${ownerName}/${repoName}/pulls/${p.number}`}>
234 <span style="color: var(--text-muted)">#{p.number}</span>{" "}
235 {p.title}
236 </a>
237 </td>
238 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
239 {formatDuration(p.inFlightMs)}
240 </td>
241 </tr>
242 ))}
243 </tbody>
244 </table>
245 )}
246 </div>
247 </Layout>
248 );
249 }
250);
251
252export default prLeadTimeRoutes;