Skip to content

Commit 19fbd93

Browse files
feat(ui): gate-outcome breakdown card (#2203) (#5098)
* feat(ui): gate-outcome breakdown card (#2203) * update spinner label color * fix: codecov patch * update * fix: codecov gap * fix
1 parent 9396dc8 commit 19fbd93

13 files changed

Lines changed: 807 additions & 5 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/** UI-side mirror of qualityDashboard.gateOutcomeBreakdown on GET /v1/app/maintainer-dashboard (#2203). */
2+
export type GateOutcomeCardData = {
3+
windowDays: number;
4+
generatedAt: string;
5+
counts: {
6+
autoMerged: number;
7+
autoClosed: number;
8+
held: number;
9+
};
10+
total: number;
11+
rates: {
12+
autoMerged: number | null;
13+
autoClosed: number | null;
14+
held: number | null;
15+
};
16+
summary: string;
17+
};
18+
19+
export type GateOutcomeSegment = {
20+
key: "autoMerged" | "autoClosed" | "held";
21+
label: string;
22+
count: number;
23+
widthPct: number;
24+
barClassName: string;
25+
};
26+
27+
const SEGMENT_META: Record<GateOutcomeSegment["key"], { label: string; barClassName: string }> = {
28+
autoMerged: { label: "Auto-merged", barClassName: "bg-success/80" },
29+
autoClosed: { label: "Auto-closed", barClassName: "bg-danger/80" },
30+
held: { label: "Held / manual", barClassName: "bg-warning/80" },
31+
};
32+
33+
export function formatGateOutcomeRate(rate: number | null): string {
34+
return rate === null ? "n/a" : `${rate}%`;
35+
}
36+
37+
/** Width percentages for the stacked proportion bar; empty when there is no sample. Pure. */
38+
export function gateOutcomeSegments(breakdown: GateOutcomeCardData): GateOutcomeSegment[] {
39+
if (breakdown.total <= 0) return [];
40+
return (Object.keys(SEGMENT_META) as GateOutcomeSegment["key"][])
41+
.map((key) => {
42+
const count = breakdown.counts[key];
43+
const meta = SEGMENT_META[key];
44+
return {
45+
key,
46+
label: meta.label,
47+
count,
48+
widthPct: (count / breakdown.total) * 100,
49+
barClassName: meta.barClassName,
50+
};
51+
})
52+
.filter((segment) => segment.widthPct > 0);
53+
}
54+
55+
export function gateOutcomeHasSamples(breakdown: GateOutcomeCardData): boolean {
56+
return breakdown.total > 0;
57+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { GateOutcomeCard } from "@/components/site/app-panels/gate-outcome-card";
5+
import type { GateOutcomeCardData } from "@/components/site/app-panels/gate-outcome-card-model";
6+
import {
7+
formatGateOutcomeRate,
8+
gateOutcomeHasSamples,
9+
gateOutcomeSegments,
10+
} from "@/components/site/app-panels/gate-outcome-card-model";
11+
12+
const FORBIDDEN_PUBLIC_TERMS =
13+
/wallet|hotkey|coldkey|mnemonic|reward|payout|farming|raw trust|trust score|scoreability|credibility|private ranking/i;
14+
15+
function breakdown(overrides: Partial<GateOutcomeCardData> = {}): GateOutcomeCardData {
16+
return {
17+
windowDays: 30,
18+
generatedAt: "2026-07-11T00:00:00.000Z",
19+
counts: { autoMerged: 6, autoClosed: 3, held: 1 },
20+
total: 10,
21+
rates: { autoMerged: 60, autoClosed: 30, held: 10 },
22+
summary:
23+
"10 gate outcome(s) in the last 30 day(s): 6 auto-merged, 3 auto-closed, 1 held for manual review.",
24+
...overrides,
25+
};
26+
}
27+
28+
describe("gate-outcome-card-model (#2203)", () => {
29+
it("builds stacked segments when all three outcome buckets are present", () => {
30+
const segments = gateOutcomeSegments(breakdown());
31+
expect(segments.map((segment) => segment.key)).toEqual(["autoMerged", "autoClosed", "held"]);
32+
expect(segments.map((segment) => segment.widthPct)).toEqual([60, 30, 10]);
33+
expect(new Set(segments.map((segment) => segment.barClassName)).size).toBe(3);
34+
expect(segments.find((segment) => segment.key === "autoMerged")?.barClassName).toBe(
35+
"bg-success/80",
36+
);
37+
expect(segments.find((segment) => segment.key === "autoClosed")?.barClassName).toBe(
38+
"bg-danger/80",
39+
);
40+
});
41+
42+
it("omits a zero-count bucket from the stacked bar while keeping rates on the card", () => {
43+
const segments = gateOutcomeSegments(
44+
breakdown({
45+
counts: { autoMerged: 4, autoClosed: 0, held: 1 },
46+
total: 5,
47+
rates: { autoMerged: 80, autoClosed: 0, held: 20 },
48+
}),
49+
);
50+
expect(segments.map((segment) => segment.key)).toEqual(["autoMerged", "held"]);
51+
expect(formatGateOutcomeRate(0)).toBe("0%");
52+
});
53+
54+
it("returns no segments and no samples when the breakdown is empty", () => {
55+
const empty = breakdown({
56+
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
57+
total: 0,
58+
rates: { autoMerged: null, autoClosed: null, held: null },
59+
summary: "No gate-outcome audit events in the last 30 day(s) for the scoped repos.",
60+
});
61+
expect(gateOutcomeHasSamples(empty)).toBe(false);
62+
expect(gateOutcomeSegments(empty)).toEqual([]);
63+
});
64+
});
65+
66+
describe("GateOutcomeCard (#2203)", () => {
67+
it("renders three stat tiles and a stacked proportion bar when all outcomes are present", () => {
68+
render(<GateOutcomeCard breakdown={breakdown()} />);
69+
expect(screen.getByText("Gate outcomes")).toBeTruthy();
70+
expect(screen.getByText("Auto-merged")).toBeTruthy();
71+
expect(screen.getByText("Auto-closed")).toBeTruthy();
72+
expect(screen.getByText("Held / manual")).toBeTruthy();
73+
expect(screen.getByText("6")).toBeTruthy();
74+
expect(screen.getByText("3")).toBeTruthy();
75+
expect(screen.getByText("60% of outcomes")).toBeTruthy();
76+
expect(
77+
screen.getByLabelText(/Gate outcome mix: 6 auto-merged, 3 auto-closed, 1 held/i),
78+
).toBeTruthy();
79+
});
80+
81+
it("shows a zero bucket as 0% while still rendering the other segments", () => {
82+
render(
83+
<GateOutcomeCard
84+
breakdown={breakdown({
85+
counts: { autoMerged: 2, autoClosed: 0, held: 2 },
86+
total: 4,
87+
rates: { autoMerged: 50, autoClosed: 0, held: 50 },
88+
})}
89+
/>,
90+
);
91+
expect(screen.getByText("0% of outcomes")).toBeTruthy();
92+
expect(
93+
screen.getByLabelText(/Gate outcome mix: 2 auto-merged, 0 auto-closed, 2 held/i),
94+
).toBeTruthy();
95+
});
96+
97+
it("renders an empty state instead of the proportion bar when there are no audit events", () => {
98+
render(
99+
<GateOutcomeCard
100+
breakdown={breakdown({
101+
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
102+
total: 0,
103+
rates: { autoMerged: null, autoClosed: null, held: null },
104+
})}
105+
/>,
106+
);
107+
expect(screen.getByText("No gate-outcome events yet")).toBeTruthy();
108+
expect(screen.queryByLabelText(/Gate outcome mix/i)).toBeNull();
109+
expect(screen.getAllByText("n/a of outcomes")).toHaveLength(3);
110+
});
111+
112+
it("never surfaces forbidden reward/wallet/score terms", () => {
113+
const { container } = render(<GateOutcomeCard breakdown={breakdown()} />);
114+
expect(container.textContent ?? "").not.toMatch(FORBIDDEN_PUBLIC_TERMS);
115+
});
116+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { BoundaryBadge, Stat } from "@/components/site/control-primitives";
2+
import { EmptyState } from "@/components/site/state-views";
3+
import {
4+
formatGateOutcomeRate,
5+
gateOutcomeHasSamples,
6+
gateOutcomeSegments,
7+
type GateOutcomeCardData,
8+
} from "@/components/site/app-panels/gate-outcome-card-model";
9+
10+
/** Gate-outcome breakdown card (#2203, part of #539): auto-merged / auto-closed / held counts and rates
11+
* from repo-scoped gate-outcome audit events. Read-only; public-safe aggregate counts only. */
12+
export function GateOutcomeCard({ breakdown }: { breakdown: GateOutcomeCardData }) {
13+
const segments = gateOutcomeSegments(breakdown);
14+
const hasSamples = gateOutcomeHasSamples(breakdown);
15+
16+
return (
17+
<section className="rounded-token border-hairline bg-card p-5">
18+
<div className="flex items-center justify-between gap-3">
19+
<div>
20+
<h2 className="font-display text-token-lg font-semibold">Gate outcomes</h2>
21+
<p className="mt-1 text-token-xs text-muted-foreground">
22+
Terminal gate dispositions from audit events over the last {breakdown.windowDays}{" "}
23+
day(s).
24+
</p>
25+
</div>
26+
<BoundaryBadge boundary="public" />
27+
</div>
28+
29+
<div className="mt-4 grid gap-3 sm:grid-cols-3">
30+
<Stat
31+
label="Auto-merged"
32+
value={String(breakdown.counts.autoMerged)}
33+
hint={
34+
<span className="text-muted-foreground">
35+
{formatGateOutcomeRate(breakdown.rates.autoMerged)} of outcomes
36+
</span>
37+
}
38+
/>
39+
<Stat
40+
label="Auto-closed"
41+
value={String(breakdown.counts.autoClosed)}
42+
hint={
43+
<span className="text-muted-foreground">
44+
{formatGateOutcomeRate(breakdown.rates.autoClosed)} of outcomes
45+
</span>
46+
}
47+
/>
48+
<Stat
49+
label="Held / manual"
50+
value={String(breakdown.counts.held)}
51+
hint={
52+
<span className="text-muted-foreground">
53+
{formatGateOutcomeRate(breakdown.rates.held)} of outcomes
54+
</span>
55+
}
56+
/>
57+
</div>
58+
59+
{hasSamples ? (
60+
<div className="mt-4">
61+
<div className="font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
62+
Outcome mix
63+
</div>
64+
<div
65+
className="mt-2 flex h-3 overflow-hidden rounded-token border border-border"
66+
role="img"
67+
aria-label={`Gate outcome mix: ${breakdown.counts.autoMerged} auto-merged, ${breakdown.counts.autoClosed} auto-closed, ${breakdown.counts.held} held`}
68+
>
69+
{segments.map((segment) => (
70+
<div
71+
key={segment.key}
72+
className={segment.barClassName}
73+
style={{ width: `${segment.widthPct}%` }}
74+
title={`${segment.label}: ${segment.count}`}
75+
/>
76+
))}
77+
</div>
78+
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-token-2xs text-muted-foreground">
79+
{segments.map((segment) => (
80+
<li key={segment.key} className="inline-flex items-center gap-1.5">
81+
<span
82+
className={`inline-block size-2 rounded-full ${segment.barClassName}`}
83+
aria-hidden
84+
/>
85+
{segment.label} · {segment.count}
86+
</li>
87+
))}
88+
</ul>
89+
</div>
90+
) : (
91+
<EmptyState
92+
className="mt-4"
93+
title="No gate-outcome events yet"
94+
description="Auto-merge, auto-close, and hold audit rows appear here once the agent processes PRs in your scoped repos."
95+
/>
96+
)}
97+
</section>
98+
);
99+
}

apps/gittensory-ui/src/components/site/app-panels/maintainer-panel-slop.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,17 @@ const dashboard = {
2828
},
2929
],
3030
settingsPreview: { removed: [], added: [] },
31-
qualityDashboard: { topContributors: [] },
31+
qualityDashboard: {
32+
topContributors: [],
33+
gateOutcomeBreakdown: {
34+
windowDays: 30,
35+
generatedAt: "2026-07-11T00:00:00.000Z",
36+
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
37+
total: 0,
38+
rates: { autoMerged: null, autoClosed: null, held: null },
39+
summary: "No gate-outcome audit events in the last 30 day(s) for the scoped repos.",
40+
},
41+
},
3242
};
3343

3444
vi.mock("@/lib/api/use-api-resource", () => ({

apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ describe("MaintainerPanel role gate", () => {
3939
});
4040
});
4141

42+
const emptyGateOutcomeBreakdown = {
43+
windowDays: 30,
44+
generatedAt: "2026-07-11T00:00:00.000Z",
45+
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
46+
total: 0,
47+
rates: { autoMerged: null, autoClosed: null, held: null },
48+
summary: "No gate-outcome audit events in the last 30 day(s) for the scoped repos.",
49+
};
50+
4251
describe("MaintainerPanel install health — Orb broker mode (#selfhost-runtime-drift)", () => {
4352
const dashboardData = {
4453
metrics: [],
@@ -66,7 +75,7 @@ describe("MaintainerPanel install health — Orb broker mode (#selfhost-runtime-
6675
],
6776
reviewability: [],
6877
settingsPreview: { removed: [], added: [] },
69-
qualityDashboard: { topContributors: [] },
78+
qualityDashboard: { topContributors: [], gateOutcomeBreakdown: emptyGateOutcomeBreakdown },
7079
};
7180

7281
it("shows a neutral 'n/a (broker)' pill instead of a fabricated perms/webhook verdict for a brokered install", () => {

apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { ActivationPreview } from "@/components/site/app-panels/activation-previ
2121
import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings";
2222
import { ContributorQualityTable } from "@/components/site/app-panels/contributor-quality-table";
2323
import type { MaintainerTopContributor } from "@/components/site/app-panels/contributor-quality-table-model";
24+
import { GateOutcomeCard } from "@/components/site/app-panels/gate-outcome-card";
25+
import type { GateOutcomeCardData } from "@/components/site/app-panels/gate-outcome-card-model";
2426
import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings";
2527
import { OnboardingPreviewCard } from "@/components/site/app-panels/onboarding-preview-card";
2628
import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table";
@@ -82,7 +84,10 @@ type MaintainerDashboard = {
8284
slop?: { risk: number; band: string } | null;
8385
}>;
8486
settingsPreview: { removed: string[]; added: string[] };
85-
qualityDashboard: { topContributors: MaintainerTopContributor[] };
87+
qualityDashboard: {
88+
topContributors: MaintainerTopContributor[];
89+
gateOutcomeBreakdown: GateOutcomeCardData;
90+
};
8691
};
8792

8893
type TrustChecklistStatus = "ready" | "needs_attention" | "blocked";
@@ -374,6 +379,8 @@ function MaintainerDashboardView({
374379
</table>
375380
</section>
376381

382+
<GateOutcomeCard breakdown={data.qualityDashboard.gateOutcomeBreakdown} />
383+
377384
<ContributorQualityTable topContributors={data.qualityDashboard.topContributors} />
378385

379386
<ActivationPreview reviewability={data.reviewability} />

src/api/routes.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
listInstallationHealth,
6969
listInstallations,
7070
listIssues,
71+
listGateOutcomeAuditEventRollups,
7172
listIssueSignalSample,
7273
listAgentRunsForActor,
7374
listDigestSubscriptionsForLogin,
@@ -265,6 +266,7 @@ import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend";
265266
import { loadPublicReuseRateTrend } from "../services/public-reuse-rate-trend";
266267
import { loadPublicReviewVolumeTrend } from "../services/public-review-volume-trend";
267268
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
269+
import { buildGateOutcomeBreakdown, GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS } from "../services/gate-outcome-breakdown";
268270
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
269271
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, normalizeReadinessGateMode } from "../signals/focus-manifest";
270272
import { resolveRepositorySettings } from "../settings/repository-settings";
@@ -1392,6 +1394,16 @@ export function createApp() {
13921394
const scopedSyncCompletions = allSyncStates.filter((state) => qualityRepoNames.has(state.repoFullName.toLowerCase())).map((state) => state.lastCompletedAt);
13931395
const qualityStale = isMaintainerQualityDataStale({ lastCompletedAts: scopedSyncCompletions, repoCount: qualityRepos.length, nowMs: Date.parse(nowIso()) });
13941396
const qualityDashboard = buildMaintainerQualityDashboard({ repos: qualityRepoInputs, generatedAt: nowIso(), stale: qualityStale, repoTotal: repositories.length });
1397+
const gateOutcomeSinceIso = new Date(Date.parse(nowIso()) - GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
1398+
const gateOutcomeRollups = await listGateOutcomeAuditEventRollups(c.env, {
1399+
repoFullNames: repositories.map((repo) => repo.fullName),
1400+
sinceIso: gateOutcomeSinceIso,
1401+
});
1402+
const gateOutcomeBreakdown = buildGateOutcomeBreakdown({
1403+
rollups: gateOutcomeRollups,
1404+
windowDays: GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS,
1405+
generatedAt: nowIso(),
1406+
});
13951407
return c.json({
13961408
generatedAt: nowIso(),
13971409
installations,
@@ -1413,7 +1425,7 @@ export function createApp() {
14131425
slop: previewSettingsByRepo.get(repoFullName)?.slopGateMode !== "off" && typeof pull.slopRisk === "number" && pull.slopBand ? { risk: pull.slopRisk, band: pull.slopBand } : null,
14141426
})),
14151427
settingsPreview: buildMaintainerSettingsPreview(),
1416-
qualityDashboard,
1428+
qualityDashboard: { ...qualityDashboard, gateOutcomeBreakdown },
14171429
});
14181430
});
14191431

0 commit comments

Comments
 (0)