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
10 changes: 6 additions & 4 deletions platform/lib/queries/temporal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,16 +279,18 @@ 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;
};
};

let summaries: SummaryRow[] | null;
let summariesError: PostgrestError | null;
Expand Down
8 changes: 4 additions & 4 deletions platform/lib/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,10 +627,10 @@ export const translations = {
basic: "PR list",
enrichment: "commit enrichment",
reviews: "reviews",
fetch: "PR read",
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 @@ -2201,10 +2201,10 @@ export const translations = {
basic: "listagem de PRs",
enrichment: "enriquecimento de commits",
reviews: "reviews",
fetch: "leitura de PRs",
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
62 changes: 44 additions & 18 deletions platform/src/app/[tenant]/compare/compare-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,20 +219,36 @@ 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.
* 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 PrDataMarker({ repo, label }: { repo: RepoSummary; label: string }) {
function PrDataSteps({
repo,
label,
steps,
}: {
repo: RepoSummary;
label: string;
steps: string;
}) {
if (repo.pr_degraded_steps.length === 0) return null;
return (
<span className="ml-2 inline-flex flex-shrink-0 items-center align-middle">
<span className="mt-1 flex min-w-0 text-xs text-muted-foreground">
<span
aria-hidden="true"
title={label}
className="h-2 w-2 rounded-full bg-amber-500"
/>
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>
);
Expand Down Expand Up @@ -436,7 +452,11 @@ export function CompareView({ repos }: CompareViewProps) {
</td>
<td className="py-2 pr-3">
<span className="font-mono text-sm">{repo.name}</span>
<PrDataMarker repo={repo} label={prDataTooltip(repo)} />
<PrDataSteps
repo={repo}
label={prDataTooltip(repo)}
steps={formatPrSteps(repo.pr_degraded_steps, t)}
/>
</td>
<MetricCell
value={repo.stabilization_ratio}
Expand Down Expand Up @@ -539,14 +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>
<PrDataMarker repo={repo} label={prDataTooltip(repo)} />
<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 @@ -633,7 +659,7 @@ export function CompareView({ repos }: CompareViewProps) {
<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"
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-600 dark:bg-amber-500"
/>
{t("repos.detail.prData.incomplete")}
</p>
Expand Down
25 changes: 15 additions & 10 deletions platform/src/app/[tenant]/repos/[repoName]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,17 +255,22 @@ export default async function RepoDetailPage({
</Badge>
))}
{/* In the header, not in the flow or merge panels: those can
disappear when the read degrades, which would hide the cause. */}
disappear when the read degrades, which would hide the cause.
The failed steps are text next to the badge, for touch and
keyboard users too; no title repeating them, which screen
readers may read as a description on top of the text. */}
{prDegraded.length > 0 && (
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"
title={t("repos.detail.prData.incompleteTooltip", {
steps: formatPrSteps(prDegraded, t),
})}
>
{t("repos.detail.prData.incomplete")}
</Badge>
<span className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"
>
{t("repos.detail.prData.incomplete")}
</Badge>
<span className="text-xs text-muted-foreground">
{formatPrSteps(prDegraded, t)}
</span>
</span>
)}
</div>
{repo.remote_url && (
Expand Down
105 changes: 105 additions & 0 deletions platform/tests/compare-pr-data-marker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";

import { translations } from "@/lib/translations";
import type { RepoSummary } from "@/types/temporal";

// The real hook reads the session; the view only needs `t()` over en-US.
vi.mock("@/hooks/useTranslation", () => ({
useTranslation: () => ({
t: (path: string, params?: Record<string, string | number>) => {
let value: unknown = translations["en-US"];
for (const key of path.split(".")) {
value = (value as Record<string, unknown>)?.[key];
}
let text = String(value);
for (const [k, v] of Object.entries(params ?? {})) {
text = text.replace(`{${k}}`, String(v));
}
return text;
},
}),
}));

function repo(
id: string,
name: string,
steps: RepoSummary["pr_degraded_steps"],
): RepoSummary {
return {
id,
name,
remote_url: null,
last_run_at: null,
runs_count: 1,
stabilization_ratio: 0.9,
revert_rate: 0,
churn_events: 1,
commits_total: 10,
ai_detection_coverage_pct: null,
pr_merged_count: 1,
pr_single_pass_rate: null,
fix_latency_median_hours: null,
cascade_rate: null,
merge_strategy: null,
commit_metrics_reliable: null,
pr_degraded_steps: steps,
stabilization_delta: null,
health: "healthy",
sparkline: [],
};
}

async function render(): Promise<string> {
const { CompareView } = await import("@/app/[tenant]/compare/compare-view");
return renderToStaticMarkup(
<CompareView
repos={[
repo("1", "widgets", ["basic", "reviews"]),
repo("2", "gadgets", []),
]}
/>,
);
}

const count = (text: string, part: string) => text.split(part).length - 1;

describe("CompareView PR-data marker", () => {
// Touch and keyboard users get no hover title, and sighted keyboard users
// on the desktop table neither: both layouts show the steps as text.
it("shows the failed steps as text in the table and on the card", async () => {
const html = await render();

expect(count(html, "<span>PR list, reviews</span>")).toBe(2);
});

// Each layout reads the full sentence once: the visible steps (and the
// dot) are aria-hidden, and only the sr-only sentence is announced. The
// table and the card are never shown at once, so a reader meets one.
it("announces the full sentence once per layout", async () => {
const html = await render();

expect(count(html, '<span aria-hidden="true" title="This run')).toBe(2);
expect(count(html, '<span class="sr-only">This run')).toBe(2);
expect(html).not.toContain("PR data incomplete: ");
});

// On the card the name truncates: the steps get a line of their own so a
// long list cannot squeeze it.
it("keeps the steps off the card's name row", async () => {
const html = await render();
const rowStart = html.indexOf('items-baseline gap-2">');
const nameRow = html.slice(rowStart, html.indexOf("</div>", rowStart));

expect(nameRow).toContain("widgets");
expect(nameRow).not.toContain("PR list");
});

// Amber 500 is ~2.1:1 on the light card, below WCAG 1.4.11's 3:1.
it("uses an amber dot that keeps 3:1 on light and dark", async () => {
const html = await render();

expect(html).toContain("bg-amber-600 dark:bg-amber-500");
expect(html).not.toMatch(/(?<!dark:)bg-amber-500/);
});
});
41 changes: 39 additions & 2 deletions platform/tests/pr-enrichment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,43 @@ 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");
});

// A failed open list, or a failed pass for open PRs, omits open-PR fields
// too: the tooltip names them, as the engine's report caveat does.
it("names open-PR metrics among what may be missing", () => {
expect(
translations["en-US"].repos.detail.prData.incompleteTooltip,
).toContain("flow, review, in-PR and open-PR metrics may be missing");
expect(
translations["pt-BR"].repos.detail.prData.incompleteTooltip,
).toContain("de commits em PR e de PRs abertos podem faltar");
});

// A failed merged list omits the PR lifecycle fields, as the engine's
// report caveat says.
it("names PR lifecycle metrics among what may be missing", () => {
expect(
translations["en-US"].repos.detail.prData.incompleteTooltip,
).toContain(
"PR lifecycle (counts, time to merge, cycle time), flow, review, in-PR and open-PR metrics",
);
expect(
translations["pt-BR"].repos.detail.prData.incompleteTooltip,
).toContain(
"métricas de ciclo de vida de PR (contagens, tempo até o merge, cycle time), de flow",
);
});
});

describe("prDegradedSteps duplicates", () => {
Expand Down Expand Up @@ -67,10 +104,10 @@ 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",
"PR list, commit enrichment, reviews, entire read",
);
expect(formatPrSteps(["basic", "fetch"], translator("pt-BR"))).toBe(
"listagem de PRs, leitura de PRs",
"listagem de PRs, leitura inteira",
);
});

Expand Down
Loading