From 24ef60b4afb91c434d4ce75f0073cbc718fbbb69 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Mon, 5 Oct 2026 15:36:25 -0300 Subject: [PATCH 01/15] fix(platform): tolerate acceptance groups without review metrics When the PR reviews read degrades, the engine omits single_pass_rate and median_review_rounds from each acceptance_by_origin / acceptance_by_tool group. The org PR health and tool comparison aggregations multiplied those fields by a weight, so a missing value would have become NaN and poisoned the whole org-level number. Both fields are now optional and a group is only summed when the values are numbers, so the result equals that of a run without the group. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- platform/lib/queries/org-summary.ts | 24 +++++++++---- platform/lib/queries/tool-comparison.ts | 3 +- platform/src/types/metrics.ts | 7 ++-- platform/tests/org-summary.test.ts | 45 +++++++++++++++++++++++++ platform/tests/tool-comparison.test.ts | 38 +++++++++++++++++++++ 5 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 platform/tests/tool-comparison.test.ts 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/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/src/types/metrics.ts b/platform/src/types/metrics.ts index f08bef7..1dcb263 100644 --- a/platform/src/types/metrics.ts +++ b/platform/src/types/metrics.ts @@ -81,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 { diff --git a/platform/tests/org-summary.test.ts b/platform/tests/org-summary.test.ts index ac240a4..6692af7 100644 --- a/platform/tests/org-summary.test.ts +++ b/platform/tests/org-summary.test.ts @@ -618,3 +618,48 @@ describe("computeHyperEngineers — dedupes the same person across name variants expect(result[0].github).toBeUndefined(); }); }); + +describe("acceptance groups without review metrics (engine omits them when reviews degraded)", () => { + const group = (extra: Record) => ({ + total_commits: 10, + commits_in_prs: 10, + pr_rate: 1, + ...extra, + }); + + it("computePRHealth ignores a group lacking single_pass_rate/median_review_rounds, with no NaN", () => { + const repos = [ + repo({ id: "r1", pr_merged_count: 5 }), + repo({ id: "r2", pr_merged_count: 5 }), + ]; + const full = payload({ + acceptance_by_origin: { + HUMAN: group({ single_pass_rate: 0.8, median_review_rounds: 2 }), + AI_ASSISTED: group({ single_pass_rate: 0.5, median_review_rounds: 1 }), + BOT: group({}), + }, + }); + const degraded = payload({ + acceptance_by_origin: { + HUMAN: group({}), + AI_ASSISTED: group({}), + BOT: group({}), + }, + }); + + const withDegraded = computePRHealth( + repos, + new Map([ + ["r1", full], + ["r2", degraded], + ]), + ); + const without = computePRHealth(repos, new Map([["r1", full]])); + + expect(withDegraded!.byOrigin).toEqual(without!.byOrigin); + expect(withDegraded!.byOrigin.human!.singlePassRate).toBe(0.8); + expect(Number.isNaN(withDegraded!.byOrigin.ai!.medianReviewRounds)).toBe( + false, + ); + }); +}); diff --git a/platform/tests/tool-comparison.test.ts b/platform/tests/tool-comparison.test.ts new file mode 100644 index 0000000..cdcceb2 --- /dev/null +++ b/platform/tests/tool-comparison.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { computeToolComparison } from "@/lib/queries/tool-comparison"; +import type { ReportMetrics } from "@/types/metrics"; + +const payload = (acceptance: Record) => + ({ + acceptance_by_tool: acceptance, + }) as unknown as ReportMetrics; + +describe("computeToolComparison — acceptance groups without review metrics", () => { + it("does not turn a missing single_pass_rate into NaN, and matches a run without that group", () => { + const full = { + total_commits: 50, + commits_in_prs: 50, + pr_rate: 1, + single_pass_rate: 0.6, + median_review_rounds: 1, + }; + const degraded = { total_commits: 50, commits_in_prs: 50, pr_rate: 1 }; + + const withDegraded = computeToolComparison( + new Map([ + ["r1", payload({ claude: full })], + ["r2", payload({ claude: degraded })], + ]), + ); + const without = computeToolComparison( + new Map([["r1", payload({ claude: full })]]), + ); + + const a = withDegraded!.rows[0]; + const b = without!.rows[0]; + expect(a.singlePassRate).toBe(0.6); + expect(Number.isNaN(a.singlePassRate)).toBe(false); + expect(a.singlePassRate).toBe(b.singlePassRate); + }); +}); From 0728f5cddbf1fcb13a0e9a4831bb7867a7b1da22 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Mon, 5 Oct 2026 15:36:34 -0300 Subject: [PATCH 02/15] feat(platform): mark repos with incomplete PR data on compare and dashboard The badge on the repo page was the only place a degraded PR read showed; the compare table and the org dashboard rendered such a run like a complete one. The compare table now shows an amber marker (with the same tooltip text, also as screen-reader text) per repo, read through prDegradedSteps from the repo_metric_summaries payload. The select falls back to the pre-023 column list when the view has no payload, so the table stays populated, just unmarked. The dashboard shows an amber note above the PR-driven panels listing the affected repos, computed from the payloads it already loads. es-ES falls back to en-US. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- platform/lib/pr-enrichment.ts | 26 ++++++ platform/lib/queries/temporal.ts | 49 ++++++++--- platform/lib/translations.ts | 4 + .../src/app/[tenant]/compare/compare-view.tsx | 19 ++++ platform/src/app/[tenant]/dashboard/page.tsx | 7 ++ .../dashboard/panels/PRDataNotice.tsx | 35 ++++++++ platform/src/types/temporal.ts | 5 ++ platform/tests/compare-pr-degraded.test.ts | 88 +++++++++++++++++++ platform/tests/org-summary.test.ts | 1 + platform/tests/pr-degraded-repos.test.ts | 43 +++++++++ 10 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 platform/src/app/[tenant]/dashboard/panels/PRDataNotice.tsx create mode 100644 platform/tests/compare-pr-degraded.test.ts create mode 100644 platform/tests/pr-degraded-repos.test.ts diff --git a/platform/lib/pr-enrichment.ts b/platform/lib/pr-enrichment.ts index 091f2b4..bcae12b 100644 --- a/platform/lib/pr-enrichment.ts +++ b/platform/lib/pr-enrichment.ts @@ -23,3 +23,29 @@ export function prDegradedSteps(value: unknown): PrReadStep[] { typeof step === "string" && Object.hasOwn(KNOWN_STEPS, step), ); } + +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/temporal.ts b/platform/lib/queries/temporal.ts index f498a47..defc052 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,13 @@ 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; + +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 @@ -233,19 +241,35 @@ 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); + // The column list is built at runtime, so supabase-js can't infer the row. + const readSummaries = async (columns: string) => + (await supabase + .from("repo_metric_summaries") + .select(columns) + .eq("organization_id", organizationId) + .eq("window_days", windowDays)) as unknown as { + data: SummaryRow[] | null; + 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, + ); + ({ 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) => { @@ -282,6 +306,7 @@ 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), stabilization_delta: delta, health: classifyHealth(stabilization), sparkline, diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 3529284..7c8ebb8 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -267,6 +267,8 @@ export const translations = { member: "Member", }, dashboard: { + 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", @@ -1831,6 +1833,8 @@ export const translations = { member: "Membro", }, dashboard: { + prDataIncomplete: + "Os dados de PR estão incompletos no run 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", diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx index a0b2be1..67ad5db 100644 --- a/platform/src/app/[tenant]/compare/compare-view.tsx +++ b/platform/src/app/[tenant]/compare/compare-view.tsx @@ -219,6 +219,13 @@ function MergeStrategyCell({ 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), so it doesn't hinge on colour or on + // the `title` tooltip. + const prDataTooltip = (repo: RepoSummary) => + t("repos.detail.prData.incompleteTooltip", { + steps: repo.pr_degraded_steps.join(", "), + }); const [sort, setSort] = useState({ key: "stabilization_ratio", dir: "desc", @@ -411,6 +418,18 @@ export function CompareView({ repos }: CompareViewProps) { {repo.name} + {repo.pr_degraded_steps.length > 0 && ( + + + )} + {/* 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..6d115f3 --- /dev/null +++ b/platform/src/app/[tenant]/dashboard/panels/PRDataNotice.tsx @@ -0,0 +1,35 @@ +import { + loadPayloads, + loadRepoSummaries, + type DashboardPanelProps, +} from "../data"; + +import { degradedReposFromPayloads } 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: degraded.map((r) => r.name).join(", "), + })} +

+ ); +} 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-degraded.test.ts b/platform/tests/compare-pr-degraded.test.ts new file mode 100644 index 0000000..f857727 --- /dev/null +++ b/platform/tests/compare-pr-degraded.test.ts @@ -0,0 +1,88 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { describe, expect, it, vi } from "vitest"; + +import { getOrgReposSummary } from "@/lib/queries/temporal"; + +type Result = { data: unknown[] | null; error: { message: string } | null }; + +// Minimal chainable stand-in for the supabase query builder: every filter +// returns the builder, awaiting it yields the canned result. +function builder(result: Result) { + const b: Record = {}; + for (const m of ["select", "eq", "order", "in"]) b[m] = () => b; + b.then = (resolve: (r: Result) => unknown) => resolve(result); + return b; +} + +function fakeClient( + summaries: (columns: string) => Result, + selects: string[] = [], +) { + return { + from: (table: string) => { + if (table === "repositories") { + return builder({ + data: [{ id: "r1", name: "alpha", remote_url: null }], + error: null, + }); + } + const b = builder({ data: [], error: null }); + b.select = (columns: string) => { + selects.push(columns); + return builder(summaries(columns)); + }; + return b; + }, + } as unknown as SupabaseClient; +} + +const row = (extra: Record = {}) => ({ + repository_id: "r1", + runs_count: 1, + last_run_at: "2026-08-01T00:00:00Z", + recent_stabilization: [], + ...extra, +}); + +describe("getOrgReposSummary — PR data degradation", () => { + it("maps pr_enrichment_degraded to the known steps", async () => { + const client = fakeClient(() => ({ + data: [row({ pr_enrichment_degraded: ["reviews", "bogus"] })], + error: null, + })); + + const [repo] = await getOrgReposSummary(client, "org", 30); + 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); + expect(repo.pr_degraded_steps).toEqual([]); + } + }); + + it("keeps the table populated, unmarked, when the view lacks payload", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const selects: string[] = []; + const client = fakeClient( + (columns) => + columns.includes("payload") + ? { + data: null, + error: { message: 'column "payload" does not exist' }, + } + : { data: [row()], error: null }, + selects, + ); + + const repos = await getOrgReposSummary(client, "org", 30); + error.mockRestore(); + + expect(repos).toHaveLength(1); + expect(repos[0].runs_count).toBe(1); + expect(repos[0].pr_degraded_steps).toEqual([]); + expect(selects).toHaveLength(2); + }); +}); diff --git a/platform/tests/org-summary.test.ts b/platform/tests/org-summary.test.ts index 6692af7..49c1e0e 100644 --- a/platform/tests/org-summary.test.ts +++ b/platform/tests/org-summary.test.ts @@ -30,6 +30,7 @@ function repo(over: Partial): RepoSummary { cascade_rate: null, merge_strategy: null, commit_metrics_reliable: null, + pr_degraded_steps: [], stabilization_delta: null, health: "unknown", sparkline: [], diff --git a/platform/tests/pr-degraded-repos.test.ts b/platform/tests/pr-degraded-repos.test.ts new file mode 100644 index 0000000..39c8a4d --- /dev/null +++ b/platform/tests/pr-degraded-repos.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { degradedReposFromPayloads } from "@/lib/pr-enrichment"; +import { translations } from "@/lib/translations"; + +describe("degradedReposFromPayloads", () => { + const repos = [ + { id: "a", name: "alpha" }, + { id: "b", name: "beta" }, + { id: "c", name: "gamma" }, + ]; + + it("lists only the repos whose latest payload reports degraded steps", () => { + const payloads = new Map([ + ["a", { pr_enrichment_degraded: ["reviews"] }], + ["b", {}], + ["c", { pr_enrichment_degraded: ["basic", "fetch"] }], + ]); + + expect(degradedReposFromPayloads(repos, payloads)).toEqual([ + { id: "a", name: "alpha", steps: ["reviews"] }, + { id: "c", name: "gamma", steps: ["basic", "fetch"] }, + ]); + }); + + it("ignores garbage, empty lists and repos without a payload", () => { + const payloads = new Map([ + ["a", { pr_enrichment_degraded: "reviews" }], + ["b", { pr_enrichment_degraded: [] }], + ]); + + expect(degradedReposFromPayloads(repos, payloads)).toEqual([]); + }); +}); + +describe("dashboard PR data note strings", () => { + it.each(["en-US", "pt-BR"] as const)("are defined in %s", (locale) => { + const text = translations[locale].dashboard.prDataIncomplete; + + expect(text).toContain("{count}"); + expect(text).toContain("{repos}"); + }); +}); From 237e3b51af9327d00d25db49906202084bbfe127 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Mon, 5 Oct 2026 15:55:50 -0300 Subject: [PATCH 03/15] fix(platform): explain the compare marker and fix pt-BR wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amber dot on the compare table only had a `title` on a non-focusable span, so keyboard and touch users could not learn what it meant. When at least one visible row is marked, a one-line legend under the table now shows the dot and the existing "PR data incomplete" text. The per-row `title` is dropped since the sr-only text already carries the detail. The pt-BR dashboard note said "no run mais recente"; every other pt-BR string says "execução". Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- platform/lib/pr-enrichment.ts | 7 +++++++ platform/lib/translations.ts | 2 +- .../src/app/[tenant]/compare/compare-view.tsx | 19 +++++++++++++------ platform/tests/pr-degraded-repos.test.ts | 18 +++++++++++++++++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/platform/lib/pr-enrichment.ts b/platform/lib/pr-enrichment.ts index bcae12b..f8157a2 100644 --- a/platform/lib/pr-enrichment.ts +++ b/platform/lib/pr-enrichment.ts @@ -49,3 +49,10 @@ 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 7c8ebb8..36047ce 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -1834,7 +1834,7 @@ export const translations = { }, dashboard: { prDataIncomplete: - "Os dados de PR estão incompletos no run mais recente de {count} repositório(s) ({repos}); as métricas de PR abaixo podem faltar ou sair distorcidas.", + "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", diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx index 67ad5db..07c7df9 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 { anyPrDataIncomplete } from "@/lib/pr-enrichment"; import { cn } from "@/lib/utils"; import type { RepoSummary } from "@/types/temporal"; import { healthIndicator } from "@/types/temporal"; @@ -220,8 +221,8 @@ function MergeStrategyCell({ 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), so it doesn't hinge on colour or on - // the `title` tooltip. + // 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: repo.pr_degraded_steps.join(", "), @@ -419,10 +420,7 @@ export function CompareView({ repos }: CompareViewProps) { {repo.name} {repo.pr_degraded_steps.length > 0 && ( - +