From caa532eaee900ad287d214192087c60296f9cdff Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Mon, 5 Oct 2026 14:50:46 -0300 Subject: [PATCH 01/18] style(platform): apply prettier to the repo page and metrics types The pre-commit hook runs prettier on every staged TypeScript file, and these two were not in its format, so any change to them dragged unrelated reformatting along. Formatting them in a commit of their own keeps the next commit to the change itself. No behavior change. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- .../app/[tenant]/repos/[repoName]/page.tsx | 33 +++++++------------ platform/src/types/metrics.ts | 6 +--- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/platform/src/app/[tenant]/repos/[repoName]/page.tsx b/platform/src/app/[tenant]/repos/[repoName]/page.tsx index 322ae28..76544cf 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/page.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/page.tsx @@ -35,11 +35,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 +81,25 @@ 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, }; } diff --git a/platform/src/types/metrics.ts b/platform/src/types/metrics.ts index eeb9c6d..891f958 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; From af7de30092bc1e72237697e6fe5b41acac742972 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Mon, 5 Oct 2026 14:52:32 -0300 Subject: [PATCH 02/18] feat(platform): show a badge when a run's PR data is incomplete The engine now reports pr_enrichment_degraded when its PR read fell back to partial data, and the repo page showed such a run like a complete one. The two panels the issue asks to mark are exactly the ones a degraded run does not render: the flow card needs the flow metrics that degradation removes, and the merge badge hides when the strategy is unknown. An amber badge in the page header names the failed steps in its tooltip. prDegradedSteps reads the field defensively, since ingestion stores any payload shape; a Record keyed by PrReadStep makes the compiler require every step in both directions. es-ES falls back to en-US, as it already does for the merge-strategy badge. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- platform/lib/pr-enrichment.ts | 25 +++++++++++++ platform/lib/translations.ts | 10 +++++ .../app/[tenant]/repos/[repoName]/page.tsx | 16 ++++++++ platform/src/types/metrics.ts | 11 ++++++ platform/tests/pr-enrichment.test.ts | 37 +++++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 platform/lib/pr-enrichment.ts create mode 100644 platform/tests/pr-enrichment.test.ts diff --git a/platform/lib/pr-enrichment.ts b/platform/lib/pr-enrichment.ts new file mode 100644 index 0000000..091f2b4 --- /dev/null +++ b/platform/lib/pr-enrichment.ts @@ -0,0 +1,25 @@ +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 []; + return value.filter( + (step): step is PrReadStep => + typeof step === "string" && Object.hasOwn(KNOWN_STEPS, step), + ); +} diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index a81a997..bd97dff 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -618,6 +618,11 @@ 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", + incompleteTooltip: + "This run's PR read failed at: {steps}. Merge strategy is shown as unknown, and flow, review and in-PR metrics may be missing or understated until the next run.", + }, dora: { title: "DORA", subtitle: @@ -2178,6 +2183,11 @@ 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", + incompleteTooltip: + "A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge aparece como desconhecida, e métricas de flow, de review e de commits em PR podem faltar ou sair subestimadas até a próxima execução.", + }, dora: { title: "DORA", subtitle: diff --git a/platform/src/app/[tenant]/repos/[repoName]/page.tsx b/platform/src/app/[tenant]/repos/[repoName]/page.tsx index 76544cf..eaa0128 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 { 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"; @@ -100,6 +101,7 @@ function extractInsights(payload: Record | null) { number | undefined, commitMetricsReliable: payload.commit_metrics_reliable as boolean | undefined, + prDegradedSteps: prDegradedSteps(payload.pr_enrichment_degraded), }; } @@ -163,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, @@ -251,6 +254,19 @@ 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. */} + {prDegraded.length > 0 && ( + + {t("repos.detail.prData.incomplete")} + + )} {repo.remote_url && (

{repo.remote_url}

diff --git a/platform/src/types/metrics.ts b/platform/src/types/metrics.ts index 891f958..cdd9b37 100644 --- a/platform/src/types/metrics.ts +++ b/platform/src/types/metrics.ts @@ -170,6 +170,10 @@ export interface VelocityResult { * The complete metrics payload from the CLI. * Matches ReportMetrics.to_dict() output — only non-None fields are present. */ +// 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"; + export interface ReportMetrics { // Core (always present) commits_total: number; @@ -277,6 +281,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/tests/pr-enrichment.test.ts b/platform/tests/pr-enrichment.test.ts new file mode 100644 index 0000000..686a6cb --- /dev/null +++ b/platform/tests/pr-enrichment.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { prDegradedSteps } from "@/lib/pr-enrichment"; +import { translations } from "@/lib/translations"; + +describe("prDegradedSteps", () => { + it("returns no steps when the payload has no such field", () => { + expect(prDegradedSteps(undefined)).toEqual([]); + }); + + it("returns the steps the engine reported", () => { + expect(prDegradedSteps(["enrichment", "reviews"])).toEqual([ + "enrichment", + "reviews", + ]); + }); + + it("ignores values it does not recognise", () => { + expect(prDegradedSteps(["enrichment", "bogus", 7, null])).toEqual([ + "enrichment", + ]); + }); + + it("ignores a field that is not a list", () => { + expect(prDegradedSteps("enrichment")).toEqual([]); + expect(prDegradedSteps({ enrichment: true })).toEqual([]); + }); +}); + +describe("PR data badge strings", () => { + it.each(["en-US", "pt-BR"] as const)("are defined in %s", (locale) => { + const prData = translations[locale].repos.detail.prData; + + expect(prData.incomplete).toBeTruthy(); + expect(prData.incompleteTooltip).toContain("{steps}"); + }); +}); From 2f859e9bdc1ff2c3827576a09ef5db477831103c Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Mon, 5 Oct 2026 15:07:51 -0300 Subject: [PATCH 03/18] fix(platform): make the PR data badge tooltip accurate The tooltip said merge strategy is shown as unknown whatever step failed, while with only the reviews or basic step failed the page shows the real merge badge beside it. It now says the strategy is not classified when enrichment fails, calls the affected metrics missing or skewed rather than understated, and drops the promise that the next run recovers them. The PrReadStep type moves above the ReportMetrics doc comment, which it had detached from the interface. Refs #236 Assisted-by: Claude Code Co-Authored-By: Claude Sonnet 5.5 --- platform/lib/translations.ts | 4 ++-- platform/src/types/metrics.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index bd97dff..3529284 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -621,7 +621,7 @@ export const translations = { prData: { incomplete: "PR data incomplete", incompleteTooltip: - "This run's PR read failed at: {steps}. Merge strategy is shown as unknown, and flow, review and in-PR metrics may be missing or understated until the next run.", + "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.", }, dora: { title: "DORA", @@ -2186,7 +2186,7 @@ export const translations = { prData: { incomplete: "Dados de PR incompletos", incompleteTooltip: - "A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge aparece como desconhecida, e métricas de flow, de review e de commits em PR podem faltar ou sair subestimadas até a próxima execução.", + "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.", }, dora: { title: "DORA", diff --git a/platform/src/types/metrics.ts b/platform/src/types/metrics.ts index cdd9b37..f08bef7 100644 --- a/platform/src/types/metrics.ts +++ b/platform/src/types/metrics.ts @@ -166,14 +166,14 @@ export interface VelocityResult { windows: VelocityWindow[]; } -/** - * The complete metrics payload from the CLI. - * Matches ReportMetrics.to_dict() output — only non-None fields are present. - */ // 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. + */ export interface ReportMetrics { // Core (always present) commits_total: number; From 24ef60b4afb91c434d4ce75f0073cbc718fbbb69 Mon Sep 17 00:00:00 2001 From: Carlos Penaforte Date: Mon, 5 Oct 2026 15:36:25 -0300 Subject: [PATCH 04/18] 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 05/18] 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 06/18] 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 && ( - +