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

feat(issues+pr): issue search/label filter + PR insights analytics

feat(issues+pr): issue search/label filter + PR insights analytics

- Issues: add ?q= title search and ?label= filter with active filter banner
- PR list: add /insights route with avg merge time, weekly throughput,
  merge rate, top reviewers, review turnaround, open-by-age histogram,
  and daily sparkline chart
- PR view: pass applySuggestionUrl to DiffView so suggestion Apply button wires up

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: 5882af3
2 files changed+72687a28902dc03a31aedf2178f6aa00457d4a453da7
2 changed files+726−8
Modifiedsrc/routes/issues.tsx+119−4View fileUnifiedSplit
33 */
44
55import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
6import { eq, and, desc, asc, sql, ilike, inArray } from "drizzle-orm";
77import { db } from "../db";
88import {
99 issues,
215215 color: var(--text);
216216 }
217217
218 /* Issue search form */
219 .issues-search-form {
220 display: flex;
221 align-items: center;
222 gap: 0;
223 background: var(--bg-elevated);
224 border: 1px solid var(--border);
225 border-radius: 9999px;
226 overflow: hidden;
227 flex: 1;
228 max-width: 280px;
229 transition: border-color 120ms ease;
230 }
231 .issues-search-form:focus-within { border-color: rgba(140,109,255,0.55); }
232 .issues-search-input {
233 flex: 1;
234 background: transparent;
235 color: var(--text);
236 border: none;
237 outline: none;
238 padding: 6px 14px;
239 font-size: 13px;
240 font-family: var(--font-sans, inherit);
241 min-width: 0;
242 }
243 .issues-search-input::placeholder { color: var(--text-muted); }
244 .issues-search-btn {
245 background: transparent;
246 border: none;
247 color: var(--text-muted);
248 padding: 6px 12px 6px 8px;
249 cursor: pointer;
250 display: flex;
251 align-items: center;
252 }
253 .issues-search-btn:hover { color: var(--text); }
254 .issues-filter-banner {
255 margin-bottom: 12px;
256 padding: 8px 14px;
257 background: rgba(140,109,255,0.06);
258 border: 1px solid rgba(140,109,255,0.2);
259 border-radius: 8px;
260 font-size: 13px;
261 color: var(--text-muted);
262 }
263 .issues-filter-clear {
264 color: var(--accent, #8c6dff);
265 text-decoration: underline;
266 cursor: pointer;
267 }
268 .issues-label-badge {
269 display: inline-block;
270 background: rgba(140,109,255,0.15);
271 color: #a78bfa;
272 border-radius: 9999px;
273 padding: 1px 8px;
274 font-size: 11.5px;
275 font-weight: 600;
276 }
277
218278 /* Issue list — modernised rows */
219279 .issues-list {
220280 list-style: none;
628688 const { owner: ownerName, repo: repoName } = c.req.param();
629689 const user = c.get("user");
630690 const state = c.req.query("state") || "open";
691 const searchQ = (c.req.query("q") || "").trim();
692 const labelFilter = (c.req.query("label") || "").trim();
631693 // Bounded pagination — unbounded selects ran a full table scan + O(n)
632694 // sort on every page load; with 10k+ issues the request would hang.
633695 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
683745
684746 const { repo } = resolved;
685747
748 // If label filter is set, find issue IDs that have that label
749 let labelFilteredIds: string[] | null = null;
750 if (labelFilter) {
751 const [matchedLabel] = await db
752 .select({ id: labels.id })
753 .from(labels)
754 .where(and(eq(labels.repositoryId, repo.id), ilike(labels.name, labelFilter)))
755 .limit(1);
756 if (matchedLabel) {
757 const rows = await db
758 .select({ issueId: issueLabels.issueId })
759 .from(issueLabels)
760 .where(eq(issueLabels.labelId, matchedLabel.id));
761 labelFilteredIds = rows.map((r) => r.issueId);
762 } else {
763 labelFilteredIds = []; // no matches
764 }
765 }
766
767 const baseWhere = and(
768 eq(issues.repositoryId, repo.id),
769 state === "open" || state === "closed" ? eq(issues.state, state) : undefined,
770 searchQ ? ilike(issues.title, `%${searchQ}%`) : undefined,
771 labelFilteredIds !== null && labelFilteredIds.length > 0
772 ? inArray(issues.id, labelFilteredIds)
773 : labelFilteredIds !== null && labelFilteredIds.length === 0
774 ? sql`false`
775 : undefined
776 );
777
686778 const issueList = await db
687779 .select({
688780 issue: issues,
690782 })
691783 .from(issues)
692784 .innerJoin(users, eq(issues.authorId, users.id))
693 .where(
694 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
695 )
785 .where(baseWhere)
696786 .orderBy(desc(issues.createdAt))
697787 .limit(perPage)
698788 .offset(offset);
781871 <span class="issues-filter-count">{Number(counts?.closed ?? 0)}</span>
782872 </a>
783873 </div>
874 <form method="get" action={`/${ownerName}/${repoName}/issues`} class="issues-search-form">
875 <input type="hidden" name="state" value={state} />
876 <input
877 type="search"
878 name="q"
879 value={searchQ}
880 placeholder="Search issues\u2026"
881 class="issues-search-input"
882 aria-label="Search issues by title"
883 />
884 {labelFilter && <input type="hidden" name="label" value={labelFilter} />}
885 <button type="submit" class="issues-search-btn" aria-label="Search">
886 <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
887 <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z" />
888 </svg>
889 </button>
890 </form>
784891 </div>
892 {(searchQ || labelFilter) && (
893 <div class="issues-filter-banner">
894 Filtering{searchQ ? <> by "<strong>{searchQ}</strong>"</> : null}
895 {labelFilter ? <> label: <span class="issues-label-badge">{labelFilter}</span></> : null}
896 {" \u00B7 "}
897 <a href={`/${ownerName}/${repoName}/issues?state=${state}`} class="issues-filter-clear">Clear filters</a>
898 </div>
899 )}
785900
786901 {issueList.length === 0 ? (
787902 <div class="issues-empty">
Modifiedsrc/routes/pulls.tsx+607−4View fileUnifiedSplit
18041804 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
18051805 </p>
18061806 </div>
1807 {user && (
1808 <div class="prs-hero-actions">
1807 <div class="prs-hero-actions">
1808 <a
1809 href={`/${ownerName}/${repoName}/pulls/insights`}
1810 class="prs-cta"
1811 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
1812 >
1813 Insights
1814 </a>
1815 {user && (
18091816 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
18101817 + New pull request
18111818 </a>
1812 </div>
1813 )}
1819 )}
1820 </div>
18141821 </div>
18151822 </div>
18161823
19301937 );
19311938});
19321939
1940/* ─────────────────────────────────────────────────────────────────────────
1941 * PR Insights — 90-day analytics for the pull request activity of a repo.
1942 * Route: GET /:owner/:repo/pulls/insights
1943 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
1944 * "insights" is not swallowed by the :number param.
1945 * ───────────────────────────────────────────────────────────────────────── */
1946
1947/** Format a millisecond duration as human-readable string. */
1948function formatMsDuration(ms: number): string {
1949 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
1950 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
1951 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
1952 return `${Math.round(ms / 86_400_000)}d`;
1953}
1954
1955/** Format an ISO week string as "Jan 15". */
1956function formatWeekLabel(isoWeek: string): string {
1957 try {
1958 const d = new Date(isoWeek);
1959 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1960 } catch {
1961 return isoWeek.slice(5, 10);
1962 }
1963}
1964
1965const PR_INSIGHTS_STYLES = `
1966 .pri-page { padding-bottom: 48px; }
1967 .pri-hero {
1968 position: relative;
1969 margin: 0 0 var(--space-5);
1970 padding: 22px 26px 24px;
1971 background: var(--bg-elevated);
1972 border: 1px solid var(--border);
1973 border-radius: 16px;
1974 overflow: hidden;
1975 }
1976 .pri-hero::before {
1977 content: '';
1978 position: absolute; top: 0; left: 0; right: 0;
1979 height: 2px;
1980 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1981 opacity: 0.7;
1982 pointer-events: none;
1983 }
1984 .pri-hero-eyebrow {
1985 font-size: 12px;
1986 color: var(--text-muted);
1987 text-transform: uppercase;
1988 letter-spacing: 0.08em;
1989 font-weight: 600;
1990 margin-bottom: 8px;
1991 }
1992 .pri-hero-title {
1993 font-family: var(--font-display);
1994 font-size: clamp(26px, 3.4vw, 34px);
1995 font-weight: 800;
1996 letter-spacing: -0.025em;
1997 line-height: 1.06;
1998 margin: 0 0 8px;
1999 color: var(--text-strong);
2000 }
2001 .pri-hero-title .gradient-text {
2002 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2003 -webkit-background-clip: text;
2004 background-clip: text;
2005 -webkit-text-fill-color: transparent;
2006 color: transparent;
2007 }
2008 .pri-hero-sub {
2009 font-size: 14.5px;
2010 color: var(--text-muted);
2011 margin: 0;
2012 line-height: 1.5;
2013 }
2014 .pri-section { margin-bottom: 32px; }
2015 .pri-section-title {
2016 font-size: 13px;
2017 font-weight: 700;
2018 text-transform: uppercase;
2019 letter-spacing: 0.06em;
2020 color: var(--text-muted);
2021 margin: 0 0 14px;
2022 }
2023 .pri-cards {
2024 display: grid;
2025 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2026 gap: 12px;
2027 }
2028 .pri-card {
2029 padding: 16px 18px;
2030 background: var(--bg-elevated);
2031 border: 1px solid var(--border);
2032 border-radius: 12px;
2033 }
2034 .pri-card-label {
2035 font-size: 12px;
2036 font-weight: 600;
2037 color: var(--text-muted);
2038 text-transform: uppercase;
2039 letter-spacing: 0.05em;
2040 margin-bottom: 6px;
2041 }
2042 .pri-card-value {
2043 font-size: 28px;
2044 font-weight: 800;
2045 letter-spacing: -0.04em;
2046 color: var(--text-strong);
2047 line-height: 1;
2048 }
2049 .pri-card-sub {
2050 font-size: 12px;
2051 color: var(--text-muted);
2052 margin-top: 4px;
2053 }
2054 .pri-chart {
2055 display: flex;
2056 align-items: flex-end;
2057 gap: 6px;
2058 height: 120px;
2059 background: var(--bg-elevated);
2060 border: 1px solid var(--border);
2061 border-radius: 12px;
2062 padding: 16px 16px 0;
2063 }
2064 .pri-bar-col {
2065 flex: 1;
2066 display: flex;
2067 flex-direction: column;
2068 align-items: center;
2069 justify-content: flex-end;
2070 height: 100%;
2071 gap: 4px;
2072 }
2073 .pri-bar {
2074 width: 100%;
2075 min-height: 4px;
2076 border-radius: 4px 4px 0 0;
2077 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2078 transition: opacity 140ms;
2079 }
2080 .pri-bar:hover { opacity: 0.8; }
2081 .pri-bar-label {
2082 font-size: 10px;
2083 color: var(--text-muted);
2084 text-align: center;
2085 padding-bottom: 8px;
2086 white-space: nowrap;
2087 overflow: hidden;
2088 text-overflow: ellipsis;
2089 max-width: 100%;
2090 }
2091 .pri-table {
2092 width: 100%;
2093 border-collapse: collapse;
2094 font-size: 13.5px;
2095 }
2096 .pri-table th {
2097 text-align: left;
2098 font-size: 12px;
2099 font-weight: 600;
2100 text-transform: uppercase;
2101 letter-spacing: 0.05em;
2102 color: var(--text-muted);
2103 padding: 8px 12px;
2104 border-bottom: 1px solid var(--border);
2105 }
2106 .pri-table td {
2107 padding: 10px 12px;
2108 border-bottom: 1px solid var(--border);
2109 color: var(--text);
2110 }
2111 .pri-table tr:last-child td { border-bottom: none; }
2112 .pri-table-wrap {
2113 background: var(--bg-elevated);
2114 border: 1px solid var(--border);
2115 border-radius: 12px;
2116 overflow: hidden;
2117 }
2118 .pri-age-row {
2119 display: flex;
2120 align-items: center;
2121 gap: 12px;
2122 padding: 10px 0;
2123 border-bottom: 1px solid var(--border);
2124 font-size: 13.5px;
2125 }
2126 .pri-age-row:last-child { border-bottom: none; }
2127 .pri-age-label {
2128 flex: 0 0 80px;
2129 color: var(--text-muted);
2130 font-size: 12.5px;
2131 font-weight: 600;
2132 }
2133 .pri-age-bar-wrap {
2134 flex: 1;
2135 height: 8px;
2136 background: var(--bg-secondary);
2137 border-radius: 9999px;
2138 overflow: hidden;
2139 }
2140 .pri-age-bar {
2141 height: 100%;
2142 border-radius: 9999px;
2143 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2144 min-width: 4px;
2145 }
2146 .pri-age-count {
2147 flex: 0 0 32px;
2148 text-align: right;
2149 font-weight: 600;
2150 color: var(--text-strong);
2151 font-size: 13px;
2152 }
2153 .pri-sparkline {
2154 display: flex;
2155 align-items: flex-end;
2156 gap: 3px;
2157 height: 40px;
2158 }
2159 .pri-spark-bar {
2160 flex: 1;
2161 min-height: 2px;
2162 border-radius: 2px 2px 0 0;
2163 background: var(--accent, #8c6dff);
2164 opacity: 0.7;
2165 }
2166 .pri-empty {
2167 color: var(--text-muted);
2168 font-size: 14px;
2169 padding: 24px 0;
2170 text-align: center;
2171 }
2172 @media (max-width: 600px) {
2173 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2174 .pri-hero { padding: 18px 18px 20px; }
2175 }
2176`;
2177
2178pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2179 const { owner: ownerName, repo: repoName } = c.req.param();
2180 const user = c.get("user");
2181
2182 const resolved = await resolveRepo(ownerName, repoName);
2183 if (!resolved) return c.notFound();
2184
2185 const repoId = resolved.repo.id;
2186 const now = Date.now();
2187
2188 // 1. Merged PRs in last 90 days (avg merge time)
2189 const mergedPRs = await db
2190 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2191 .from(pullRequests)
2192 .where(and(
2193 eq(pullRequests.repositoryId, repoId),
2194 eq(pullRequests.state, "merged"),
2195 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2196 ));
2197
2198 const avgMergeMs = mergedPRs.length > 0
2199 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2200 : null;
2201
2202 // 2. PR throughput (last 8 weeks)
2203 const weeklyPRs = await db
2204 .select({
2205 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2206 count: sql<number>`count(*)::int`,
2207 })
2208 .from(pullRequests)
2209 .where(and(
2210 eq(pullRequests.repositoryId, repoId),
2211 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2212 ))
2213 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2214 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2215
2216 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2217
2218 // 3. PR merge rate (last 90 days)
2219 const [rateCounts] = await db
2220 .select({
2221 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2222 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2223 })
2224 .from(pullRequests)
2225 .where(and(
2226 eq(pullRequests.repositoryId, repoId),
2227 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2228 ));
2229
2230 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2231 const mergeRate = totalResolved > 0
2232 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2233 : null;
2234
2235 // 4. Top reviewers (last 90 days)
2236 const reviewerCounts = await db
2237 .select({
2238 userId: prReviews.reviewerId,
2239 username: users.username,
2240 count: sql<number>`count(*)::int`,
2241 })
2242 .from(prReviews)
2243 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2244 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2245 .where(and(
2246 eq(pullRequests.repositoryId, repoId),
2247 sql`${prReviews.createdAt} > now() - interval '90 days'`
2248 ))
2249 .groupBy(prReviews.reviewerId, users.username)
2250 .orderBy(desc(sql`count(*)`))
2251 .limit(5);
2252
2253 // 5. Average reviews per merged PR
2254 const [avgReviewRow] = await db
2255 .select({
2256 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2257 })
2258 .from(pullRequests)
2259 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2260 .where(and(
2261 eq(pullRequests.repositoryId, repoId),
2262 eq(pullRequests.state, "merged"),
2263 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2264 ));
2265
2266 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2267 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2268 : null;
2269
2270 // 6. Review turnaround — avg time from PR open to first review
2271 const prsWithReviews = await db
2272 .select({
2273 createdAt: pullRequests.createdAt,
2274 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2275 })
2276 .from(pullRequests)
2277 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2278 .where(and(
2279 eq(pullRequests.repositoryId, repoId),
2280 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2281 ))
2282 .groupBy(pullRequests.id, pullRequests.createdAt);
2283
2284 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2285 ? prsWithReviews.reduce((s, row) => {
2286 const firstMs = new Date(row.firstReview).getTime();
2287 return s + Math.max(0, firstMs - row.createdAt.getTime());
2288 }, 0) / prsWithReviews.length
2289 : null;
2290
2291 // 7. Open PRs by age bucket
2292 const openPRs = await db
2293 .select({ createdAt: pullRequests.createdAt })
2294 .from(pullRequests)
2295 .where(and(
2296 eq(pullRequests.repositoryId, repoId),
2297 eq(pullRequests.state, "open")
2298 ));
2299
2300 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2301 for (const { createdAt } of openPRs) {
2302 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2303 if (ageDays < 1) ageBuckets.lt1d++;
2304 else if (ageDays < 3) ageBuckets.d1to3++;
2305 else if (ageDays < 7) ageBuckets.d3to7++;
2306 else if (ageDays < 30) ageBuckets.d7to30++;
2307 else ageBuckets.gt30d++;
2308 }
2309 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2310
2311 // 8. 7-day merge sparkline
2312 const sparklineRows = await db
2313 .select({
2314 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2315 count: sql<number>`count(*)::int`,
2316 })
2317 .from(pullRequests)
2318 .where(and(
2319 eq(pullRequests.repositoryId, repoId),
2320 eq(pullRequests.state, "merged"),
2321 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2322 ))
2323 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2324 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2325
2326 const sparkMap = new Map<string, number>();
2327 for (const row of sparklineRows) {
2328 sparkMap.set(row.day.slice(0, 10), row.count);
2329 }
2330 const sparkline: number[] = [];
2331 for (let i = 6; i >= 0; i--) {
2332 const d = new Date(now - i * 86_400_000);
2333 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2334 }
2335 const maxSpark = Math.max(1, ...sparkline);
2336
2337 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2338 { label: "< 1 day", key: "lt1d" },
2339 { label: "1–3 days", key: "d1to3" },
2340 { label: "3–7 days", key: "d3to7" },
2341 { label: "7–30 days", key: "d7to30" },
2342 { label: "> 30 days", key: "gt30d" },
2343 ];
2344
2345 return c.html(
2346 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2347 <RepoHeader owner={ownerName} repo={repoName} />
2348 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2349 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2350
2351 <div class="pri-page">
2352 {/* Hero */}
2353 <div class="pri-hero">
2354 <div class="pri-hero-eyebrow">Pull requests</div>
2355 <h1 class="pri-hero-title">
2356 PR <span class="gradient-text">Insights</span>
2357 </h1>
2358 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2359 </div>
2360
2361 {/* Stat cards */}
2362 <div class="pri-section">
2363 <div class="pri-section-title">At a glance</div>
2364 <div class="pri-cards">
2365 <div class="pri-card">
2366 <div class="pri-card-label">Avg merge time</div>
2367 <div class="pri-card-value">
2368 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2369 </div>
2370 <div class="pri-card-sub">last 90 days</div>
2371 </div>
2372 <div class="pri-card">
2373 <div class="pri-card-label">Total merged</div>
2374 <div class="pri-card-value">{mergedPRs.length}</div>
2375 <div class="pri-card-sub">last 90 days</div>
2376 </div>
2377 <div class="pri-card">
2378 <div class="pri-card-label">Open PRs</div>
2379 <div class="pri-card-value">{openPRs.length}</div>
2380 <div class="pri-card-sub">right now</div>
2381 </div>
2382 <div class="pri-card">
2383 <div class="pri-card-label">Merge rate</div>
2384 <div class="pri-card-value">
2385 {mergeRate != null ? `${mergeRate}%` : "—"}
2386 </div>
2387 <div class="pri-card-sub">merged vs closed</div>
2388 </div>
2389 <div class="pri-card">
2390 <div class="pri-card-label">Avg reviews / PR</div>
2391 <div class="pri-card-value">
2392 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2393 </div>
2394 <div class="pri-card-sub">merged PRs, 90d</div>
2395 </div>
2396 <div class="pri-card">
2397 <div class="pri-card-label">Top reviewer</div>
2398 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2399 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2400 </div>
2401 <div class="pri-card-sub">
2402 {reviewerCounts.length > 0
2403 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2404 : "no reviews yet"}
2405 </div>
2406 </div>
2407 </div>
2408 </div>
2409
2410 {/* Review turnaround */}
2411 <div class="pri-section">
2412 <div class="pri-section-title">Review turnaround</div>
2413 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2414 <div class="pri-card">
2415 <div class="pri-card-label">Avg time to first review</div>
2416 <div class="pri-card-value">
2417 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2418 </div>
2419 <div class="pri-card-sub">
2420 {prsWithReviews.length > 0
2421 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2422 : "no reviewed PRs in 90d"}
2423 </div>
2424 </div>
2425 </div>
2426 </div>
2427
2428 {/* Weekly throughput bar chart */}
2429 <div class="pri-section">
2430 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2431 {weeklyPRs.length === 0 ? (
2432 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2433 ) : (
2434 <div class="pri-chart">
2435 {weeklyPRs.map((w) => (
2436 <div class="pri-bar-col">
2437 <div
2438 class="pri-bar"
2439 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2440 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2441 />
2442 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2443 </div>
2444 ))}
2445 </div>
2446 )}
2447 </div>
2448
2449 {/* 7-day merge sparkline */}
2450 <div class="pri-section">
2451 <div class="pri-section-title">Merges this week (daily)</div>
2452 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2453 <div class="pri-sparkline">
2454 {sparkline.map((v) => (
2455 <div
2456 class="pri-spark-bar"
2457 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2458 title={`${v} merge${v === 1 ? "" : "s"}`}
2459 />
2460 ))}
2461 </div>
2462 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2463 <span>7 days ago</span>
2464 <span>Today</span>
2465 </div>
2466 </div>
2467 </div>
2468
2469 {/* Top reviewers table */}
2470 <div class="pri-section">
2471 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2472 {reviewerCounts.length === 0 ? (
2473 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2474 ) : (
2475 <div class="pri-table-wrap">
2476 <table class="pri-table">
2477 <thead>
2478 <tr>
2479 <th>#</th>
2480 <th>Reviewer</th>
2481 <th>Reviews</th>
2482 </tr>
2483 </thead>
2484 <tbody>
2485 {reviewerCounts.map((r, i) => (
2486 <tr>
2487 <td style="color:var(--text-muted)">{i + 1}</td>
2488 <td>
2489 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2490 {r.username}
2491 </a>
2492 </td>
2493 <td style="font-weight:600">{r.count}</td>
2494 </tr>
2495 ))}
2496 </tbody>
2497 </table>
2498 </div>
2499 )}
2500 </div>
2501
2502 {/* Open PRs by age */}
2503 <div class="pri-section">
2504 <div class="pri-section-title">Open PRs by age</div>
2505 {openPRs.length === 0 ? (
2506 <div class="pri-empty">No open pull requests.</div>
2507 ) : (
2508 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2509 {ageBucketDefs.map(({ label, key }) => (
2510 <div class="pri-age-row">
2511 <span class="pri-age-label">{label}</span>
2512 <div class="pri-age-bar-wrap">
2513 <div
2514 class="pri-age-bar"
2515 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2516 />
2517 </div>
2518 <span class="pri-age-count">{ageBuckets[key]}</span>
2519 </div>
2520 ))}
2521 </div>
2522 )}
2523 </div>
2524
2525 {/* Back link */}
2526 <div>
2527 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2528 {"←"} Back to pull requests
2529 </a>
2530 </div>
2531 </div>
2532 </Layout>
2533 );
2534});
2535
19332536// New PR form
19342537pulls.get(
19352538 "/:owner/:repo/pulls/new",
19362539