Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions platform/lib/queries/invest-here.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,21 @@ const severityRank: Record<HotspotSeverity, number> = {
};

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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
}));

Expand Down
16 changes: 13 additions & 3 deletions platform/lib/queries/temporal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,23 @@ export async function getRepoTimeSeries(
limit = 52,
windowDays: number = DEFAULT_WINDOW_DAYS,
): Promise<TimeSeriesPoint[]> {
// 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(
"created_at, stabilization_ratio, revert_rate, churn_events, commits_total, ai_detection_coverage_pct",
)
.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,
Expand Down Expand Up @@ -134,17 +140,21 @@ export async function getRepoAITimeSeries(
limit = 52,
windowDays: number = DEFAULT_WINDOW_DAYS,
): Promise<AIImpactPoint[]> {
// 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<string, unknown>;

Expand Down
59 changes: 41 additions & 18 deletions platform/src/app/[tenant]/ai-exposure/page.tsx
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -46,9 +64,14 @@ export default async function AIExposurePage({

return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t('aiExposure.title')}</h1>
<p className="text-sm text-muted-foreground">{t('aiExposure.subtitle')}</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t("aiExposure.title")}</h1>
<p className="text-sm text-muted-foreground">
{t("aiExposure.subtitle")}
</p>
</div>
<WindowSelector windowDays={windowDays} options={availableWindows} />
</div>

<AIExposureView exposure={exposure} tenantSlug={tenant} />
Expand Down
11 changes: 8 additions & 3 deletions platform/src/app/[tenant]/repos/[repoName]/charts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<tr
key={w.week_start}
Expand All @@ -1146,7 +1151,7 @@ export function RepoCharts({
</td>
<td className="py-2">
{(
((origin.AI_ASSISTED ?? 0) / totalOrigin) *
((origin.AI_ASSISTED ?? 0) / totalOriginNonBot) *
100
).toFixed(0)}
%
Expand Down
7 changes: 7 additions & 0 deletions platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export function DORARepoCard({ data }: Props) {
<MetricCard
label={t("dashboard.dora.metrics.mttrPerDeploy")}
value={formatHours(data.mttrPerDeploySecondsMedian)}
hint={
data.mttrPerDeploySecondsP90 !== null
? t("dashboard.dora.metrics.mttrP90Hint", {
value: formatHours(data.mttrPerDeploySecondsP90),
})
: undefined
}
/>
<MetricCard
label={t("dashboard.dora.metrics.deployFrequency")}
Expand Down
74 changes: 51 additions & 23 deletions platform/src/app/[tenant]/repos/page.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,80 @@
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 { RepoList } from '../dashboard/repo-list';
import { RepoList } from "../dashboard/repo-list";

import { authOptions } from '@/lib/auth';
import { getOrgReposSummary } from '@/lib/queries/temporal';
import { getServerTranslation } from '@/lib/server-translation';
import { supabaseAdmin } from '@/lib/supabase';
import { WindowSelector } from "@/components/WindowSelector";
import { authOptions } from "@/lib/auth";
import {
getAvailableWindowDays,
resolveWindowDays,
parseWindowParam,
getOrgReposSummary,
} from "@/lib/queries/temporal";
import { getServerTranslation } from "@/lib/server-translation";
import { supabaseAdmin } from "@/lib/supabase";

export default async function ReposPage({
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 { 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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t('repos.title')}</h1>
<p className="text-sm text-muted-foreground">
{t('repos.subtitle', { count: repoSummaries.length, org: org.name })}
</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t("repos.title")}</h1>
<p className="text-sm text-muted-foreground">
{t("repos.subtitle", {
count: repoSummaries.length,
org: org.name,
})}
</p>
</div>
<WindowSelector windowDays={windowDays} options={availableWindows} />
</div>

<RepoList
Expand Down
10 changes: 6 additions & 4 deletions platform/src/types/invest-here.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
* All hotspots are systemic (directory, file coupling, origin). No per-person fields.
*/

export type HotspotSeverity = 'high' | 'medium' | 'low';
export type HotspotSeverity = "high" | "medium" | "low";

export interface WeakDirectoryHotspot {
kind: 'weak_directory';
kind: "weak_directory";
severity: HotspotSeverity;
directory: string;
stabilizationRatio: number;
Expand All @@ -18,7 +18,7 @@ export interface WeakDirectoryHotspot {
}

export interface TightCouplingHotspot {
kind: 'tight_coupling';
kind: "tight_coupling";
severity: HotspotSeverity;
fileA: string;
fileB: string;
Expand All @@ -27,11 +27,13 @@ export interface TightCouplingHotspot {
}

export interface FixMagnetHotspot {
kind: 'fix_magnet';
kind: "fix_magnet";
severity: HotspotSeverity;
origin: string;
disproportionality: number;
/** 0-100. */
codeSharePct: number;
/** 0-100. */
fixSharePct: number;
fixesAttracted: number;
}
Expand Down
2 changes: 2 additions & 0 deletions platform/src/types/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ export interface RevertMetrics {

export interface FixTargetMetrics {
fixes_attracted: number;
/** Despite the name, a 0-1 fraction (iris/analysis/fix_targeting.py). Scale by 100 before display. */
code_share_pct: number;
/** Despite the name, a 0-1 fraction (iris/analysis/fix_targeting.py). Scale by 100 before display. */
fix_share_pct: number;
disproportionality: number;
}
Expand Down
Loading
Loading