Commit6a645f0
fix(insights): build the missing Insights hub + stop test-gaps 502ing
fix(insights): build the missing Insights hub + stop test-gaps 502ing Found by PROPERLY pressing every repo-nav tab on the real logged-in session (not a curl status sweep, which had mis-triaged the 404 as "expected"). Three real bugs the prior "audits" missed: 1. The "Insights" nav tab links to /:owner/:repo/insights, but NOTHING served that path — it 404'd on every repo. The seven insight sub-reports (DORA, velocity, health, hot-files, bus-factor, test-gaps, engineering) were orphaned with no entry point, even though each sub-page's own subnav links back to /insights as "Overview". Built a real hub there: grouped cards (Delivery / Quality & risk / Code intelligence / People) linking every report. 2. /insights/test-gaps returned 502 (Bad Gateway) — not a crash, a ~27s synchronous AI walk on page load that blew past the proxy timeout on a cold cache (the cache is in-memory, so every deploy resets it). The route now renders immediately from cache-only (getCachedTestGaps), kicks the analysis off in the background (ensureTestGapsAnalysis, dedup'd), and shows an auto-refreshing "analysing…" state on a miss. Never blocks, never 502s. 3. The new greedy hub route (/:owner/:repo/insights matches /orgs/:slug/insights with owner="orgs") shadowed org-insights and 404'd it. Fixed by mounting orgInsightsRoutes before insightRoutes — same route-shadowing class as the earlier admin fix. Tests: insights-hub.test.ts locks both the route-registered and no-shadow invariants; org-insights smoke passes again. Full suite 3110 pass, same 4 pre-existing unrelated fails.
5 files changed+204−96a645f0ea4a3ce6e3890a9e23464c749e9864964
5 changed files+204−9
Addedsrc/__tests__/insights-hub.test.ts+27−0View fileUnifiedSplit
@@ -0,0 +1,27 @@
1/**
2 * Regression coverage for the Insights tab.
3 *
4 * The repo-nav "Insights" tab links to /:owner/:repo/insights, but NOTHING
5 * served that path — it 404'd on every repo (found by pressing every nav tab
6 * on the real logged-in session, not by a curl status sweep, which had
7 * mis-triaged the 404 as "expected"). A hub page now serves it.
8 *
9 * The hub route is greedy (`/:owner/:repo/insights` matches `/orgs/:slug/insights`
10 * with owner="orgs"), so it MUST be mounted after org-insights or it shadows
11 * it. These tests lock both invariants.
12 */
13import { describe, it, expect } from "bun:test";
14import app from "../app";
15
16describe("insights hub", () => {
17 it("route is registered + gated: unknown repo → 404 (not a fall-through)", async () => {
18 const res = await app.request("/nobody-xyz/nothing-xyz/insights");
19 expect(res.status).toBe(404);
20 });
21
22 it("does NOT shadow /orgs/:slug/insights — that still routes to org-insights (302 login)", async () => {
23 const res = await app.request("/orgs/some-slug/insights");
24 expect(res.status).toBe(302);
25 expect(res.headers.get("location") || "").toContain("/login");
26 });
27});
Modifiedsrc/app.tsx+9−2View fileUnifiedSplit
@@ -726,7 +726,14 @@ app.route("/", adminDiagnoseRoutes);
726726// Unified admin command center — /admin/command
727727app.route("/", adminCommandRoutes);
728728
729// Insights (time-travel, dependencies, rollback)
729// Org insights — MUST be mounted BEFORE insightRoutes. insightRoutes' repo
730// insights hub (GET /:owner/:repo/insights) would otherwise match
731// /orgs/:slug/insights (owner="orgs") and 404 it before the org router runs.
732// Same route-shadowing class as the adminOps/insights note above. org-insights
733// only matches the literal "/orgs/..." prefix, so mounting it first is safe.
734app.route("/", orgInsightsRoutes);
735
736// Insights (repo insights hub, time-travel, dependencies, rollback)
730737app.route("/", insightRoutes);
731738
732739// Engineering Intelligence Dashboard — /insights + /:owner/:repo/insights/engineering
@@ -824,7 +831,7 @@ app.route("/", marketplaceRoutes);
824831app.route("/", marketplaceAgentsRoutes);
825832app.route("/", mergeQueueRoutes);
826833app.route("/", mirrorsRoutes);
827app.route("/", orgInsightsRoutes);
834// orgInsightsRoutes is mounted earlier (before insightRoutes) — see the note there.
828835app.route("/", orgHealthRoutes);
829836app.route("/", packagesRoutes);
830837app.route("/", packagesApiRoutes);
Modifiedsrc/lib/test-gaps.ts+42−0View fileUnifiedSplit
@@ -377,3 +377,45 @@ export async function getTestGaps(
377377export function clearTestGapsCache(repoId: string): void {
378378 cache.delete(repoId);
379379}
380
381/**
382 * Cache-only lookup — NO compute. Returns null on miss so the route can
383 * render the page immediately instead of blocking on detectTestGaps, which
384 * walks the whole repo + calls the model and can take ~30s — long enough to
385 * blow past the front proxy's timeout and 502 the page on a cold cache
386 * (which happens after every deploy, since the cache is in-memory).
387 */
388export function getCachedTestGaps(repoId: string): TestGapReport | null {
389 const cached = cache.get(repoId);
390 if (cached && cached.expiresAt > Date.now()) return cached.report;
391 return null;
392}
393
394/** In-flight analyses, so a burst of page loads can't spawn N concurrent
395 * detectTestGaps runs for the same repo. */
396const inFlightTestGaps = new Set<string>();
397
398/**
399 * Fire-and-forget: ensure a background analysis is (or gets) running to
400 * populate the cache. No-op if already cached or already in flight. Never
401 * throws. The page renders an "analysing…" state and the next load (or a
402 * short auto-refresh) picks up the cached result.
403 */
404export function ensureTestGapsAnalysis(
405 ownerName: string,
406 repoName: string,
407 repoId: string
408): void {
409 if (getCachedTestGaps(repoId) || inFlightTestGaps.has(repoId)) return;
410 inFlightTestGaps.add(repoId);
411 void detectTestGaps(ownerName, repoName, repoId)
412 .then((report) => {
413 cache.set(repoId, { report, expiresAt: Date.now() + CACHE_TTL_MS });
414 })
415 .catch(() => {
416 /* swallow — the page shows the analysing state; a refresh retries */
417 })
418 .finally(() => {
419 inFlightTestGaps.delete(repoId);
420 });
421}
Modifiedsrc/routes/insights.tsx+101−0View fileUnifiedSplit
@@ -496,6 +496,107 @@ insights.get("/:owner/:repo/timeline/:ref{.+$}", async (c) => {
496496});
497497
498498// Coupled files analysis (the canonical "Insights" landing surface)
499// ─── INSIGHTS HUB (the tab landing page) ─────────────────────────────
500// The repo-nav "Insights" tab links here. Previously NOTHING served
501// /:owner/:repo/insights, so the tab 404'd on every repo — and the seven
502// insight sub-reports (DORA, velocity, health, hot files, bus factor, test
503// gaps, engineering) were orphaned with no entry point. This hub is the
504// "Overview" that every sub-page's subnav already links back to.
505insights.get("/:owner/:repo/insights", async (c) => {
506 const { owner, repo } = c.req.param();
507 const user = c.get("user");
508 if (!(await repoExists(owner, repo))) return c.notFound();
509
510 const base = `/${owner}/${repo}`;
511 const groups: Array<{
512 heading: string;
513 cards: Array<{ title: string; desc: string; href: string; icon: string }>;
514 }> = [
515 {
516 heading: "Delivery",
517 cards: [
518 { title: "DORA metrics", icon: "🚀", href: `${base}/insights/dora`, desc: "Deploy frequency, lead time, change-fail rate, and time-to-restore." },
519 { title: "Velocity", icon: "📈", href: `${base}/insights/velocity`, desc: "Throughput, cycle time, and PR flow over time." },
520 { title: "Engineering metrics", icon: "🧭", href: `${base}/insights/engineering`, desc: "Team-level engineering signals and trends." },
521 ],
522 },
523 {
524 heading: "Quality & risk",
525 cards: [
526 { title: "Reliability & health", icon: "❤️", href: `${base}/insights/health`, desc: "Build health, stability trends, and reliability signals." },
527 { title: "Test gaps", icon: "🧪", href: `${base}/insights/test-gaps`, desc: "Untested code ranked by risk — where to write tests first." },
528 { title: "Bus factor", icon: "🚌", href: `${base}/insights/bus-factor`, desc: "Knowledge concentration and single-maintainer risk." },
529 ],
530 },
531 {
532 heading: "Code intelligence",
533 cards: [
534 { title: "Hot files", icon: "🔥", href: `${base}/insights/hotfiles`, desc: "Highest-churn files — where change concentrates." },
535 { title: "File coupling & history", icon: "🕸️", href: `${base}/coupling`, desc: "Files that change together, plus milestone time-travel." },
536 { title: "Dependencies", icon: "📦", href: `${base}/dependencies`, desc: "Import graph and upgrade-impact analysis." },
537 ],
538 },
539 {
540 heading: "People & activity",
541 cards: [
542 { title: "Contributors", icon: "👥", href: `${base}/contributors`, desc: "Who's contributing, and how much." },
543 { title: "Pulse", icon: "💓", href: `${base}/pulse`, desc: "Recent activity at a glance." },
544 ],
545 },
546 ];
547
548 return c.html(
549 <Layout title={`Insights — ${owner}/${repo}`} user={user}>
550 <RepoHeader owner={owner} repo={repo} />
551 <RepoNav owner={owner} repo={repo} active="insights" />
552 <div class="insights-wrap">
553 <section class="insights-hero">
554 <div class="insights-hero-orb" aria-hidden="true" />
555 <div class="insights-hero-inner">
556 <div class="insights-eyebrow">
557 <span class="insights-eyebrow-dot" aria-hidden="true" />
558 Insights · {owner}/{repo}
559 </div>
560 <h2 class="insights-title">
561 <span class="insights-title-grad">Code intelligence.</span>
562 </h2>
563 <p class="insights-sub">
564 Delivery, quality, code-health, and people signals for this
565 repository — the reports GitHub doesn't ship.
566 </p>
567 </div>
568 </section>
569
570 {groups.map((g) => (
571 <>
572 <div class="insights-section-head">
573 <h3 class="insights-section-title">{g.heading}</h3>
574 </div>
575 <div
576 style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);margin-bottom:var(--space-5)"
577 >
578 {g.cards.map((card) => (
579 <a
580 href={card.href}
581 style="display:block;padding:16px 18px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;text-decoration:none;transition:border-color 120ms ease,transform 120ms ease"
582 >
583 <div style="display:flex;align-items:center;gap:9px;margin-bottom:6px">
584 <span aria-hidden="true" style="font-size:18px">{card.icon}</span>
585 <span style="font-size:15px;font-weight:600;color:var(--text-strong)">{card.title}</span>
586 <span style="margin-left:auto;color:var(--text-muted)">→</span>
587 </div>
588 <div style="font-size:13px;color:var(--text-muted);line-height:1.5">{card.desc}</div>
589 </a>
590 ))}
591 </div>
592 </>
593 ))}
594 </div>
595 <style dangerouslySetInnerHTML={{ __html: styles }} />
596 </Layout>
597 );
598});
599
499600insights.get("/:owner/:repo/coupling", async (c) => {
500601 const { owner, repo } = c.req.param();
501602 const user = c.get("user");
Modifiedsrc/routes/test-gaps.tsx+25−7View fileUnifiedSplit
@@ -18,7 +18,7 @@ import { requireRepoAccess } from "../middleware/repo-access";
1818import { Layout } from "../views/layout";
1919import { RepoHeader, RepoNav } from "../views/components";
2020import { getUnreadCount } from "../lib/unread";
21import { getTestGaps, clearTestGapsCache, type TestGap, type TestGapReport } from "../lib/test-gaps";
21import { getTestGaps, getCachedTestGaps, ensureTestGapsAnalysis, clearTestGapsCache, type TestGap, type TestGapReport } from "../lib/test-gaps";
2222
2323const testGapsRoutes = new Hono<AuthEnv>();
2424
@@ -322,13 +322,16 @@ testGapsRoutes.get(
322322
323323 const unreadCount = user ? await getUnreadCount(user.id) : 0;
324324
325 // Load / compute report
326 let report: TestGapReport | null = null;
325 // Load report from cache ONLY — never block the page on the ~30s
326 // detectTestGaps walk, which blew past the proxy timeout and 502'd the
327 // page on a cold cache (every deploy resets the in-memory cache). On a
328 // miss we kick off the analysis in the background and render an
329 // "analysing…" state that auto-refreshes; the next load shows the result.
330 let report: TestGapReport | null = getCachedTestGaps(repo.id);
327331 let analysisError: string | null = null;
328 try {
329 report = await getTestGaps(ownerName, repoName, repo.id);
330 } catch (err) {
331 analysisError = err instanceof Error ? err.message : "Analysis failed";
332 const analysing = !report;
333 if (analysing) {
334 ensureTestGapsAnalysis(ownerName, repoName, repo.id);
332335 }
333336
334337 const highCount = report?.gaps.filter((g) => riskTier(g.riskScore) === "high").length ?? 0;
@@ -457,6 +460,21 @@ testGapsRoutes.get(
457460 </div>
458461 )}
459462 </>
463 ) : analysing ? (
464 <div class="tg-empty">
465 <div class="tg-empty-icon">⏳</div>
466 <div class="tg-empty-title">Analysing test coverage…</div>
467 <p class="tg-empty-sub">
468 This repo hasn't been analysed yet — scanning source files and
469 ranking untested code by risk (usually ~30 seconds). This page
470 refreshes automatically.
471 </p>
472 <script
473 dangerouslySetInnerHTML={{
474 __html: "setTimeout(function(){location.reload();},15000);",
475 }}
476 />
477 </div>
460478 ) : !analysisError ? (
461479 <div class="tg-empty">
462480 <div class="tg-empty-icon">🧪</div>
463481