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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** UI-side mirror of qualityDashboard.gateOutcomeBreakdown on GET /v1/app/maintainer-dashboard (#2203). */
export type GateOutcomeCardData = {
windowDays: number;
generatedAt: string;
counts: {
autoMerged: number;
autoClosed: number;
held: number;
};
total: number;
rates: {
autoMerged: number | null;
autoClosed: number | null;
held: number | null;
};
summary: string;
};

export type GateOutcomeSegment = {
key: "autoMerged" | "autoClosed" | "held";
label: string;
count: number;
widthPct: number;
barClassName: string;
};

const SEGMENT_META: Record<GateOutcomeSegment["key"], { label: string; barClassName: string }> = {
autoMerged: { label: "Auto-merged", barClassName: "bg-success/80" },
autoClosed: { label: "Auto-closed", barClassName: "bg-danger/80" },
held: { label: "Held / manual", barClassName: "bg-warning/80" },
};

export function formatGateOutcomeRate(rate: number | null): string {
return rate === null ? "n/a" : `${rate}%`;
}

/** Width percentages for the stacked proportion bar; empty when there is no sample. Pure. */
export function gateOutcomeSegments(breakdown: GateOutcomeCardData): GateOutcomeSegment[] {
if (breakdown.total <= 0) return [];
return (Object.keys(SEGMENT_META) as GateOutcomeSegment["key"][])
.map((key) => {
const count = breakdown.counts[key];
const meta = SEGMENT_META[key];
return {
key,
label: meta.label,
count,
widthPct: (count / breakdown.total) * 100,
barClassName: meta.barClassName,
};
})
.filter((segment) => segment.widthPct > 0);
}

export function gateOutcomeHasSamples(breakdown: GateOutcomeCardData): boolean {
return breakdown.total > 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { GateOutcomeCard } from "@/components/site/app-panels/gate-outcome-card";
import type { GateOutcomeCardData } from "@/components/site/app-panels/gate-outcome-card-model";
import {
formatGateOutcomeRate,
gateOutcomeHasSamples,
gateOutcomeSegments,
} from "@/components/site/app-panels/gate-outcome-card-model";

const FORBIDDEN_PUBLIC_TERMS =
/wallet|hotkey|coldkey|mnemonic|reward|payout|farming|raw trust|trust score|scoreability|credibility|private ranking/i;

function breakdown(overrides: Partial<GateOutcomeCardData> = {}): GateOutcomeCardData {
return {
windowDays: 30,
generatedAt: "2026-07-11T00:00:00.000Z",
counts: { autoMerged: 6, autoClosed: 3, held: 1 },
total: 10,
rates: { autoMerged: 60, autoClosed: 30, held: 10 },
summary:
"10 gate outcome(s) in the last 30 day(s): 6 auto-merged, 3 auto-closed, 1 held for manual review.",
...overrides,
};
}

describe("gate-outcome-card-model (#2203)", () => {
it("builds stacked segments when all three outcome buckets are present", () => {
const segments = gateOutcomeSegments(breakdown());
expect(segments.map((segment) => segment.key)).toEqual(["autoMerged", "autoClosed", "held"]);
expect(segments.map((segment) => segment.widthPct)).toEqual([60, 30, 10]);
expect(new Set(segments.map((segment) => segment.barClassName)).size).toBe(3);
expect(segments.find((segment) => segment.key === "autoMerged")?.barClassName).toBe(
"bg-success/80",
);
expect(segments.find((segment) => segment.key === "autoClosed")?.barClassName).toBe(
"bg-danger/80",
);
});

it("omits a zero-count bucket from the stacked bar while keeping rates on the card", () => {
const segments = gateOutcomeSegments(
breakdown({
counts: { autoMerged: 4, autoClosed: 0, held: 1 },
total: 5,
rates: { autoMerged: 80, autoClosed: 0, held: 20 },
}),
);
expect(segments.map((segment) => segment.key)).toEqual(["autoMerged", "held"]);
expect(formatGateOutcomeRate(0)).toBe("0%");
});

it("returns no segments and no samples when the breakdown is empty", () => {
const empty = breakdown({
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
total: 0,
rates: { autoMerged: null, autoClosed: null, held: null },
summary: "No gate-outcome audit events in the last 30 day(s) for the scoped repos.",
});
expect(gateOutcomeHasSamples(empty)).toBe(false);
expect(gateOutcomeSegments(empty)).toEqual([]);
});
});

describe("GateOutcomeCard (#2203)", () => {
it("renders three stat tiles and a stacked proportion bar when all outcomes are present", () => {
render(<GateOutcomeCard breakdown={breakdown()} />);
expect(screen.getByText("Gate outcomes")).toBeTruthy();
expect(screen.getByText("Auto-merged")).toBeTruthy();
expect(screen.getByText("Auto-closed")).toBeTruthy();
expect(screen.getByText("Held / manual")).toBeTruthy();
expect(screen.getByText("6")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("60% of outcomes")).toBeTruthy();
expect(
screen.getByLabelText(/Gate outcome mix: 6 auto-merged, 3 auto-closed, 1 held/i),
).toBeTruthy();
});

it("shows a zero bucket as 0% while still rendering the other segments", () => {
render(
<GateOutcomeCard
breakdown={breakdown({
counts: { autoMerged: 2, autoClosed: 0, held: 2 },
total: 4,
rates: { autoMerged: 50, autoClosed: 0, held: 50 },
})}
/>,
);
expect(screen.getByText("0% of outcomes")).toBeTruthy();
expect(
screen.getByLabelText(/Gate outcome mix: 2 auto-merged, 0 auto-closed, 2 held/i),
).toBeTruthy();
});

it("renders an empty state instead of the proportion bar when there are no audit events", () => {
render(
<GateOutcomeCard
breakdown={breakdown({
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
total: 0,
rates: { autoMerged: null, autoClosed: null, held: null },
})}
/>,
);
expect(screen.getByText("No gate-outcome events yet")).toBeTruthy();
expect(screen.queryByLabelText(/Gate outcome mix/i)).toBeNull();
expect(screen.getAllByText("n/a of outcomes")).toHaveLength(3);
});

it("never surfaces forbidden reward/wallet/score terms", () => {
const { container } = render(<GateOutcomeCard breakdown={breakdown()} />);
expect(container.textContent ?? "").not.toMatch(FORBIDDEN_PUBLIC_TERMS);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { BoundaryBadge, Stat } from "@/components/site/control-primitives";
import { EmptyState } from "@/components/site/state-views";
import {
formatGateOutcomeRate,
gateOutcomeHasSamples,
gateOutcomeSegments,
type GateOutcomeCardData,
} from "@/components/site/app-panels/gate-outcome-card-model";

/** Gate-outcome breakdown card (#2203, part of #539): auto-merged / auto-closed / held counts and rates
* from repo-scoped gate-outcome audit events. Read-only; public-safe aggregate counts only. */
export function GateOutcomeCard({ breakdown }: { breakdown: GateOutcomeCardData }) {
const segments = gateOutcomeSegments(breakdown);
const hasSamples = gateOutcomeHasSamples(breakdown);

return (
<section className="rounded-token border-hairline bg-card p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-display text-token-lg font-semibold">Gate outcomes</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
Terminal gate dispositions from audit events over the last {breakdown.windowDays}{" "}
day(s).
</p>
</div>
<BoundaryBadge boundary="public" />
</div>

<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Stat
label="Auto-merged"
value={String(breakdown.counts.autoMerged)}
hint={
<span className="text-muted-foreground">
{formatGateOutcomeRate(breakdown.rates.autoMerged)} of outcomes
</span>
}
/>
<Stat
label="Auto-closed"
value={String(breakdown.counts.autoClosed)}
hint={
<span className="text-muted-foreground">
{formatGateOutcomeRate(breakdown.rates.autoClosed)} of outcomes
</span>
}
/>
<Stat
label="Held / manual"
value={String(breakdown.counts.held)}
hint={
<span className="text-muted-foreground">
{formatGateOutcomeRate(breakdown.rates.held)} of outcomes
</span>
}
/>
</div>

{hasSamples ? (
<div className="mt-4">
<div className="font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
Outcome mix
</div>
<div
className="mt-2 flex h-3 overflow-hidden rounded-token border border-border"
role="img"
aria-label={`Gate outcome mix: ${breakdown.counts.autoMerged} auto-merged, ${breakdown.counts.autoClosed} auto-closed, ${breakdown.counts.held} held`}
>
{segments.map((segment) => (
<div
key={segment.key}
className={segment.barClassName}
style={{ width: `${segment.widthPct}%` }}
title={`${segment.label}: ${segment.count}`}
/>
))}
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-token-2xs text-muted-foreground">
{segments.map((segment) => (
<li key={segment.key} className="inline-flex items-center gap-1.5">
<span
className={`inline-block size-2 rounded-full ${segment.barClassName}`}
aria-hidden
/>
{segment.label} · {segment.count}
</li>
))}
</ul>
</div>
) : (
<EmptyState
className="mt-4"
title="No gate-outcome events yet"
description="Auto-merge, auto-close, and hold audit rows appear here once the agent processes PRs in your scoped repos."
/>
)}
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,17 @@ const dashboard = {
},
],
settingsPreview: { removed: [], added: [] },
qualityDashboard: { topContributors: [] },
qualityDashboard: {
topContributors: [],
gateOutcomeBreakdown: {
windowDays: 30,
generatedAt: "2026-07-11T00:00:00.000Z",
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
total: 0,
rates: { autoMerged: null, autoClosed: null, held: null },
summary: "No gate-outcome audit events in the last 30 day(s) for the scoped repos.",
},
},
};

vi.mock("@/lib/api/use-api-resource", () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ describe("MaintainerPanel role gate", () => {
});
});

const emptyGateOutcomeBreakdown = {
windowDays: 30,
generatedAt: "2026-07-11T00:00:00.000Z",
counts: { autoMerged: 0, autoClosed: 0, held: 0 },
total: 0,
rates: { autoMerged: null, autoClosed: null, held: null },
summary: "No gate-outcome audit events in the last 30 day(s) for the scoped repos.",
};

describe("MaintainerPanel install health — Orb broker mode (#selfhost-runtime-drift)", () => {
const dashboardData = {
metrics: [],
Expand Down Expand Up @@ -66,7 +75,7 @@ describe("MaintainerPanel install health — Orb broker mode (#selfhost-runtime-
],
reviewability: [],
settingsPreview: { removed: [], added: [] },
qualityDashboard: { topContributors: [] },
qualityDashboard: { topContributors: [], gateOutcomeBreakdown: emptyGateOutcomeBreakdown },
};

it("shows a neutral 'n/a (broker)' pill instead of a fabricated perms/webhook verdict for a brokered install", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { ActivationPreview } from "@/components/site/app-panels/activation-previ
import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings";
import { ContributorQualityTable } from "@/components/site/app-panels/contributor-quality-table";
import type { MaintainerTopContributor } from "@/components/site/app-panels/contributor-quality-table-model";
import { GateOutcomeCard } from "@/components/site/app-panels/gate-outcome-card";
import type { GateOutcomeCardData } from "@/components/site/app-panels/gate-outcome-card-model";
import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings";
import { OnboardingPreviewCard } from "@/components/site/app-panels/onboarding-preview-card";
import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table";
Expand Down Expand Up @@ -82,7 +84,10 @@ type MaintainerDashboard = {
slop?: { risk: number; band: string } | null;
}>;
settingsPreview: { removed: string[]; added: string[] };
qualityDashboard: { topContributors: MaintainerTopContributor[] };
qualityDashboard: {
topContributors: MaintainerTopContributor[];
gateOutcomeBreakdown: GateOutcomeCardData;
};
};

type TrustChecklistStatus = "ready" | "needs_attention" | "blocked";
Expand Down Expand Up @@ -374,6 +379,8 @@ function MaintainerDashboardView({
</table>
</section>

<GateOutcomeCard breakdown={data.qualityDashboard.gateOutcomeBreakdown} />

<ContributorQualityTable topContributors={data.qualityDashboard.topContributors} />

<ActivationPreview reviewability={data.reviewability} />
Expand Down
14 changes: 13 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
listInstallationHealth,
listInstallations,
listIssues,
listGateOutcomeAuditEventRollups,
listIssueSignalSample,
listAgentRunsForActor,
listDigestSubscriptionsForLogin,
Expand Down Expand Up @@ -265,6 +266,7 @@ import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend";
import { loadPublicReuseRateTrend } from "../services/public-reuse-rate-trend";
import { loadPublicReviewVolumeTrend } from "../services/public-review-volume-trend";
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
import { buildGateOutcomeBreakdown, GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS } from "../services/gate-outcome-breakdown";
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, normalizeReadinessGateMode } from "../signals/focus-manifest";
import { resolveRepositorySettings } from "../settings/repository-settings";
Expand Down Expand Up @@ -1392,6 +1394,16 @@ export function createApp() {
const scopedSyncCompletions = allSyncStates.filter((state) => qualityRepoNames.has(state.repoFullName.toLowerCase())).map((state) => state.lastCompletedAt);
const qualityStale = isMaintainerQualityDataStale({ lastCompletedAts: scopedSyncCompletions, repoCount: qualityRepos.length, nowMs: Date.parse(nowIso()) });
const qualityDashboard = buildMaintainerQualityDashboard({ repos: qualityRepoInputs, generatedAt: nowIso(), stale: qualityStale, repoTotal: repositories.length });
const gateOutcomeSinceIso = new Date(Date.parse(nowIso()) - GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
const gateOutcomeRollups = await listGateOutcomeAuditEventRollups(c.env, {
repoFullNames: repositories.map((repo) => repo.fullName),
sinceIso: gateOutcomeSinceIso,
});
const gateOutcomeBreakdown = buildGateOutcomeBreakdown({
rollups: gateOutcomeRollups,
windowDays: GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS,
generatedAt: nowIso(),
});
return c.json({
generatedAt: nowIso(),
installations,
Expand All @@ -1413,7 +1425,7 @@ export function createApp() {
slop: previewSettingsByRepo.get(repoFullName)?.slopGateMode !== "off" && typeof pull.slopRisk === "number" && pull.slopBand ? { risk: pull.slopRisk, band: pull.slopBand } : null,
})),
settingsPreview: buildMaintainerSettingsPreview(),
qualityDashboard,
qualityDashboard: { ...qualityDashboard, gateOutcomeBreakdown },
});
});

Expand Down
Loading
Loading