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); + } + }); +});