diff --git a/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card-model.ts b/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card-model.ts new file mode 100644 index 000000000..f2d124212 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card-model.ts @@ -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 = { + 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; +} diff --git a/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card.test.tsx new file mode 100644 index 000000000..45561c606 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card.test.tsx @@ -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 { + 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(); + 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( + , + ); + 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( + , + ); + 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(); + expect(container.textContent ?? "").not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card.tsx new file mode 100644 index 000000000..5ecaca2b6 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/gate-outcome-card.tsx @@ -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 ( +
+
+
+

Gate outcomes

+

+ Terminal gate dispositions from audit events over the last {breakdown.windowDays}{" "} + day(s). +

+
+ +
+ +
+ + {formatGateOutcomeRate(breakdown.rates.autoMerged)} of outcomes + + } + /> + + {formatGateOutcomeRate(breakdown.rates.autoClosed)} of outcomes + + } + /> + + {formatGateOutcomeRate(breakdown.rates.held)} of outcomes + + } + /> +
+ + {hasSamples ? ( +
+
+ Outcome mix +
+
+ {segments.map((segment) => ( +
+ ))} +
+
    + {segments.map((segment) => ( +
  • + + {segment.label} · {segment.count} +
  • + ))} +
+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel-slop.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel-slop.test.tsx index 64975b24e..6ae559b59 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel-slop.test.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel-slop.test.tsx @@ -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", () => ({ diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.test.tsx index 84b5ae3d8..de90d825e 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.test.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.test.tsx @@ -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: [], @@ -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", () => { diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx index 42a182d19..8270a8a93 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx @@ -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"; @@ -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"; @@ -374,6 +379,8 @@ function MaintainerDashboardView({ + + diff --git a/src/api/routes.ts b/src/api/routes.ts index 0a8a47d7e..3f7d8960c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -68,6 +68,7 @@ import { listInstallationHealth, listInstallations, listIssues, + listGateOutcomeAuditEventRollups, listIssueSignalSample, listAgentRunsForActor, listDigestSubscriptionsForLogin, @@ -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"; @@ -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, @@ -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 }, }); }); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index d6bf74393..d3667bad6 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3149,6 +3149,41 @@ export async function listPrVisibilitySkipAuditEvents( return { limit, hasMore: items.length > limit, items: items.slice(0, limit) }; } +/** Repo-scoped rollups of gate-outcome audit rows for the maintainer dashboard (#2203). Counts only + * `agent.action.merge|close|hold` events whose targetKey is a `repo#pr` key inside the scoped repos. */ +export async function listGateOutcomeAuditEventRollups( + env: Env, + options: { repoFullNames: string[]; sinceIso: string }, +): Promise> { + const scopedRepoNames = uniqueRepoNames(options.repoFullNames.map((name) => name.trim()).filter(Boolean)); + if (scopedRepoNames.length === 0) return []; + + const repoFilters = scopedRepoNames.map((repoFullName) => { + const prefix = `${repoFullName.toLowerCase()}#`; + const upperBound = `${repoFullName.toLowerCase()}$`; + return sql`lower(${auditEvents.targetKey}) >= ${prefix} and lower(${auditEvents.targetKey}) < ${upperBound}`; + }); + const repoFilter = or(...repoFilters); + + const rows = await getDb(env.DB) + .select({ + eventType: auditEvents.eventType, + outcome: auditEvents.outcome, + count: sql`count(*)`, + }) + .from(auditEvents) + .where( + and( + inArray(auditEvents.eventType, ["agent.action.merge", "agent.action.close", "agent.action.hold"]), + gte(auditEvents.createdAt, options.sinceIso), + repoFilter, + ), + ) + .groupBy(auditEvents.eventType, auditEvents.outcome); + + return rows.map((row) => ({ eventType: row.eventType, outcome: row.outcome, count: Number(row.count) })); +} + // #784 audit feed: the agent's own action history for a repo — both executed actions (`agent.action.`) // and approval-queue decisions (`agent.pending_action.accepted|rejected`). Repo-scoped via the `repo#pr` // targetKey prefix range (mirrors listPrVisibilitySkipAuditEvents). Read-only; private trust/score metadata diff --git a/src/services/gate-outcome-breakdown.ts b/src/services/gate-outcome-breakdown.ts new file mode 100644 index 000000000..3accd648a --- /dev/null +++ b/src/services/gate-outcome-breakdown.ts @@ -0,0 +1,73 @@ +// Gate-outcome breakdown for the maintainer quality dashboard (#539 / #2203). Pure aggregation over +// repo-scoped `agent.action.{merge,close,hold}` audit rows — auto-merged, auto-closed, and held/manual +// terminal dispositions only. Public-safe: counts and rates, never reward/wallet/score fields. + +export const GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS = 30; + +const TERMINAL_AUTO_OUTCOMES = new Set(["success", "completed"]); + +export type GateOutcomeBreakdownCounts = { + autoMerged: number; + autoClosed: number; + held: number; +}; + +export type GateOutcomeBreakdownRates = { + autoMerged: number | null; + autoClosed: number | null; + held: number | null; +}; + +export type GateOutcomeBreakdown = { + windowDays: number; + generatedAt: string; + counts: GateOutcomeBreakdownCounts; + total: number; + rates: GateOutcomeBreakdownRates; + summary: string; +}; + +export type GateOutcomeAuditRollup = { + eventType: string; + outcome: string; + count: number; +}; + +/** Map one grouped audit row into a breakdown bucket, or null when it is not a gate-outcome event. Pure. */ +export function classifyGateOutcomeAuditBucket(event: Pick): keyof GateOutcomeBreakdownCounts | null { + if (!TERMINAL_AUTO_OUTCOMES.has(event.outcome)) return null; + if (event.eventType === "agent.action.merge") return "autoMerged"; + if (event.eventType === "agent.action.close") return "autoClosed"; + if (event.eventType === "agent.action.hold") return "held"; + return null; +} + +function breakdownRate(count: number, total: number): number | null { + if (total <= 0) return null; + return Math.round((count / total) * 1000) / 10; +} + +/** Fold repo-scoped gate-outcome audit rollups into count + rate tiles for the maintainer dashboard. Pure. */ +export function buildGateOutcomeBreakdown(args: { + rollups: ReadonlyArray; + windowDays?: number | undefined; + generatedAt: string; +}): GateOutcomeBreakdown { + const counts: GateOutcomeBreakdownCounts = { autoMerged: 0, autoClosed: 0, held: 0 }; + for (const row of args.rollups) { + const bucket = classifyGateOutcomeAuditBucket(row); + if (bucket) counts[bucket] += row.count; + } + const windowDays = args.windowDays ?? GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS; + const total = counts.autoMerged + counts.autoClosed + counts.held; + const rates: GateOutcomeBreakdownRates = { + autoMerged: breakdownRate(counts.autoMerged, total), + autoClosed: breakdownRate(counts.autoClosed, total), + held: breakdownRate(counts.held, total), + }; + const summary = + total === 0 + ? `No gate-outcome audit events in the last ${windowDays} day(s) for the scoped repos.` + : `${total} gate outcome(s) in the last ${windowDays} day(s): ${counts.autoMerged} auto-merged, ${counts.autoClosed} auto-closed, ${counts.held} held for manual review.`; + return { windowDays, generatedAt: args.generatedAt, counts, total, rates, summary }; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index ba5466f9b..0133f0f4f 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -2220,7 +2220,20 @@ describe("api routes", () => { expect(res.status).toBe(200); const body = (await res.json()) as { metrics: Array<{ label: string; value: number }>; - qualityDashboard: { generatedAt: string; stale: boolean; repoQuality: Array<{ repoFullName: string; queueBand: string }>; topContributors: Array<{ login: string; band: string }>; qualitySignals: { openPrs: number }; summary: string }; + qualityDashboard: { + generatedAt: string; + stale: boolean; + repoQuality: Array<{ repoFullName: string; queueBand: string }>; + topContributors: Array<{ login: string; band: string }>; + qualitySignals: { openPrs: number }; + summary: string; + gateOutcomeBreakdown: { + windowDays: number; + counts: { autoMerged: number; autoClosed: number; held: number }; + total: number; + rates: { autoMerged: number | null; autoClosed: number | null; held: number | null }; + }; + }; }; expect(body.metrics.find((metric) => metric.label === "Open PRs cached")?.value).toBe(8); // Quality dashboard (#557): shaped, scoped, public-safe trend/outcome data with bands not raw scores. @@ -2231,6 +2244,8 @@ describe("api routes", () => { expect(body.qualityDashboard.topContributors.every((entry) => ["strong", "developing", "early"].includes(entry.band))).toBe(true); expect(body.qualityDashboard.qualitySignals.openPrs).toBeGreaterThanOrEqual(0); expect(body.qualityDashboard.summary).toContain("open PR(s)"); + expect(body.qualityDashboard.gateOutcomeBreakdown.windowDays).toBeGreaterThan(0); + expect(body.qualityDashboard.gateOutcomeBreakdown.total).toBeGreaterThanOrEqual(0); expect(JSON.stringify(body.qualityDashboard)).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS); expect(JSON.stringify(body.qualityDashboard)).not.toMatch(/"burdenScore"|"credibility"/); }); diff --git a/test/unit/gate-outcome-audit-rollups.test.ts b/test/unit/gate-outcome-audit-rollups.test.ts new file mode 100644 index 000000000..d1ad4d082 --- /dev/null +++ b/test/unit/gate-outcome-audit-rollups.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { listGateOutcomeAuditEventRollups, recordAuditEvent } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +describe("listGateOutcomeAuditEventRollups (#2203)", () => { + it("counts repo-scoped merge/close/hold audit rows inside the window", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "gittensory", + targetKey: "octo/demo#1", + outcome: "completed", + createdAt: "2026-07-10T12:00:00.000Z", + }); + await recordAuditEvent(env, { + eventType: "agent.action.close", + actor: "gittensory", + targetKey: "octo/demo#2", + outcome: "success", + createdAt: "2026-07-10T13:00:00.000Z", + }); + await recordAuditEvent(env, { + eventType: "agent.action.hold", + actor: "gittensory", + targetKey: "octo/demo#3", + outcome: "completed", + createdAt: "2026-07-10T14:00:00.000Z", + }); + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "gittensory", + targetKey: "other/repo#9", + outcome: "completed", + createdAt: "2026-07-10T15:00:00.000Z", + }); + + const rollups = await listGateOutcomeAuditEventRollups(env, { + repoFullNames: ["octo/demo"], + sinceIso: "2026-07-01T00:00:00.000Z", + }); + expect(rollups).toEqual( + expect.arrayContaining([ + { eventType: "agent.action.merge", outcome: "completed", count: 1 }, + { eventType: "agent.action.close", outcome: "success", count: 1 }, + { eventType: "agent.action.hold", outcome: "completed", count: 1 }, + ]), + ); + expect(rollups.some((row) => row.eventType === "agent.action.merge" && row.count > 1)).toBe(false); + }); + + it("returns an empty rollup list when the scoped repo list is empty", async () => { + const env = createTestEnv(); + await expect(listGateOutcomeAuditEventRollups(env, { repoFullNames: [], sinceIso: "2026-07-01T00:00:00.000Z" })).resolves.toEqual([]); + }); + + it("ignores blank repo names after trimming", async () => { + const env = createTestEnv(); + await expect( + listGateOutcomeAuditEventRollups(env, { repoFullNames: [" ", ""], sinceIso: "2026-07-01T00:00:00.000Z" }), + ).resolves.toEqual([]); + }); +}); diff --git a/test/unit/gate-outcome-breakdown.test.ts b/test/unit/gate-outcome-breakdown.test.ts new file mode 100644 index 000000000..1237d5d3c --- /dev/null +++ b/test/unit/gate-outcome-breakdown.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + buildGateOutcomeBreakdown, + classifyGateOutcomeAuditBucket, + GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS, +} from "../../src/services/gate-outcome-breakdown"; + +const FORBIDDEN_PUBLIC_TERMS = + /wallet|hotkey|coldkey|mnemonic|reward|payout|farming|raw trust|trust score|scoreability|credibility|private ranking/i; + +describe("buildGateOutcomeBreakdown (#2203)", () => { + it("folds merge/close/hold audit rollups into counts and rates when all buckets are present", () => { + const result = buildGateOutcomeBreakdown({ + generatedAt: "2026-07-11T00:00:00.000Z", + rollups: [ + { eventType: "agent.action.merge", outcome: "completed", count: 6 }, + { eventType: "agent.action.close", outcome: "success", count: 3 }, + { eventType: "agent.action.hold", outcome: "completed", count: 1 }, + ], + }); + expect(result).toMatchObject({ + windowDays: GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS, + counts: { autoMerged: 6, autoClosed: 3, held: 1 }, + total: 10, + rates: { autoMerged: 60, autoClosed: 30, held: 10 }, + }); + expect(result.summary).toContain("6 auto-merged"); + expect(JSON.stringify(result)).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("leaves a zero bucket at count 0 while still computing rates for the others", () => { + const result = buildGateOutcomeBreakdown({ + generatedAt: "2026-07-11T00:00:00.000Z", + rollups: [ + { eventType: "agent.action.merge", outcome: "completed", count: 4 }, + { eventType: "agent.action.hold", outcome: "completed", count: 1 }, + ], + }); + expect(result.counts).toEqual({ autoMerged: 4, autoClosed: 0, held: 1 }); + expect(result.rates.autoClosed).toBe(0); + expect(result.rates.autoMerged).toBe(80); + }); + + it("returns null rates and an empty summary branch when there are no gate-outcome events", () => { + const result = buildGateOutcomeBreakdown({ generatedAt: "2026-07-11T00:00:00.000Z", rollups: [] }); + expect(result.total).toBe(0); + expect(result.rates).toEqual({ autoMerged: null, autoClosed: null, held: null }); + expect(result.summary).toContain("No gate-outcome audit events"); + }); + + it("classifyGateOutcomeAuditBucket maps each terminal gate-outcome event type to its bucket", () => { + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.merge", outcome: "success" })).toBe("autoMerged"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.merge", outcome: "completed" })).toBe("autoMerged"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.close", outcome: "success" })).toBe("autoClosed"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.close", outcome: "completed" })).toBe("autoClosed"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.hold", outcome: "success" })).toBe("held"); + }); + + it("classifyGateOutcomeAuditBucket ignores dry-run or non-terminal merge/close/hold rows", () => { + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.merge", outcome: "dry_run" })).toBeNull(); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.close", outcome: "denied" })).toBeNull(); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.hold", outcome: "dry_run" })).toBeNull(); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.hold", outcome: "denied" })).toBeNull(); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.approve", outcome: "completed" })).toBeNull(); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.hold", outcome: "completed" })).toBe("held"); + }); + + it("skips unrecognized audit rollups when folding counts", () => { + const result = buildGateOutcomeBreakdown({ + generatedAt: "2026-07-11T00:00:00.000Z", + rollups: [ + { eventType: "agent.action.merge", outcome: "completed", count: 2 }, + { eventType: "agent.action.merge", outcome: "dry_run", count: 9 }, + ], + }); + expect(result.counts.autoMerged).toBe(2); + expect(result.total).toBe(2); + }); + + it("honors an explicit windowDays override in the summary", () => { + const result = buildGateOutcomeBreakdown({ + generatedAt: "2026-07-11T00:00:00.000Z", + windowDays: 7, + rollups: [], + }); + expect(result.windowDays).toBe(7); + expect(result.summary).toContain("7 day(s)"); + }); +}); diff --git a/test/unit/routes-gate-outcome-breakdown.test.ts b/test/unit/routes-gate-outcome-breakdown.test.ts new file mode 100644 index 000000000..eafe92395 --- /dev/null +++ b/test/unit/routes-gate-outcome-breakdown.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { recordAuditEvent, upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS, buildGateOutcomeBreakdown, classifyGateOutcomeAuditBucket } from "../../src/services/gate-outcome-breakdown"; +import { createTestEnv } from "../helpers/d1"; + +function stubMinerDetection(): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("gittensor.io")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); +} + +async function seedOwnedRepo(env: Env, owner: string, name: string, installationId: number): Promise { + await upsertInstallation(env, { + installation: { + id: installationId, + account: { login: owner, id: installationId, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read" }, + events: ["repository"], + }, + }); + await upsertRepositoryFromGitHub(env, { name, full_name: `${owner}/${name}`, private: false, owner: { login: owner } }, installationId); + await env.DB.prepare("UPDATE repositories SET is_registered = 1 WHERE full_name = ?").bind(`${owner}/${name}`).run(); +} + +describe("GET /v1/app/maintainer-dashboard gateOutcomeBreakdown (#2203)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("surfaces repo-scoped gate-outcome counts on qualityDashboard for an owner session", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + stubMinerDetection(); + const now = "2026-07-11T12:00:00.000Z"; + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "gittensory", + targetKey: "owner/repo#1", + outcome: "completed", + createdAt: now, + }); + await recordAuditEvent(env, { + eventType: "agent.action.close", + actor: "gittensory", + targetKey: "owner/repo#2", + outcome: "success", + createdAt: now, + }); + await recordAuditEvent(env, { + eventType: "agent.action.hold", + actor: "gittensory", + targetKey: "owner/repo#3", + outcome: "completed", + createdAt: now, + }); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 101 }); + + const res = await app.request( + "/v1/app/maintainer-dashboard", + { headers: { cookie: `gittensory_session=${token}` } }, + env, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + qualityDashboard: { + gateOutcomeBreakdown: { + windowDays: number; + total: number; + counts: { autoMerged: number; autoClosed: number; held: number }; + rates: { autoMerged: number | null; autoClosed: number | null; held: number | null }; + summary: string; + }; + }; + }; + expect(body.qualityDashboard.gateOutcomeBreakdown).toMatchObject({ + windowDays: GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS, + total: 3, + counts: { autoMerged: 1, autoClosed: 1, held: 1 }, + rates: { autoMerged: 33.3, autoClosed: 33.3, held: 33.3 }, + }); + expect(body.qualityDashboard.gateOutcomeBreakdown.summary).toContain("auto-merged"); + }); + + it("excludes non-terminal and non-gate audit rows from gateOutcomeBreakdown totals", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + stubMinerDetection(); + const now = "2026-07-11T12:00:00.000Z"; + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "gittensory", + targetKey: "owner/repo#1", + outcome: "queued", + createdAt: now, + }); + await recordAuditEvent(env, { + eventType: "agent.action.close", + actor: "gittensory", + targetKey: "owner/repo#2", + outcome: "denied", + createdAt: now, + }); + await recordAuditEvent(env, { + eventType: "agent.action.hold", + actor: "gittensory", + targetKey: "owner/repo#3", + outcome: "denied", + createdAt: now, + }); + await recordAuditEvent(env, { + eventType: "agent.action.approve", + actor: "gittensory", + targetKey: "owner/repo#4", + outcome: "completed", + createdAt: now, + }); + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "gittensory", + targetKey: "owner/repo#5", + outcome: "success", + createdAt: now, + }); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 101 }); + + const res = await app.request( + "/v1/app/maintainer-dashboard", + { headers: { cookie: `gittensory_session=${token}` } }, + env, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + qualityDashboard: { + gateOutcomeBreakdown: { + total: number; + counts: { autoMerged: number; autoClosed: number; held: number }; + summary: string; + }; + }; + }; + expect(body.qualityDashboard.gateOutcomeBreakdown).toMatchObject({ + total: 1, + counts: { autoMerged: 1, autoClosed: 0, held: 0 }, + }); + expect(body.qualityDashboard.gateOutcomeBreakdown.summary).toContain("1 gate outcome"); + }); + + it("returns an empty gateOutcomeBreakdown when scoped repos have no terminal gate-outcome audit rows", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 101 }); + + const res = await app.request( + "/v1/app/maintainer-dashboard", + { headers: { cookie: `gittensory_session=${token}` } }, + env, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + qualityDashboard: { + gateOutcomeBreakdown: { + total: number; + rates: { autoMerged: number | null; autoClosed: number | null; held: number | null }; + summary: string; + }; + }; + }; + expect(body.qualityDashboard.gateOutcomeBreakdown.total).toBe(0); + expect(body.qualityDashboard.gateOutcomeBreakdown.rates).toEqual({ + autoMerged: null, + autoClosed: null, + held: null, + }); + expect(body.qualityDashboard.gateOutcomeBreakdown.summary).toContain("No gate-outcome audit events"); + }); +}); + +describe("classifyGateOutcomeAuditBucket (#2203)", () => { + it("maps each terminal gate-outcome event type to its bucket and rejects everything else", () => { + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.merge", outcome: "success" })).toBe("autoMerged"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.merge", outcome: "completed" })).toBe("autoMerged"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.close", outcome: "success" })).toBe("autoClosed"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.close", outcome: "completed" })).toBe("autoClosed"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.hold", outcome: "success" })).toBe("held"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.hold", outcome: "completed" })).toBe("held"); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.merge", outcome: "dry_run" })).toBeNull(); + expect(classifyGateOutcomeAuditBucket({ eventType: "agent.action.approve", outcome: "completed" })).toBeNull(); + }); +}); + +describe("buildGateOutcomeBreakdown (#2203)", () => { + it("defaults windowDays when the caller omits it", () => { + const result = buildGateOutcomeBreakdown({ + generatedAt: "2026-07-11T00:00:00.000Z", + rollups: [], + }); + expect(result.windowDays).toBe(GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS); + }); + + it("honors an explicit windowDays override in the summary", () => { + const result = buildGateOutcomeBreakdown({ + generatedAt: "2026-07-11T00:00:00.000Z", + windowDays: 14, + rollups: [{ eventType: "agent.action.hold", outcome: "completed", count: 2 }], + }); + expect(result.windowDays).toBe(14); + expect(result.summary).toContain("14 day(s)"); + }); +});