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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions platform/lib/pr-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,44 @@ const KNOWN_STEPS: Record<PrReadStep, true> = {
*/
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, string | number>,
) => 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 {
Expand Down Expand Up @@ -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);
}
94 changes: 72 additions & 22 deletions platform/lib/queries/temporal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
// 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<number | null> | 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";
Expand Down Expand Up @@ -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:
Expand All @@ -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<RepoSummary[]> {
// Query 1: all repos
const { data: repos, error: reposError } = await supabase
Expand All @@ -241,29 +279,39 @@ export async function getOrgReposSummary(
if (!repos || repos.length === 0) return [];

// Query 2: pre-aggregated summary, one row per repo (see doc comment).
// The column list is built at runtime, so supabase-js can't infer the row.
const readSummaries = async (columns: string) =>
(await supabase
const readSummaries = async (columns: string) => {
const result = await supabase
.from("repo_metric_summaries")
.select(columns)
.eq("organization_id", organizationId)
.eq("window_days", windowDays)) as unknown as {
.eq("window_days", windowDays);
// The column list is built at runtime, so supabase-js can't infer the row.
return result as unknown as {
data: SummaryRow[] | null;
error: PostgrestError | null;
};

// 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));
}
Expand All @@ -287,7 +335,7 @@ export async function getOrgReposSummary(
const sparkline = (s?.recent_stabilization ?? [])
.slice(0, SPARKLINE_POINTS)
.reverse()
.filter((v: number | null): v is number => v !== null);
.filter((v): v is number => v !== null);

return {
id: repo.id,
Expand All @@ -306,7 +354,9 @@ export async function getOrgReposSummary(
cascade_rate: s?.cascade_rate ?? null,
merge_strategy: s?.merge_strategy ?? null,
commit_metrics_reliable: s?.commit_metrics_reliable ?? null,
pr_degraded_steps: prDegradedSteps(s?.pr_enrichment_degraded),
pr_degraded_steps: options.includePrDegraded
? prDegradedSteps(s?.pr_enrichment_degraded)
: [],
stabilization_delta: delta,
health: classifyHealth(stabilization),
sparkline,
Expand Down
18 changes: 16 additions & 2 deletions platform/lib/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -622,8 +623,14 @@ export const translations = {
},
prData: {
incomplete: "PR data incomplete",
steps: {
basic: "PR list",
enrichment: "commit enrichment",
reviews: "reviews",
fetch: "entire read",
},
incompleteTooltip:
"This run's PR read failed at: {steps}. Merge strategy isn't classified when enrichment fails, 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 PR lifecycle (counts, time to merge, cycle time), flow, review, in-PR and open-PR metrics may be missing or skewed.",
},
dora: {
title: "DORA",
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -2189,8 +2197,14 @@ export const translations = {
},
prData: {
incomplete: "Dados de PR incompletos",
steps: {
basic: "listagem de PRs",
enrichment: "enriquecimento de commits",
reviews: "reviews",
fetch: "leitura inteira",
},
incompleteTooltip:
"A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge não é classificada quando o enriquecimento 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 ciclo de vida de PR (contagens, tempo até o merge, cycle time), de flow, de review, de commits em PR e de PRs abertos podem faltar ou sair distorcidas.",
},
dora: {
title: "DORA",
Expand Down
97 changes: 67 additions & 30 deletions platform/src/app/[tenant]/compare/compare-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -218,14 +218,47 @@ function MergeStrategyCell({
);
}

/**
* The failed PR-read steps of a repo whose latest run read PR data only
* partially, on a line of their own under the repo name in both layouts: an
* amber dot (the legend under the list says what it means, so nothing hinges
* on colour) and the steps as text, for touch and keyboard users who get no
* hover. Amber 600 on light and 500 on dark keep the dot above 3:1 against
* the card (WCAG 1.4.11).
*
* The dot and the visible steps are aria-hidden, with the full sentence as
* their hover title; screen readers get that sentence once, as sr-only text.
*/
function PrDataSteps({
repo,
label,
steps,
}: {
repo: RepoSummary;
label: string;
steps: string;
}) {
if (repo.pr_degraded_steps.length === 0) return null;
return (
<span className="mt-1 flex min-w-0 text-xs text-muted-foreground">
<span
aria-hidden="true"
title={label}
className="flex min-w-0 items-center gap-1.5"
>
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-600 dark:bg-amber-500" />
<span>{steps}</span>
</span>
<span className="sr-only">{label}</span>
</span>
);
}

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: repo.pr_degraded_steps.join(", "),
steps: formatPrSteps(repo.pr_degraded_steps, t),
});
const [sort, setSort] = useState<SortState>({
key: "stabilization_ratio",
Expand Down Expand Up @@ -419,15 +452,11 @@ export function CompareView({ repos }: CompareViewProps) {
</td>
<td className="py-2 pr-3">
<span className="font-mono text-sm">{repo.name}</span>
{repo.pr_degraded_steps.length > 0 && (
<span className="ml-2 inline-flex items-center align-middle">
<span
aria-hidden="true"
className="h-2 w-2 rounded-full bg-amber-500"
/>
<span className="sr-only">{prDataTooltip(repo)}</span>
</span>
)}
<PrDataSteps
repo={repo}
label={prDataTooltip(repo)}
steps={formatPrSteps(repo.pr_degraded_steps, t)}
/>
</td>
<MetricCell
value={repo.stabilization_ratio}
Expand Down Expand Up @@ -481,15 +510,6 @@ export function CompareView({ repos }: CompareViewProps) {
})}
</tbody>
</table>
{anyPrDataIncomplete(sorted) && (
<p className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<span
aria-hidden="true"
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-500"
/>
{t("repos.detail.prData.incomplete")}
</p>
)}
</div>

{/* Mobile: sort control + card stack */}
Expand Down Expand Up @@ -539,13 +559,20 @@ export function CompareView({ repos }: CompareViewProps) {
className="flex flex-col gap-3 rounded-lg border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-2">
<span className="text-xs text-muted-foreground">
#{i + 1}
</span>
<span className="truncate font-mono text-sm">
{repo.name}
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-baseline gap-2">
<span className="text-xs text-muted-foreground">
#{i + 1}
</span>
<span className="truncate font-mono text-sm">
{repo.name}
</span>
</div>
<PrDataSteps
repo={repo}
label={prDataTooltip(repo)}
steps={formatPrSteps(repo.pr_degraded_steps, t)}
/>
</div>
<span
className={cn(
Expand Down Expand Up @@ -627,6 +654,16 @@ export function CompareView({ repos }: CompareViewProps) {
})}
</ul>
</div>

{sorted.some((repo) => repo.pr_degraded_steps.length > 0) && (
<p className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<span
aria-hidden="true"
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-600 dark:bg-amber-500"
/>
{t("repos.detail.prData.incomplete")}
</p>
)}
</CardContent>
</Card>
);
Expand Down
Loading
Loading