From d3ee82fc0aa0a3d980d447dbd88c3937f5df9821 Mon Sep 17 00:00:00 2001 From: Rodrigo Alves da Silva Matos Date: Thu, 13 Aug 2026 17:15:13 -0300 Subject: [PATCH] fix(platform): five high-severity bugs on /repos and repo-detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getRepoTimeSeries / getRepoAITimeSeries fetched the OLDEST N runs instead of the newest N: `.order("created_at", { ascending: true }).limit(52)` sorts ascending then takes the first 52, i.e. the oldest 52 once a repo/window has more than 52 analysis runs. The repo-detail page's top MetricCards, ChangeAlert, and every chart fed by these two queries would freeze on a stale run and never advance, while the rest of the page (sourced from getRepoLatestPayload, which already did DESC + limit(1) correctly) kept showing the true latest state — two contradictory pictures of the same repo on one screen. Fixed by fetching DESC + limit then reversing in JS, mirroring the pattern getOrgReposSummary's sparkline already uses correctly. - Investment Hotspots' fix-magnet cards rendered "0% of changes, 1% of fixes" instead of "35%, 70%". The engine's code_share_pct/ fix_share_pct (iris/analysis/fix_targeting.py) are 0-1 fractions despite the "_pct" name; the platform copied them through unscaled while its two sibling hotspot types (stabilizationRatio, couplingRate) correctly multiply by 100. Scaled at the query layer and documented the misleading engine field name on both the raw FixTargetMetrics type and the platform's own FixMagnetHotspot type. - /repos and /ai-exposure called getOrgReposSummary (and, for ai-exposure, getOrgLatestPayloads) without resolving the org's actual available analysis window first — unlike /dashboard and /compare, which both call getAvailableWindowDays + resolveWindowDays. Both pages silently defaulted to the hardcoded 90-day window; any org ingesting under a different window saw every repo as unanalyzed on these two pages while the other two showed real data. Added the same window resolution plus a WindowSelector, for parity with the rest of the app. - The repo-scoped DORA card never got the MTTR P90 hint that was added to the org dashboard's DORAOverview and to RepoDORA's own type — a wiring gap, not a missing capability. Added it, reusing the same translation key. - The Weekly Activity table's "AI%" column divided by HUMAN+AI_ASSISTED +BOT, while the canonical ai_detection_coverage_pct metric (used by the "AI Adoption" chart on the same page) explicitly excludes BOT from its denominator. A week with a dependency-bump burst would show two different, both-labeled-"AI%" numbers on the same screen that don't reconcile. Excluded BOT from the table's denominator to match. Added a regression test for the fix-magnet scaling bug (confirmed it fails against the pre-fix code: 0.35 instead of 35). The other four fixes aren't covered by an automated test — three are UI/page-level code this codebase doesn't unit-test anywhere, and the time-series ordering fix is a direct Supabase query with no existing DB-mocking convention to build on (platform/CLAUDE.md: "integration tests use real DBs, unit tests for analysis modules"). Validated via tsc + careful review against the already-correct sibling patterns in the same files. Co-Authored-By: Claude Sonnet 5 --- platform/lib/queries/invest-here.ts | 31 ++++---- platform/lib/queries/temporal.ts | 16 +++- .../src/app/[tenant]/ai-exposure/page.tsx | 59 ++++++++++----- .../app/[tenant]/repos/[repoName]/charts.tsx | 11 ++- .../[tenant]/repos/[repoName]/dora-card.tsx | 7 ++ platform/src/app/[tenant]/repos/page.tsx | 74 +++++++++++++------ platform/src/types/invest-here.ts | 10 ++- platform/src/types/metrics.ts | 2 + platform/tests/invest-here.test.ts | 56 ++++++++++++++ 9 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 platform/tests/invest-here.test.ts diff --git a/platform/lib/queries/invest-here.ts b/platform/lib/queries/invest-here.ts index c9caafa..90fac7c 100644 --- a/platform/lib/queries/invest-here.ts +++ b/platform/lib/queries/invest-here.ts @@ -45,21 +45,21 @@ const severityRank: Record = { }; function weakDirSeverity(ratio: number): HotspotSeverity { - if (ratio < WEAK_DIR_HIGH_RATIO) return 'high'; - if (ratio < WEAK_DIR_MED_RATIO) return 'medium'; - return 'low'; + if (ratio < WEAK_DIR_HIGH_RATIO) return "high"; + if (ratio < WEAK_DIR_MED_RATIO) return "medium"; + return "low"; } function couplingSeverity(rate: number): HotspotSeverity { - if (rate >= COUPLING_HIGH_RATE) return 'high'; - if (rate >= COUPLING_MED_RATE) return 'medium'; - return 'low'; + if (rate >= COUPLING_HIGH_RATE) return "high"; + if (rate >= COUPLING_MED_RATE) return "medium"; + return "low"; } function fixSeverity(disp: number): HotspotSeverity { - if (disp >= FIX_HIGH_DISPROPORTIONALITY) return 'high'; - if (disp >= FIX_MED_DISPROPORTIONALITY) return 'medium'; - return 'low'; + if (disp >= FIX_HIGH_DISPROPORTIONALITY) return "high"; + if (disp >= FIX_MED_DISPROPORTIONALITY) return "medium"; + return "low"; } export function computeInvestmentHotspots( @@ -91,7 +91,7 @@ export function computeInvestmentHotspots( }) .slice(0, WEAK_DIR_LIMIT) .map((d) => ({ - kind: 'weak_directory', + kind: "weak_directory", severity: weakDirSeverity(d.stabilization_ratio), directory: d.directory, stabilizationRatio: d.stabilization_ratio, @@ -118,7 +118,7 @@ export function computeInvestmentHotspots( }) .slice(0, COUPLING_LIMIT) .map((c) => ({ - kind: 'tight_coupling', + kind: "tight_coupling", severity: couplingSeverity(c.coupling_rate), fileA: c.file_a, fileB: c.file_b, @@ -142,12 +142,15 @@ export function computeInvestmentHotspots( .sort(([, a], [, b]) => b.disproportionality - a.disproportionality) .slice(0, FIX_LIMIT) .map(([origin, m]) => ({ - kind: 'fix_magnet', + kind: "fix_magnet", severity: fixSeverity(m.disproportionality), origin, disproportionality: m.disproportionality, - codeSharePct: m.code_share_pct, - fixSharePct: m.fix_share_pct, + // The engine names these "_pct" but returns 0-1 fractions + // (iris/analysis/fix_targeting.py) — scale to 0-100 here so the + // field actually holds what its name promises. + codeSharePct: m.code_share_pct * 100, + fixSharePct: m.fix_share_pct * 100, fixesAttracted: m.fixes_attracted, })); diff --git a/platform/lib/queries/temporal.ts b/platform/lib/queries/temporal.ts index f7daf9c..482c7f5 100644 --- a/platform/lib/queries/temporal.ts +++ b/platform/lib/queries/temporal.ts @@ -89,6 +89,12 @@ export async function getRepoTimeSeries( limit = 52, windowDays: number = DEFAULT_WINDOW_DAYS, ): Promise { + // Fetch newest-first and take the most recent `limit` rows, then reverse + // to chronological order for charting. Ordering ascending before LIMIT + // would return the OLDEST `limit` rows instead once a repo has more than + // `limit` runs — the chart (and the "latest" point every caller derives + // from the last array entry) would freeze on a stale run and never + // advance as new analyses land. const { data } = await supabase .from("metrics") .select( @@ -96,10 +102,10 @@ export async function getRepoTimeSeries( ) .eq("repository_id", repositoryId) .eq("window_days", windowDays) - .order("created_at", { ascending: true }) + .order("created_at", { ascending: false }) .limit(limit); - return (data ?? []).map((row) => ({ + return (data ?? []).reverse().map((row) => ({ date: row.created_at, stabilization_ratio: row.stabilization_ratio, revert_rate: row.revert_rate, @@ -134,17 +140,21 @@ export async function getRepoAITimeSeries( limit = 52, windowDays: number = DEFAULT_WINDOW_DAYS, ): Promise { + // See getRepoTimeSeries above: fetch newest-first then reverse, or a repo + // with more than `limit` runs would get the oldest `limit` instead and + // the AI-impact charts would freeze on a stale slice. const { data } = await supabase .from("metrics") .select("created_at, payload, ai_detection_coverage_pct") .eq("repository_id", repositoryId) .eq("window_days", windowDays) - .order("created_at", { ascending: true }) + .order("created_at", { ascending: false }) .limit(limit); if (!data) return []; return data + .reverse() .map((row) => { const p = (row.payload ?? {}) as Record; diff --git a/platform/src/app/[tenant]/ai-exposure/page.tsx b/platform/src/app/[tenant]/ai-exposure/page.tsx index 78be185..c033195 100644 --- a/platform/src/app/[tenant]/ai-exposure/page.tsx +++ b/platform/src/app/[tenant]/ai-exposure/page.tsx @@ -1,40 +1,58 @@ -import { notFound, redirect } from 'next/navigation'; +import { notFound, redirect } from "next/navigation"; -import { getServerSession } from 'next-auth/next'; +import { getServerSession } from "next-auth/next"; -import { AIExposureView } from './ai-exposure-view'; - -import { authOptions } from '@/lib/auth'; -import { getOrgLatestPayloads } from '@/lib/queries/org-summary'; -import { computeShadowAIExposure } from '@/lib/queries/shadow-ai'; -import { getOrgReposSummary } from '@/lib/queries/temporal'; -import { getServerTranslation } from '@/lib/server-translation'; -import { supabaseAdmin } from '@/lib/supabase'; +import { AIExposureView } from "./ai-exposure-view"; +import { WindowSelector } from "@/components/WindowSelector"; +import { authOptions } from "@/lib/auth"; +import { getOrgLatestPayloads } from "@/lib/queries/org-summary"; +import { computeShadowAIExposure } from "@/lib/queries/shadow-ai"; +import { + getAvailableWindowDays, + resolveWindowDays, + parseWindowParam, + getOrgReposSummary, +} from "@/lib/queries/temporal"; +import { getServerTranslation } from "@/lib/server-translation"; +import { supabaseAdmin } from "@/lib/supabase"; export default async function AIExposurePage({ params, + searchParams, }: { params: Promise<{ tenant: string }>; + searchParams: Promise<{ window?: string }>; }) { const session = await getServerSession(authOptions); - if (!session?.user) redirect('/auth/signin'); + if (!session?.user) redirect("/auth/signin"); const { tenant } = await params; + const { window: windowParam } = await searchParams; const { data: org } = await supabaseAdmin - .from('organizations') - .select('id, name') - .eq('slug', tenant) + .from("organizations") + .select("id, name") + .eq("slug", tenant) .single(); if (!org) notFound(); - const repos = await getOrgReposSummary(supabaseAdmin, org.id); + // Analysis window (issue #80): resolve to a window the org actually has + // data for, instead of defaulting to 90d and showing every repo as + // unanalyzed when the org ingests under a different window. + const availableWindows = await getAvailableWindowDays(supabaseAdmin, org.id); + const windowDays = resolveWindowDays( + parseWindowParam(windowParam), + availableWindows, + ); + + const repos = await getOrgReposSummary(supabaseAdmin, org.id, windowDays); const payloads = await getOrgLatestPayloads( supabaseAdmin, org.id, repos.map((r) => r.id), + windowDays, ); const exposure = computeShadowAIExposure( @@ -46,9 +64,14 @@ export default async function AIExposurePage({ return (
-
-

{t('aiExposure.title')}

-

{t('aiExposure.subtitle')}

+
+
+

{t("aiExposure.title")}

+

+ {t("aiExposure.subtitle")} +

+
+
diff --git a/platform/src/app/[tenant]/repos/[repoName]/charts.tsx b/platform/src/app/[tenant]/repos/[repoName]/charts.tsx index 67e24e8..f692823 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/charts.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/charts.tsx @@ -1121,8 +1121,13 @@ export function RepoCharts({ const totalIntent = Object.values(intent).reduce((a, b) => a + b, 0) || 1; const origin = w.origin ?? {}; - const totalOrigin = - Object.values(origin).reduce((a, b) => a + b, 0) || 1; + // Excludes BOT, matching ai_detection_coverage_pct's own + // denominator (ai_commits / total_non_bot_commits) — the + // "AI Adoption" chart on this same page uses that field. + // Including bot commits here understated AI share on any + // week with dependency-bump activity. + const totalOriginNonBot = + (origin.HUMAN ?? 0) + (origin.AI_ASSISTED ?? 0) || 1; return ( {( - ((origin.AI_ASSISTED ?? 0) / totalOrigin) * + ((origin.AI_ASSISTED ?? 0) / totalOriginNonBot) * 100 ).toFixed(0)} % diff --git a/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx b/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx index d8860fa..206239a 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx @@ -38,6 +38,13 @@ export function DORARepoCard({ data }: Props) { ; + searchParams: Promise<{ window?: string }>; }) { const session = await getServerSession(authOptions); - if (!session?.user) redirect('/auth/signin'); + if (!session?.user) redirect("/auth/signin"); const { tenant } = await params; + const { window: windowParam } = await searchParams; const { t } = await getServerTranslation(); const { data: org } = await supabaseAdmin - .from('organizations') - .select('id, name') - .eq('slug', tenant) + .from("organizations") + .select("id, name") + .eq("slug", tenant) .single(); if (!org) notFound(); const { data: membership } = await supabaseAdmin - .from('organization_members') - .select('role') - .eq('user_id', session.user.id) - .eq('organization_id', org.id) + .from("organization_members") + .select("role") + .eq("user_id", session.user.id) + .eq("organization_id", org.id) .single(); - const role = membership?.role as 'owner' | 'admin' | 'member' | undefined; - const canDelete = role === 'owner' || role === 'admin'; + const role = membership?.role as "owner" | "admin" | "member" | undefined; + const canDelete = role === "owner" || role === "admin"; - const repoSummaries = await getOrgReposSummary(supabaseAdmin, org.id); + // Analysis window (issue #80): resolve to a window the org actually has + // data for, instead of defaulting to 90d and showing every repo as "0 + // runs" when the org ingests under a different window. + const availableWindows = await getAvailableWindowDays(supabaseAdmin, org.id); + const windowDays = resolveWindowDays( + parseWindowParam(windowParam), + availableWindows, + ); + + const repoSummaries = await getOrgReposSummary( + supabaseAdmin, + org.id, + windowDays, + ); return (
-
-

{t('repos.title')}

-

- {t('repos.subtitle', { count: repoSummaries.length, org: org.name })} -

+
+
+

{t("repos.title")}

+

+ {t("repos.subtitle", { + count: repoSummaries.length, + org: org.name, + })} +

+
+
): ReportMetrics { + return { + commits_total: 0, + commits_revert: 0, + revert_rate: 0, + churn_events: 0, + churn_lines_affected: 0, + files_touched: 0, + files_stabilized: 0, + stabilization_ratio: 0, + ...over, + } as ReportMetrics; +} + +describe("computeInvestmentHotspots — fix magnet scaling", () => { + it("scales code_share_pct/fix_share_pct (0-1 fractions from the engine) to 0-100", () => { + const out = computeInvestmentHotspots( + payload({ + fix_target_by_origin: { + AI_ASSISTED: { + fixes_attracted: 10, + code_share_pct: 0.35, + fix_share_pct: 0.7, + disproportionality: 2.5, + }, + HUMAN: { + fixes_attracted: 0, + code_share_pct: 0, + fix_share_pct: 0, + disproportionality: 0, + }, + BOT: { + fixes_attracted: 0, + code_share_pct: 0, + fix_share_pct: 0, + disproportionality: 0, + }, + }, + }), + ); + + const magnet = out.hotspots.find((h) => h.kind === "fix_magnet"); + expect(magnet).toBeDefined(); + // Not 0.35/0.7 — the engine's field is misleadingly named "_pct" but + // holds a 0-1 fraction (iris/analysis/fix_targeting.py). + if (magnet?.kind === "fix_magnet") { + expect(magnet.codeSharePct).toBe(35); + expect(magnet.fixSharePct).toBe(70); + } + }); +});