From 97df095d7612f4f197c4dc2b730fa62a5ed760ee Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Wed, 7 Oct 2026 20:21:37 -0300 Subject: [PATCH 01/12] fix(platform): make the PR-degraded select opt-in, narrow fallback Selecting payload->pr_enrichment_degraded makes repo_metric_summaries aggregate every run's payload, and it was requested by all callers (repos, ai-exposure, dashboard, compare). It is now an option that only the compare page turns on; other callers get pr_degraded_steps: []. The retry without the field now happens only when the error says the column is missing (code 42703, or a message naming payload), so a timeout is logged and surfaced instead of being silently masked. SummaryRow becomes an interface of the selected columns instead of Record. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- platform/lib/queries/temporal.ts | 82 +++++++++++++++++----- platform/src/app/[tenant]/compare/page.tsx | 4 +- platform/tests/compare-pr-degraded.test.ts | 77 ++++++++++++++++++-- 3 files changed, 141 insertions(+), 22 deletions(-) diff --git a/platform/lib/queries/temporal.ts b/platform/lib/queries/temporal.ts index defc052..4e7582b 100644 --- a/platform/lib/queries/temporal.ts +++ b/platform/lib/queries/temporal.ts @@ -17,9 +17,30 @@ import { classifyHealth } from "@/types/temporal"; const SPARKLINE_POINTS = 12; -// Loosely typed on purpose: this mirrors the view's columns (see -// SUMMARY_COLUMNS) as the untyped client used to return them. -type SummaryRow = Record; +// 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"; @@ -212,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: @@ -224,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 @@ -252,18 +290,26 @@ export async function getOrgReposSummary( error: PostgrestError | null; }; - // Selecting one key out of `payload` marks degraded PR reads without - // shipping whole payloads. The view only has `payload` since migration 023; - // when the select fails (older database) retry without it so the table stays - // populated, just unmarked. - let { data: summaries, error: summariesError } = await readSummaries( - `${SUMMARY_COLUMNS}, pr_enrichment_degraded:payload->pr_enrichment_degraded`, - ); - if (summariesError) { - logQueryError( - "getOrgReposSummary (repo_metric_summaries with payload)", - summariesError, - ); + 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)); } @@ -287,7 +333,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, @@ -306,7 +352,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: prDegradedSteps(s?.pr_enrichment_degraded), + pr_degraded_steps: options.includePrDegraded + ? prDegradedSteps(s?.pr_enrichment_degraded) + : [], stabilization_delta: delta, health: classifyHealth(stabilization), sparkline, 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/tests/compare-pr-degraded.test.ts b/platform/tests/compare-pr-degraded.test.ts index f857727..2e38b40 100644 --- a/platform/tests/compare-pr-degraded.test.ts +++ b/platform/tests/compare-pr-degraded.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it, vi } from "vitest"; import { getOrgReposSummary } from "@/lib/queries/temporal"; -type Result = { data: unknown[] | null; error: { message: string } | null }; +type Result = { + data: unknown[] | null; + error: { message: string; code?: string } | null; +}; + +const withPayload = { includePrDegraded: true }; // Minimal chainable stand-in for the supabase query builder: every filter // returns the builder, awaiting it yields the canned result. @@ -51,14 +56,14 @@ describe("getOrgReposSummary — PR data degradation", () => { error: null, })); - const [repo] = await getOrgReposSummary(client, "org", 30); + const [repo] = await getOrgReposSummary(client, "org", 30, withPayload); expect(repo.pr_degraded_steps).toEqual(["reviews"]); }); it("leaves absent or garbage values unmarked", async () => { for (const extra of [{}, { pr_enrichment_degraded: "reviews" }]) { const client = fakeClient(() => ({ data: [row(extra)], error: null })); - const [repo] = await getOrgReposSummary(client, "org", 30); + const [repo] = await getOrgReposSummary(client, "org", 30, withPayload); expect(repo.pr_degraded_steps).toEqual([]); } }); @@ -77,7 +82,7 @@ describe("getOrgReposSummary — PR data degradation", () => { selects, ); - const repos = await getOrgReposSummary(client, "org", 30); + const repos = await getOrgReposSummary(client, "org", 30, withPayload); error.mockRestore(); expect(repos).toHaveLength(1); @@ -85,4 +90,68 @@ describe("getOrgReposSummary — PR data degradation", () => { expect(repos[0].pr_degraded_steps).toEqual([]); expect(selects).toHaveLength(2); }); + + it("does not select the payload key unless asked", async () => { + const selects: string[] = []; + const client = fakeClient( + () => ({ + data: [row({ pr_enrichment_degraded: ["reviews"] })], + error: null, + }), + selects, + ); + + const [repo] = await getOrgReposSummary(client, "org", 30); + + expect(selects).toHaveLength(1); + expect(selects[0]).not.toContain("payload"); + expect(repo.pr_degraded_steps).toEqual([]); + }); + + it("retries without payload on a PostgREST missing-column error", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const selects: string[] = []; + const client = fakeClient( + (columns) => + columns.includes("payload") + ? { + data: null, + error: { + code: "42703", + message: "column repo_metric_summaries.foo does not exist", + }, + } + : { data: [row()], error: null }, + selects, + ); + + const repos = await getOrgReposSummary(client, "org", 30, withPayload); + error.mockRestore(); + + expect(selects).toHaveLength(2); + expect(repos[0].runs_count).toBe(1); + }); + + it("does not retry, and logs, on an unrelated error such as a timeout", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const selects: string[] = []; + const client = fakeClient( + () => ({ + data: null, + error: { + code: "57014", + message: "canceling statement due to statement timeout", + }, + }), + selects, + ); + + const repos = await getOrgReposSummary(client, "org", 30, withPayload); + const logged = error.mock.calls.length; + error.mockRestore(); + + expect(selects).toHaveLength(1); + expect(logged).toBeGreaterThan(0); + expect(repos[0].runs_count).toBe(0); + }); }); From 403e8b72b3b405e3b387e9fb3f4112ffe5948125 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Wed, 7 Oct 2026 20:22:37 -0300 Subject: [PATCH 02/12] fix(platform): localize PR steps, dedupe them, cap the repo list Degraded steps showed as raw ids ("enrichment") in the repo badge and the compare marker. They now go through a localized label (repos.detail.prData.steps.*, en-US and pt-BR; es-ES falls back) via formatPrSteps. prDegradedSteps drops repeated steps, so a payload listing one twice no longer prints it twice. The dashboard note listed every degraded repo; it now names at most 5 and collapses the rest into "and N more" / "e mais N". anyPrDataIncomplete was a one-line wrapper; the compare view checks inline instead. The repo page comment claimed the flow and merge panels are absent exactly when the read degraded, which is false when only "basic" failed. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- platform/lib/pr-enrichment.ts | 43 ++++++++++--- platform/lib/translations.ts | 14 ++++ .../src/app/[tenant]/compare/compare-view.tsx | 6 +- .../dashboard/panels/PRDataNotice.tsx | 10 ++- .../app/[tenant]/repos/[repoName]/page.tsx | 8 +-- platform/tests/pr-degraded-repos.test.ts | 18 +----- platform/tests/pr-enrichment.test.ts | 64 ++++++++++++++++++- 7 files changed, 128 insertions(+), 35 deletions(-) diff --git a/platform/lib/pr-enrichment.ts b/platform/lib/pr-enrichment.ts index f8157a2..b51b24c 100644 --- a/platform/lib/pr-enrichment.ts +++ b/platform/lib/pr-enrichment.ts @@ -18,10 +18,44 @@ const KNOWN_STEPS: Record = { */ export function prDegradedSteps(value: unknown): PrReadStep[] { if (!Array.isArray(value)) return []; - return value.filter( + 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 { @@ -49,10 +83,3 @@ export function degradedReposFromPayloads( } return degraded; } - -/** Whether any row of a table is marked as having a partial PR read. */ -export function anyPrDataIncomplete( - rows: ReadonlyArray<{ pr_degraded_steps: readonly string[] }>, -): boolean { - return rows.some((row) => row.pr_degraded_steps.length > 0); -} diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 36047ce..3eb23ea 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -267,6 +267,7 @@ 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: { @@ -622,6 +623,12 @@ export const translations = { }, prData: { incomplete: "PR data incomplete", + steps: { + basic: "PR list", + enrichment: "commit enrichment", + reviews: "reviews", + fetch: "PR read", + }, incompleteTooltip: "This run's PR read failed at: {steps}. Merge strategy isn't classified when enrichment fails, and flow, review and in-PR metrics may be missing or skewed.", }, @@ -1833,6 +1840,7 @@ 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: { @@ -2189,6 +2197,12 @@ export const translations = { }, prData: { incomplete: "Dados de PR incompletos", + steps: { + basic: "listagem de PRs", + enrichment: "enriquecimento de commits", + reviews: "reviews", + fetch: "leitura de PRs", + }, incompleteTooltip: "A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge não é classificada quando o enriquecimento falha, e métricas de flow, de review e de commits em PR podem faltar ou sair distorcidas.", }, diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx index 07c7df9..a54f903 100644 --- a/platform/src/app/[tenant]/compare/compare-view.tsx +++ b/platform/src/app/[tenant]/compare/compare-view.tsx @@ -7,7 +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 { anyPrDataIncomplete } from "@/lib/pr-enrichment"; +import { formatPrSteps } from "@/lib/pr-enrichment"; import { cn } from "@/lib/utils"; import type { RepoSummary } from "@/types/temporal"; import { healthIndicator } from "@/types/temporal"; @@ -225,7 +225,7 @@ export function CompareView({ repos }: CompareViewProps) { // what the dot means, so it doesn't hinge on colour or on a hover tooltip. const prDataTooltip = (repo: RepoSummary) => t("repos.detail.prData.incompleteTooltip", { - steps: repo.pr_degraded_steps.join(", "), + steps: formatPrSteps(repo.pr_degraded_steps, t), }); const [sort, setSort] = useState({ key: "stabilization_ratio", @@ -481,7 +481,7 @@ export function CompareView({ repos }: CompareViewProps) { })} - {anyPrDataIncomplete(sorted) && ( + {sorted.some((repo) => repo.pr_degraded_steps.length > 0) && (

); diff --git a/platform/src/app/[tenant]/repos/[repoName]/page.tsx b/platform/src/app/[tenant]/repos/[repoName]/page.tsx index eaa0128..e104e21 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/page.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/page.tsx @@ -13,7 +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 { prDegradedSteps } from "@/lib/pr-enrichment"; +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"; @@ -254,14 +254,14 @@ export default async function RepoDetailPage({ {insights.mergeStrategy} ))} - {/* In the header, not in the flow or merge panels: both are absent - exactly when the read degraded. */} + {/* In the header, not in the flow or merge panels: those can + disappear when the read degrades, which would hide the cause. */} {prDegraded.length > 0 && ( {t("repos.detail.prData.incomplete")} diff --git a/platform/tests/pr-degraded-repos.test.ts b/platform/tests/pr-degraded-repos.test.ts index de81dbc..39c8a4d 100644 --- a/platform/tests/pr-degraded-repos.test.ts +++ b/platform/tests/pr-degraded-repos.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - anyPrDataIncomplete, - degradedReposFromPayloads, -} from "@/lib/pr-enrichment"; +import { degradedReposFromPayloads } from "@/lib/pr-enrichment"; import { translations } from "@/lib/translations"; describe("degradedReposFromPayloads", () => { @@ -44,16 +41,3 @@ describe("dashboard PR data note strings", () => { expect(text).toContain("{repos}"); }); }); - -describe("anyPrDataIncomplete", () => { - it("is true only when at least one row is marked", () => { - expect(anyPrDataIncomplete([])).toBe(false); - expect(anyPrDataIncomplete([{ pr_degraded_steps: [] }])).toBe(false); - expect( - anyPrDataIncomplete([ - { pr_degraded_steps: [] }, - { pr_degraded_steps: ["reviews"] }, - ]), - ).toBe(true); - }); -}); diff --git a/platform/tests/pr-enrichment.test.ts b/platform/tests/pr-enrichment.test.ts index 686a6cb..4a75224 100644 --- a/platform/tests/pr-enrichment.test.ts +++ b/platform/tests/pr-enrichment.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { prDegradedSteps } from "@/lib/pr-enrichment"; +import { + formatPrSteps, + formatRepoNames, + prDegradedSteps, +} from "@/lib/pr-enrichment"; import { translations } from "@/lib/translations"; describe("prDegradedSteps", () => { @@ -35,3 +39,61 @@ describe("PR data badge strings", () => { expect(prData.incompleteTooltip).toContain("{steps}"); }); }); + +describe("prDegradedSteps duplicates", () => { + it("drops repeated steps, keeping the first occurrence's order", () => { + expect( + prDegradedSteps(["reviews", "basic", "reviews", "basic", "fetch"]), + ).toEqual(["reviews", "basic", "fetch"]); + }); +}); + +// A `t()` over the real catalogue, as the app resolves it (no es-ES needed). +function translator(locale: "en-US" | "pt-BR") { + return (path: string, params?: Record) => { + let value: unknown = translations[locale]; + 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; + }; +} + +describe("formatPrSteps", () => { + it("lists localized step labels, comma separated", () => { + const steps = ["basic", "enrichment", "reviews", "fetch"] as const; + expect(formatPrSteps(steps, translator("en-US"))).toBe( + "PR list, commit enrichment, reviews, PR read", + ); + expect(formatPrSteps(["basic", "fetch"], translator("pt-BR"))).toBe( + "listagem de PRs, leitura de PRs", + ); + }); + + it("is empty for no steps", () => { + expect(formatPrSteps([], translator("en-US"))).toBe(""); + }); +}); + +describe("formatRepoNames", () => { + const names = ["a", "b", "c", "d", "e", "f", "g"]; + + it("lists every name up to the cap", () => { + expect(formatRepoNames(names.slice(0, 5), translator("en-US"))).toBe( + "a, b, c, d, e", + ); + }); + + it("collapses the rest into a localized count", () => { + expect(formatRepoNames(names, translator("en-US"))).toBe( + "a, b, c, d, e and 2 more", + ); + expect(formatRepoNames(names, translator("pt-BR"))).toBe( + "a, b, c, d, e e mais 2", + ); + }); +}); From d9c6a38f8861b92b4fe65a3e1e4e156d065d73aa Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Wed, 7 Oct 2026 20:23:20 -0300 Subject: [PATCH 03/12] fix(platform): show the PR-data marker on mobile compare cards The amber marker and its legend only existed in the desktop table, so the md:hidden card stack gave no sign a repo's PR read degraded. The marker is now one component used by both layouts, and the legend sits below both so either one explains it. The dot gains a title (hover for sighted users) next to its sr-only text; the legend keeps the meaning off colour and off the tooltip. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- .../src/app/[tenant]/compare/compare-view.tsx | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx index a54f903..dbd3ab6 100644 --- a/platform/src/app/[tenant]/compare/compare-view.tsx +++ b/platform/src/app/[tenant]/compare/compare-view.tsx @@ -218,11 +218,28 @@ function MergeStrategyCell({ ); } +/** + * Amber dot next to a repo whose latest run read PR data only partially. The + * dot is decorative (hover title for sighted users); the step list is also in + * the DOM as screen-reader text, and a legend under the list says what the dot + * means, so nothing hinges on colour or on a tooltip. + */ +function PrDataMarker({ repo, label }: { repo: RepoSummary; label: string }) { + if (repo.pr_degraded_steps.length === 0) return null; + return ( + + + ); +} + export function CompareView({ repos }: CompareViewProps) { const { t } = useTranslation(); - // Amber dot next to a repo whose latest run read PR data only partially. - // The text is also in the DOM (sr-only), and a legend under the table says - // what the dot means, so it doesn't hinge on colour or on a hover tooltip. const prDataTooltip = (repo: RepoSummary) => t("repos.detail.prData.incompleteTooltip", { steps: formatPrSteps(repo.pr_degraded_steps, t), @@ -419,15 +436,7 @@ export function CompareView({ repos }: CompareViewProps) { {repo.name} - {repo.pr_degraded_steps.length > 0 && ( - - - )} + - {sorted.some((repo) => repo.pr_degraded_steps.length > 0) && ( -

-

- )} {/* Mobile: sort control + card stack */} @@ -546,6 +546,7 @@ export function CompareView({ repos }: CompareViewProps) { {repo.name} + + + {sorted.some((repo) => repo.pr_degraded_steps.length > 0) && ( +

+

+ )} ); From f52697551f73bd77618c619df57d9e12b6f91841 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Wed, 7 Oct 2026 21:00:40 -0300 Subject: [PATCH 04/12] fix(platform): raise the PR-data dot contrast and fix copy The amber dot is the only per-row cue on the compare page, and amber-500 sits near 2.1:1 on the light card, below the 3:1 WCAG 1.4.11 asks of it. The dot and its legend now use amber-600 on light (about 3.2:1) and keep amber-500 on dark (well above 3:1). The PR-data tooltip said merge strategy isn't classified "when enrichment fails"; the engine only skips it when enrichment fails for merged PRs, so the copy now says so, in both locales. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- platform/lib/translations.ts | 4 ++-- platform/src/app/[tenant]/compare/compare-view.tsx | 8 +++++--- platform/tests/pr-enrichment.test.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 3eb23ea..2cfcac2 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -630,7 +630,7 @@ export const translations = { fetch: "PR read", }, incompleteTooltip: - "This run's PR read failed at: {steps}. Merge strategy isn't classified when enrichment fails, and flow, review and in-PR metrics may be missing or skewed.", + "This run's PR read failed at: {steps}. Merge strategy isn't classified when enrichment fails for merged PRs, and flow, review and in-PR metrics may be missing or skewed.", }, dora: { title: "DORA", @@ -2204,7 +2204,7 @@ export const translations = { fetch: "leitura de PRs", }, incompleteTooltip: - "A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge não é classificada quando o enriquecimento falha, e métricas de flow, de review e de commits em PR podem faltar ou sair distorcidas.", + "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 flow, de review e de commits em PR podem faltar ou sair distorcidas.", }, dora: { title: "DORA", diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx index dbd3ab6..02a16d8 100644 --- a/platform/src/app/[tenant]/compare/compare-view.tsx +++ b/platform/src/app/[tenant]/compare/compare-view.tsx @@ -222,7 +222,9 @@ function MergeStrategyCell({ * Amber dot next to a repo whose latest run read PR data only partially. The * dot is decorative (hover title for sighted users); the step list is also in * the DOM as screen-reader text, and a legend under the list says what the dot - * means, so nothing hinges on colour or on a tooltip. + * means, so nothing hinges on colour or on a tooltip. Amber 600 on light and + * 500 on dark keep the dot, the only per-row cue, above 3:1 against the card + * (WCAG 1.4.11). */ function PrDataMarker({ repo, label }: { repo: RepoSummary; label: string }) { if (repo.pr_degraded_steps.length === 0) return null; @@ -231,7 +233,7 @@ function PrDataMarker({ repo, label }: { repo: RepoSummary; label: string }) { @@ -633,7 +635,7 @@ export function CompareView({ repos }: CompareViewProps) {

diff --git a/platform/tests/pr-enrichment.test.ts b/platform/tests/pr-enrichment.test.ts index 4a75224..20898f7 100644 --- a/platform/tests/pr-enrichment.test.ts +++ b/platform/tests/pr-enrichment.test.ts @@ -38,6 +38,17 @@ describe("PR data badge strings", () => { expect(prData.incomplete).toBeTruthy(); expect(prData.incompleteTooltip).toContain("{steps}"); }); + + // Enrichment failing for open or closed PRs leaves the merge strategy + // classified; only merged PRs feed it. + it("ties the unclassified merge strategy to merged PRs", () => { + expect( + translations["en-US"].repos.detail.prData.incompleteTooltip, + ).toContain("when enrichment fails for merged PRs"); + expect( + translations["pt-BR"].repos.detail.prData.incompleteTooltip, + ).toContain("quando o enriquecimento dos PRs mergeados falha"); + }); }); describe("prDegradedSteps duplicates", () => { From 6135669e203a96a4ff660356262a3abd4abea503 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Wed, 7 Oct 2026 21:02:21 -0300 Subject: [PATCH 05/12] fix(platform): show failed PR steps as text on touch and keyboard The failed PR-read steps lived only in a hover title and screen-reader text, on the compare dot and on the repo-page badge, so touch and keyboard users could see that the read was incomplete but not where. The mobile compare card now shows the localized steps as muted text next to the dot, and the repo page shows them next to the badge. On the card the visible steps replace the dot's sr-only sentence, so screen readers read them once, after a short sr-only "PR data incomplete:" prefix. The card marker also drops its ml-2: the row already spaces its items with gap-2. The desktop table is unchanged. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Opus 5.5 --- .../src/app/[tenant]/compare/compare-view.tsx | 58 +++++++++++++++---- .../app/[tenant]/repos/[repoName]/page.tsx | 27 +++++---- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx index 02a16d8..93224e5 100644 --- a/platform/src/app/[tenant]/compare/compare-view.tsx +++ b/platform/src/app/[tenant]/compare/compare-view.tsx @@ -220,21 +220,48 @@ function MergeStrategyCell({ /** * Amber dot next to a repo whose latest run read PR data only partially. The - * dot is decorative (hover title for sighted users); the step list is also in - * the DOM as screen-reader text, and a legend under the list says what the dot - * means, so nothing hinges on colour or on a tooltip. Amber 600 on light and - * 500 on dark keep the dot, the only per-row cue, above 3:1 against the card - * (WCAG 1.4.11). + * dot is decorative (hover title for sighted users), and a legend under the + * list says what it means, so nothing hinges on colour or on a tooltip. Amber + * 600 on light and 500 on dark keep the dot, the only per-row cue, above 3:1 + * against the card (WCAG 1.4.11). + * + * In the table the failed steps are screen-reader text. The mobile card shows + * them as text instead (`visible`), since touch and keyboard users get no + * hover; screen readers then read that text once, after a short sr-only + * prefix, rather than the full label on top of it. */ -function PrDataMarker({ repo, label }: { repo: RepoSummary; label: string }) { +function PrDataMarker({ + repo, + label, + visible, +}: { + repo: RepoSummary; + label: string; + visible?: { prefix: string; steps: string }; +}) { if (repo.pr_degraded_steps.length === 0) return null; + const dot = ( +