diff --git a/platform/lib/pr-enrichment.ts b/platform/lib/pr-enrichment.ts new file mode 100644 index 0000000..b51b24c --- /dev/null +++ b/platform/lib/pr-enrichment.ts @@ -0,0 +1,85 @@ +import type { PrReadStep } from "@/types/metrics"; + +// Every step the type allows. Keyed by the union, so the compiler checks both +// directions: a step added to `PrReadStep` fails to compile until it is here. +const KNOWN_STEPS: Record = { + basic: true, + enrichment: true, + reviews: true, + fetch: true, +}; + +/** + * The degraded PR-read steps a metrics payload reports, or `[]` when none. + * + * `/api/ingest` stores the payload with `.passthrough()`, so this field can + * arrive from any CLI version in any shape. Anything that is not a list of + * known step names is ignored rather than trusted. + */ +export function prDegradedSteps(value: unknown): PrReadStep[] { + if (!Array.isArray(value)) return []; + const steps = value.filter( + (step): step is PrReadStep => + typeof step === "string" && Object.hasOwn(KNOWN_STEPS, step), + ); + // Same step twice adds nothing; keep the first occurrence's position. + return [...new Set(steps)]; +} + +/** The `t()` of both `useTranslation` and `getServerTranslation`. */ +export type Translate = ( + path: string, + params?: Record, +) => string; + +/** Degraded steps as a localized, comma-separated list for tooltips/notes. */ +export function formatPrSteps( + steps: readonly PrReadStep[], + t: Translate, +): string { + return steps.map((step) => t(`repos.detail.prData.steps.${step}`)).join(", "); +} + +/** Most repo names the dashboard note lists before collapsing the rest. */ +export const MAX_LISTED_REPOS = 5; + +/** + * Repo names for the dashboard note: the first `max`, then a localized + * "and N more" for the remainder, so one bad batch cannot flood the page. + */ +export function formatRepoNames( + names: readonly string[], + t: Translate, + max: number = MAX_LISTED_REPOS, +): string { + const listed = names.slice(0, max).join(", "); + const rest = names.length - max; + if (rest <= 0) return listed; + return `${listed} ${t("dashboard.prDataMoreRepos", { count: rest })}`; +} + +export interface DegradedRepo { + id: string; + name: string; + steps: PrReadStep[]; +} + +/** + * The repos whose latest payload reports a degraded PR read, in the order of + * `repos`. Takes the payloads the dashboard already loads, so it costs no + * extra query. + */ +export function degradedReposFromPayloads( + repos: ReadonlyArray<{ id: string; name: string }>, + payloads: ReadonlyMap, +): DegradedRepo[] { + const degraded: DegradedRepo[] = []; + for (const repo of repos) { + const steps = prDegradedSteps( + payloads.get(repo.id)?.pr_enrichment_degraded, + ); + if (steps.length > 0) + degraded.push({ id: repo.id, name: repo.name, steps }); + } + return degraded; +} diff --git a/platform/lib/queries/org-summary.ts b/platform/lib/queries/org-summary.ts index a358f6e..e8a1d8f 100644 --- a/platform/lib/queries/org-summary.ts +++ b/platform/lib/queries/org-summary.ts @@ -659,15 +659,27 @@ export function computePRHealth( const acc = p.acceptance_by_origin; if (acc?.HUMAN && acc.HUMAN.commits_in_prs > 0) { const w = acc.HUMAN.commits_in_prs; - humanSPRSum += acc.HUMAN.single_pass_rate * w; - humanRoundsSum += acc.HUMAN.median_review_rounds * w; - humanWeight += w; + // The engine omits the review metrics when the reviews read degraded; + // skip the group instead of letting `undefined * w` poison the sums. + if ( + typeof acc.HUMAN.single_pass_rate === "number" && + typeof acc.HUMAN.median_review_rounds === "number" + ) { + humanSPRSum += acc.HUMAN.single_pass_rate * w; + humanRoundsSum += acc.HUMAN.median_review_rounds * w; + humanWeight += w; + } } if (acc?.AI_ASSISTED && acc.AI_ASSISTED.commits_in_prs > 0) { const w = acc.AI_ASSISTED.commits_in_prs; - aiSPRSum += acc.AI_ASSISTED.single_pass_rate * w; - aiRoundsSum += acc.AI_ASSISTED.median_review_rounds * w; - aiWeight += w; + if ( + typeof acc.AI_ASSISTED.single_pass_rate === "number" && + typeof acc.AI_ASSISTED.median_review_rounds === "number" + ) { + aiSPRSum += acc.AI_ASSISTED.single_pass_rate * w; + aiRoundsSum += acc.AI_ASSISTED.median_review_rounds * w; + aiWeight += w; + } } } diff --git a/platform/lib/queries/temporal.ts b/platform/lib/queries/temporal.ts index f498a47..d6a128a 100644 --- a/platform/lib/queries/temporal.ts +++ b/platform/lib/queries/temporal.ts @@ -3,8 +3,9 @@ * Pure functions that take a Supabase client and return typed data. */ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { PostgrestError, SupabaseClient } from "@supabase/supabase-js"; +import { prDegradedSteps } from "@/lib/pr-enrichment"; import { logQueryError } from "@/lib/queries/log-error"; import type { TimeSeriesPoint, @@ -16,6 +17,34 @@ import { classifyHealth } from "@/types/temporal"; const SPARKLINE_POINTS = 12; +// The columns of `repo_metric_summaries` that SUMMARY_COLUMNS selects, plus the +// optional payload key. Columns are nullable because the view aggregates over +// runs that may lack a metric. +interface SummaryRow { + repository_id: string; + runs_count: number | null; + last_run_at: string | null; + stabilization_ratio: number | null; + prev_stabilization_ratio: number | null; + revert_rate: number | null; + churn_events: number | null; + commits_total: number | null; + ai_detection_coverage_pct: number | null; + pr_merged_count: number | null; + pr_single_pass_rate: number | null; + fix_latency_median_hours: number | null; + cascade_rate: number | null; + merge_strategy: string | null; + commit_metrics_reliable: boolean | null; + // Newest first; entries can be null. + recent_stabilization: Array | null; + // Untrusted JSON from the payload; read through `prDegradedSteps`. + pr_enrichment_degraded?: unknown; +} + +const SUMMARY_COLUMNS = + "repository_id, runs_count, last_run_at, stabilization_ratio, prev_stabilization_ratio, revert_rate, churn_events, commits_total, ai_detection_coverage_pct, pr_merged_count, pr_single_pass_rate, fix_latency_median_hours, cascade_rate, merge_strategy, commit_metrics_reliable, recent_stabilization"; + /** * Canonical percentage-point significance thresholds, mirrored from * `iris/analysis/trend_delta.py` (see docs/METRICS.md): delta < PP_STABLE is @@ -204,6 +233,17 @@ export async function getRepoAITimeSeries( .filter((p) => p.ai_pct !== null && p.ai_pct > 0); } +/** + * Whether a PostgREST error says the view has no `payload` column: PostgreSQL's + * undefined_column (42703), or a PostgREST-side error naming the column. + */ +function isMissingPayloadColumn(error: PostgrestError): boolean { + return ( + error.code === "42703" || + /payload|pr_enrichment_degraded/i.test(error.message ?? "") + ); +} + /** Get summary for all repos in an org (latest + previous for delta). * * Uses 2 bulk queries instead of 3N+1 per-repo queries: @@ -216,11 +256,17 @@ export async function getRepoAITimeSeries( * window, repos whose latest run fell outside the newest-1000 slice came back * with zero rows and rendered "0 runs" despite having metrics. The view * returns ~one row per repo, so the result set stays well under any cap. + * + * `options.includePrDegraded` also selects `payload->pr_enrichment_degraded`. + * It is off by default because reading any key of `payload` makes the view + * aggregate the payload of every run, which only the compare table needs. + * Without it every row has `pr_degraded_steps: []`. */ export async function getOrgReposSummary( supabase: SupabaseClient, organizationId: string, windowDays: number = DEFAULT_WINDOW_DAYS, + options: { includePrDegraded?: boolean } = {}, ): Promise { // Query 1: all repos const { data: repos, error: reposError } = await supabase @@ -233,19 +279,45 @@ export async function getOrgReposSummary( if (!repos || repos.length === 0) return []; // Query 2: pre-aggregated summary, one row per repo (see doc comment). - const { data: summaries, error: summariesError } = await supabase - .from("repo_metric_summaries") - .select( - "repository_id, runs_count, last_run_at, stabilization_ratio, prev_stabilization_ratio, revert_rate, churn_events, commits_total, ai_detection_coverage_pct, pr_merged_count, pr_single_pass_rate, fix_latency_median_hours, cascade_rate, merge_strategy, commit_metrics_reliable, recent_stabilization", - ) - .eq("organization_id", organizationId) - .eq("window_days", windowDays); + const readSummaries = async (columns: string) => { + const result = await supabase + .from("repo_metric_summaries") + .select(columns) + .eq("organization_id", organizationId) + .eq("window_days", windowDays); + // The column list is built at runtime, so supabase-js can't infer the row. + return result as unknown as { + data: SummaryRow[] | null; + error: PostgrestError | null; + }; + }; + + let summaries: SummaryRow[] | null; + let summariesError: PostgrestError | null; + if (options.includePrDegraded) { + // Selecting one key out of `payload` marks degraded PR reads without + // shipping whole payloads. The view only has `payload` since migration + // 023; when the column is missing (older database) retry without it so the + // table stays populated, just unmarked. Any other error (a timeout, say) + // is not retried: it goes the normal error path below. + ({ data: summaries, error: summariesError } = await readSummaries( + `${SUMMARY_COLUMNS}, pr_enrichment_degraded:payload->pr_enrichment_degraded`, + )); + if (summariesError && isMissingPayloadColumn(summariesError)) { + logQueryError( + "getOrgReposSummary (repo_metric_summaries with payload)", + summariesError, + ); + ({ data: summaries, error: summariesError } = + await readSummaries(SUMMARY_COLUMNS)); + } + } else { + ({ data: summaries, error: summariesError } = + await readSummaries(SUMMARY_COLUMNS)); + } logQueryError("getOrgReposSummary (repo_metric_summaries)", summariesError); - const summaryByRepo = new Map< - string, - NonNullable[number] - >(); + const summaryByRepo = new Map(); for (const row of summaries ?? []) summaryByRepo.set(row.repository_id, row); return repos.map((repo) => { @@ -263,7 +335,7 @@ export async function getOrgReposSummary( const sparkline = (s?.recent_stabilization ?? []) .slice(0, SPARKLINE_POINTS) .reverse() - .filter((v: number | null): v is number => v !== null); + .filter((v): v is number => v !== null); return { id: repo.id, @@ -282,6 +354,9 @@ export async function getOrgReposSummary( cascade_rate: s?.cascade_rate ?? null, merge_strategy: s?.merge_strategy ?? null, commit_metrics_reliable: s?.commit_metrics_reliable ?? null, + pr_degraded_steps: options.includePrDegraded + ? prDegradedSteps(s?.pr_enrichment_degraded) + : [], stabilization_delta: delta, health: classifyHealth(stabilization), sparkline, diff --git a/platform/lib/queries/tool-comparison.ts b/platform/lib/queries/tool-comparison.ts index d09ae68..862a98d 100644 --- a/platform/lib/queries/tool-comparison.ts +++ b/platform/lib/queries/tool-comparison.ts @@ -93,7 +93,8 @@ export function computeToolComparison( for (const [tool, m] of Object.entries(p.acceptance_by_tool)) { const acc = accs.get(tool) ?? emptyAcc(tool); const weight = m.commits_in_prs ?? 0; - if (weight > 0) { + // Omitted by the engine when the PR reviews read degraded. + if (weight > 0 && typeof m.single_pass_rate === "number") { acc.singlePassSum += m.single_pass_rate * weight; acc.singlePassWeight += weight; } diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index a81a997..2b89e53 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -267,6 +267,9 @@ export const translations = { member: "Member", }, dashboard: { + prDataMoreRepos: "and {count} more", + prDataIncomplete: + "PR data is incomplete in the latest run of {count} repository(ies) ({repos}); PR metrics below may be missing or skewed.", pulse: { totalCommits: "Total Commits", prsMerged: "PRs Merged", @@ -618,6 +621,17 @@ export const translations = { unreliableTooltip: "This repo collapses commits on merge (squash/mixed), so commit-level metrics (commit shape, cascades, AI detection coverage) are approximate.", }, + prData: { + incomplete: "PR data incomplete", + steps: { + basic: "PR list", + enrichment: "commit enrichment", + reviews: "reviews", + fetch: "entire read", + }, + incompleteTooltip: + "This run's PR read failed at: {steps}. Merge strategy isn't classified when enrichment fails for merged PRs, and PR lifecycle (counts, time to merge, cycle time), flow, review, in-PR and open-PR metrics may be missing or skewed.", + }, dora: { title: "DORA", subtitle: @@ -1826,6 +1840,9 @@ export const translations = { member: "Membro", }, dashboard: { + prDataMoreRepos: "e mais {count}", + prDataIncomplete: + "Os dados de PR estão incompletos na execução mais recente de {count} repositório(s) ({repos}); as métricas de PR abaixo podem faltar ou sair distorcidas.", pulse: { totalCommits: "Commits totais", prsMerged: "PRs mesclados", @@ -2178,6 +2195,17 @@ export const translations = { unreliableTooltip: "Este repo colapsa commits no merge (squash/mixed), então métricas por-commit (formato de commit, cascades, cobertura de detecção de IA) são aproximadas.", }, + prData: { + incomplete: "Dados de PR incompletos", + steps: { + basic: "listagem de PRs", + enrichment: "enriquecimento de commits", + reviews: "reviews", + fetch: "leitura inteira", + }, + incompleteTooltip: + "A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge não é classificada quando o enriquecimento dos PRs mergeados falha, e métricas de ciclo de vida de PR (contagens, tempo até o merge, cycle time), de flow, de review, de commits em PR e de PRs abertos podem faltar ou sair distorcidas.", + }, dora: { title: "DORA", subtitle: diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx index a0b2be1..c3ef5f4 100644 --- a/platform/src/app/[tenant]/compare/compare-view.tsx +++ b/platform/src/app/[tenant]/compare/compare-view.tsx @@ -7,6 +7,7 @@ import { Search } from "lucide-react"; import { Sparkline } from "@/components/charts/Sparkline"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useTranslation } from "@/hooks/useTranslation"; +import { formatPrSteps } from "@/lib/pr-enrichment"; import { cn } from "@/lib/utils"; import type { RepoSummary } from "@/types/temporal"; import { healthIndicator } from "@/types/temporal"; @@ -217,8 +218,48 @@ function MergeStrategyCell({ ); } +/** + * The failed PR-read steps of a repo whose latest run read PR data only + * partially, on a line of their own under the repo name in both layouts: an + * amber dot (the legend under the list says what it means, so nothing hinges + * on colour) and the steps as text, for touch and keyboard users who get no + * hover. Amber 600 on light and 500 on dark keep the dot above 3:1 against + * the card (WCAG 1.4.11). + * + * The dot and the visible steps are aria-hidden, with the full sentence as + * their hover title; screen readers get that sentence once, as sr-only text. + */ +function PrDataSteps({ + repo, + label, + steps, +}: { + repo: RepoSummary; + label: string; + steps: string; +}) { + if (repo.pr_degraded_steps.length === 0) return null; + return ( + + + ); +} + export function CompareView({ repos }: CompareViewProps) { const { t } = useTranslation(); + const prDataTooltip = (repo: RepoSummary) => + t("repos.detail.prData.incompleteTooltip", { + steps: formatPrSteps(repo.pr_degraded_steps, t), + }); const [sort, setSort] = useState({ key: "stabilization_ratio", dir: "desc", @@ -411,6 +452,11 @@ export function CompareView({ repos }: CompareViewProps) { {repo.name} +
-
- - #{i + 1} - - - {repo.name} - +
+
+ + #{i + 1} + + + {repo.name} + +
+
+ + {sorted.some((repo) => repo.pr_degraded_steps.length > 0) && ( +

+

+ )} ); diff --git a/platform/src/app/[tenant]/compare/page.tsx b/platform/src/app/[tenant]/compare/page.tsx index 88766d4..27debab 100644 --- a/platform/src/app/[tenant]/compare/page.tsx +++ b/platform/src/app/[tenant]/compare/page.tsx @@ -44,7 +44,9 @@ export default async function ComparePage({ availableWindows, ); - const repos = await getOrgReposSummary(supabaseAdmin, org.id, windowDays); + const repos = await getOrgReposSummary(supabaseAdmin, org.id, windowDays, { + includePrDegraded: true, + }); const { t } = await getServerTranslation(); return ( diff --git a/platform/src/app/[tenant]/dashboard/page.tsx b/platform/src/app/[tenant]/dashboard/page.tsx index 4daf4a9..440f209 100644 --- a/platform/src/app/[tenant]/dashboard/page.tsx +++ b/platform/src/app/[tenant]/dashboard/page.tsx @@ -18,6 +18,7 @@ import { HyperEngineersPanel } from "./panels/HyperEngineersPanel"; import { IntentDistributionPanel } from "./panels/IntentDistributionPanel"; import { OrgPulsePanel } from "./panels/OrgPulsePanel"; import { OrgTimelinePanel } from "./panels/OrgTimelinePanel"; +import { PRDataNotice } from "./panels/PRDataNotice"; import { PRHealthPanel } from "./panels/PRHealthPanel"; import { HeroRowSkeleton, @@ -140,6 +141,12 @@ export default async function OrgDashboardPage({ + {/* PR data note: above every panel that aggregates PR metrics (pulse, + quality, PR health, cycle time). Nothing renders when none degraded. */} + + + + {/* Org pulse hero cards */} }> diff --git a/platform/src/app/[tenant]/dashboard/panels/PRDataNotice.tsx b/platform/src/app/[tenant]/dashboard/panels/PRDataNotice.tsx new file mode 100644 index 0000000..f86af60 --- /dev/null +++ b/platform/src/app/[tenant]/dashboard/panels/PRDataNotice.tsx @@ -0,0 +1,41 @@ +import { + loadPayloads, + loadRepoSummaries, + type DashboardPanelProps, +} from "../data"; + +import { + degradedReposFromPayloads, + formatRepoNames, +} from "@/lib/pr-enrichment"; +import { getServerTranslation } from "@/lib/server-translation"; + +/** + * Amber note above the PR-driven panels when a repo's latest run read its PR + * data only partially. Renders nothing when no repo degraded. + */ +export async function PRDataNotice({ orgId, windowDays }: DashboardPanelProps) { + const [repos, payloads] = await Promise.all([ + loadRepoSummaries(orgId, windowDays), + loadPayloads(orgId, windowDays), + ]); + + const degraded = degradedReposFromPayloads(repos, payloads); + if (degraded.length === 0) return null; + + const { t } = await getServerTranslation(); + return ( +

+ {t("dashboard.prDataIncomplete", { + count: degraded.length, + repos: formatRepoNames( + degraded.map((r) => r.name), + t, + ), + })} +

+ ); +} diff --git a/platform/src/app/[tenant]/repos/[repoName]/page.tsx b/platform/src/app/[tenant]/repos/[repoName]/page.tsx index 322ae28..6cc7666 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/page.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/page.tsx @@ -13,6 +13,7 @@ import { MetricCard } from "@/components/charts/MetricCard"; import { Badge } from "@/components/ui/badge"; import { WindowSelector } from "@/components/WindowSelector"; import { authOptions } from "@/lib/auth"; +import { formatPrSteps, prDegradedSteps } from "@/lib/pr-enrichment"; import { extractAdoptionSummary } from "@/lib/queries/adoption-timeline"; import { computeRepoDORA } from "@/lib/queries/dora"; import { computeInvestmentHotspots } from "@/lib/queries/invest-here"; @@ -35,11 +36,9 @@ function extractInsights(payload: Record | null) { return { intentDistribution: payload.commit_intent_distribution as - | Record - | undefined, + Record | undefined, originDistribution: payload.commit_origin_distribution as - | Record - | undefined, + Record | undefined, stabilizationByOrigin: payload.stabilization_by_origin as | Record | undefined, @@ -83,34 +82,26 @@ function extractInsights(payload: Record | null) { | undefined, flowEfficiencyMedian: payload.flow_efficiency_median as number | undefined, flowEfficiencyByIntent: payload.flow_efficiency_by_intent as - | Partial> - | undefined, + Partial> | undefined, timeInPhaseMedianHours: payload.time_in_phase_median_hours as - | Partial> - | undefined, + Partial> | undefined, medianTimeToFirstReviewHours: payload.median_time_to_first_review_hours as - | number - | undefined, + number | undefined, humanReviewCoveragePct: payload.human_review_coverage_pct as - | number - | undefined, + number | undefined, humanApprovalCoveragePct: payload.human_approval_coverage_pct as - | number - | undefined, + number | undefined, humanReviewCoverageByIntent: payload.human_review_coverage_by_intent as - | Partial> - | undefined, + Partial> | undefined, humanReviewCoverageByOriginOfPr: payload.human_review_coverage_by_origin_of_pr as - | Partial> - | undefined, + Partial> | undefined, mergeStrategy: payload.merge_strategy as string | undefined, mergeStrategyDominantShare: payload.merge_strategy_dominant_share as - | number - | undefined, + number | undefined, commitMetricsReliable: payload.commit_metrics_reliable as - | boolean - | undefined, + boolean | undefined, + prDegradedSteps: prDegradedSteps(payload.pr_enrichment_degraded), }; } @@ -174,6 +165,7 @@ export default async function RepoDetailPage({ ]); const insights = extractInsights(payload); + const prDegraded = insights.prDegradedSteps ?? []; const hotspots = computeInvestmentHotspots(payload as ReportMetrics | null); const adoptionSummary = extractAdoptionSummary( payload as ReportMetrics | null, @@ -262,6 +254,24 @@ export default async function RepoDetailPage({ {insights.mergeStrategy} ))} + {/* In the header, not in the flow or merge panels: those can + disappear when the read degrades, which would hide the cause. + The failed steps are text next to the badge, for touch and + keyboard users too; no title repeating them, which screen + readers may read as a description on top of the text. */} + {prDegraded.length > 0 && ( + + + {t("repos.detail.prData.incomplete")} + + + {formatPrSteps(prDegraded, t)} + + + )}
{repo.remote_url && (

{repo.remote_url}

diff --git a/platform/src/types/metrics.ts b/platform/src/types/metrics.ts index eeb9c6d..1dcb263 100644 --- a/platform/src/types/metrics.ts +++ b/platform/src/types/metrics.ts @@ -5,11 +5,7 @@ export type CommitOrigin = "HUMAN" | "AI_ASSISTED" | "BOT"; export type ChangeIntent = - | "FEATURE" - | "FIX" - | "REFACTOR" - | "CONFIG" - | "UNKNOWN"; + "FEATURE" | "FIX" | "REFACTOR" | "CONFIG" | "UNKNOWN"; export interface OriginMetrics { churn_events: number; @@ -85,8 +81,11 @@ export interface AcceptanceMetrics { total_commits: number; commits_in_prs: number; pr_rate: number; - single_pass_rate: number; - median_review_rounds: number; + // Omitted by the engine when the PR reviews read degraded (see + // `pr_enrichment_degraded`): a missing value is not zero. Callers must check + // `typeof === "number"` before aggregating. + single_pass_rate?: number; + median_review_rounds?: number; } export interface FunnelStage { @@ -170,6 +169,10 @@ export interface VelocityResult { windows: VelocityWindow[]; } +// Steps of the engine's GitHub PR read that can fail and fall back to partial +// data. Mirrors `DEGRADED_*` in `iris/models/pull_request.py`. +export type PrReadStep = "basic" | "enrichment" | "reviews" | "fetch"; + /** * The complete metrics payload from the CLI. * Matches ReportMetrics.to_dict() output — only non-None fields are present. @@ -281,6 +284,13 @@ export interface ReportMetrics { merge_strategy_dominant_share?: number; // 0.0–1.0 commit_metrics_reliable?: boolean; + // Steps of the engine's GitHub PR read that failed and fell back to partial + // data in this run. Absent when every step succeeded, and on payloads from + // CLI versions before the field existed. When present, PR-derived fields may + // be missing or understated, and `merge_strategy` is "unknown" if + // "enrichment" failed. Read it through `prDegradedSteps` (lib/pr-enrichment). + pr_enrichment_degraded?: PrReadStep[]; + // Repository kind — `NON_CODE` marks a documentation / issue-board repo: // no project manifest is tracked, so nothing is built or deployed from it // and every other metric here describes prose. All of them are still diff --git a/platform/src/types/temporal.ts b/platform/src/types/temporal.ts index b22a079..508a7a4 100644 --- a/platform/src/types/temporal.ts +++ b/platform/src/types/temporal.ts @@ -2,6 +2,8 @@ * Types for temporal intelligence — trends, comparisons, change detection. */ +import type { PrReadStep } from "@/types/metrics"; + /** A single point in a time series (one analysis run). */ export interface TimeSeriesPoint { date: string; // ISO date @@ -34,6 +36,9 @@ export interface RepoSummary { // for this repo are approximate. merge_strategy: string | null; commit_metrics_reliable: boolean | null; + // PR-read steps that fell back to partial data in the latest run (empty when + // none, or when the view has no payload to read it from). + pr_degraded_steps: PrReadStep[]; // Delta vs previous run stabilization_delta: number | null; // Health classification diff --git a/platform/tests/compare-pr-data-marker.test.tsx b/platform/tests/compare-pr-data-marker.test.tsx new file mode 100644 index 0000000..75dea81 --- /dev/null +++ b/platform/tests/compare-pr-data-marker.test.tsx @@ -0,0 +1,105 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { translations } from "@/lib/translations"; +import type { RepoSummary } from "@/types/temporal"; + +// The real hook reads the session; the view only needs `t()` over en-US. +vi.mock("@/hooks/useTranslation", () => ({ + useTranslation: () => ({ + t: (path: string, params?: Record) => { + let value: unknown = translations["en-US"]; + for (const key of path.split(".")) { + value = (value as Record)?.[key]; + } + let text = String(value); + for (const [k, v] of Object.entries(params ?? {})) { + text = text.replace(`{${k}}`, String(v)); + } + return text; + }, + }), +})); + +function repo( + id: string, + name: string, + steps: RepoSummary["pr_degraded_steps"], +): RepoSummary { + return { + id, + name, + remote_url: null, + last_run_at: null, + runs_count: 1, + stabilization_ratio: 0.9, + revert_rate: 0, + churn_events: 1, + commits_total: 10, + ai_detection_coverage_pct: null, + pr_merged_count: 1, + pr_single_pass_rate: null, + fix_latency_median_hours: null, + cascade_rate: null, + merge_strategy: null, + commit_metrics_reliable: null, + pr_degraded_steps: steps, + stabilization_delta: null, + health: "healthy", + sparkline: [], + }; +} + +async function render(): Promise { + const { CompareView } = await import("@/app/[tenant]/compare/compare-view"); + return renderToStaticMarkup( + , + ); +} + +const count = (text: string, part: string) => text.split(part).length - 1; + +describe("CompareView PR-data marker", () => { + // Touch and keyboard users get no hover title, and sighted keyboard users + // on the desktop table neither: both layouts show the steps as text. + it("shows the failed steps as text in the table and on the card", async () => { + const html = await render(); + + expect(count(html, "PR list, reviews")).toBe(2); + }); + + // Each layout reads the full sentence once: the visible steps (and the + // dot) are aria-hidden, and only the sr-only sentence is announced. The + // table and the card are never shown at once, so a reader meets one. + it("announces the full sentence once per layout", async () => { + const html = await render(); + + expect(count(html, '