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,45 @@
// Reversal-health analytics card model (#2193). UI-side mirror of the AgentHealth fields from
// src/review/ops.ts surfaced on the operator-dashboard payload — plus status helpers for the card.

/** A bot auto-action a human overrode (revert of a bot-merge / reopen of a bot-close). */
export type ReversedTarget = {
number: number;
repo: string;
status: string;
eventType: string;
};

/** AgentHealth subset used by ReversalHealthCard (ops.ts:42-55). */
export type ReversalHealth = {
reversals: number;
reversalRate: number;
manualRate: number;
recentAutoActions: number;
reversedTargets?: ReversedTarget[];
};

/** alerts.ts:204 — any human reversal of a bot auto-action is the calibration-regression signal. */
export const REVERSAL_ALERT_MIN = 1;

export function formatRatePct(rate: number): string {
return `${Math.round(rate * 100)}%`;
}

export function formatReversalEventType(eventType: string): string {
if (eventType === "reversal_reverted") return "merge reverted";
if (eventType === "reversal_reopened") return "close reopened";
return eventType.replaceAll("_", " ");
}

export function reversalHealthStatus(health: ReversalHealth): {
tone: "ready" | "warn" | "info";
label: string;
} {
if (health.recentAutoActions === 0) {
return { tone: "info", label: "no auto-actions in window" };
}
if (health.reversals >= REVERSAL_ALERT_MIN) {
return { tone: "warn", label: `${health.reversals} reversal(s)` };
}
return { tone: "ready", label: "0 reversals" };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { ReversalHealthCard } from "@/components/site/app-panels/reversal-health-card";
import {
formatRatePct,
reversalHealthStatus,
type ReversalHealth,
} from "@/components/site/app-panels/reversal-health-card-model";

function health(overrides: Partial<ReversalHealth> = {}): ReversalHealth {
return {
reversals: 0,
reversalRate: 0,
manualRate: 0.1,
recentAutoActions: 20,
reversedTargets: [],
...overrides,
};
}

describe("reversalHealthStatus", () => {
it("returns ready when there are auto-actions but zero reversals", () => {
expect(reversalHealthStatus(health())).toEqual({ tone: "ready", label: "0 reversals" });
});

it("returns warn when reversals meet the documented alert minimum (above-threshold arm)", () => {
expect(reversalHealthStatus(health({ reversals: 2, reversalRate: 0.1 }))).toEqual({
tone: "warn",
label: "2 reversal(s)",
});
});

it("returns info when recentAutoActions is 0 (empty-denominator arm keeps reversalRate at 0)", () => {
expect(reversalHealthStatus(health({ recentAutoActions: 0, reversalRate: 0 }))).toEqual({
tone: "info",
label: "no auto-actions in window",
});
});
});

describe("ReversalHealthCard", () => {
it("renders zero-reversals stats and the empty reversed-targets state", () => {
render(<ReversalHealthCard health={health()} />);
expect(screen.getByText("Reversal health")).toBeTruthy();
expect(screen.getByText("0%")).toBeTruthy();
expect(screen.getByText("0 reversals")).toBeTruthy();
expect(screen.getByText("No reversals in window")).toBeTruthy();
expect(formatRatePct(0.1)).toBe("10%");
expect(screen.getByText("10%")).toBeTruthy();
expect(screen.getByText("20")).toBeTruthy();
});

it("renders above-threshold reversal stats and lists reversed targets", () => {
render(
<ReversalHealthCard
health={health({
reversals: 1,
reversalRate: 0.25,
recentAutoActions: 4,
reversedTargets: [
{
number: 42,
repo: "acme/widgets",
status: "merged",
eventType: "reversal_reopened",
},
],
})}
/>,
);
expect(screen.getByText("25%")).toBeTruthy();
expect(screen.getByText("1 reversal(s)")).toBeTruthy();
expect(screen.getByText("acme/widgets#42")).toBeTruthy();
expect(screen.getByText(/close reopened · merged/)).toBeTruthy();
expect(screen.queryByText("No reversals in window")).toBeNull();
});

it("shows 0% reversal rate when recentAutoActions is 0 (empty-denominator branch)", () => {
render(
<ReversalHealthCard
health={health({ recentAutoActions: 0, reversalRate: 0, reversals: 0 })}
/>,
);
expect(screen.getByText("no auto-actions in window")).toBeTruthy();
expect(screen.getAllByText("0%").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("No reversals in window")).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Stat, StatusPill } from "@/components/site/control-primitives";
import { EmptyState } from "@/components/site/state-views";
import {
formatRatePct,
formatReversalEventType,
reversalHealthStatus,
type ReversalHealth,
} from "@/components/site/app-panels/reversal-health-card-model";

/** Analytics card (#2193): reversal rate and recent auto-action health from computeAgentHealth — read-only
* over the operator-dashboard payload. Lists reversed targets when present; EmptyState when none. */
export function ReversalHealthCard({ health }: { health: ReversalHealth }) {
const status = reversalHealthStatus(health);
const reversedTargets = health.reversedTargets ?? [];

return (
<section className="rounded-token border border-border bg-transparent p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-display text-token-lg font-semibold">Reversal health</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
How often humans reopened or reverted a bot auto-action in the last 7 days. Public-safe
counts only.
</p>
</div>
<StatusPill status={status.tone}>{status.label}</StatusPill>
</div>

<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="Reversal rate"
value={formatRatePct(health.reversalRate)}
hint={
<span className="text-muted-foreground">
reversals / recent auto-actions (7d window)
</span>
}
/>
<Stat
label="Reversals"
value={String(health.reversals)}
hint={<span className="text-muted-foreground">human overrides in window</span>}
/>
<Stat
label="Recent auto-actions"
value={String(health.recentAutoActions)}
hint={<span className="text-muted-foreground">merged + closed in window</span>}
/>
<Stat
label="Manual rate"
value={formatRatePct(health.manualRate)}
hint={<span className="text-muted-foreground">lifetime terminal decisions punted</span>}
/>
</div>

{reversedTargets.length > 0 ? (
<ul className="mt-4 space-y-2">
{reversedTargets.map((target) => (
<li
key={`${target.repo}#${target.number}-${target.eventType}`}
className="flex flex-wrap items-center justify-between gap-2 rounded-token border border-border bg-background/40 px-3 py-2 text-token-sm"
>
<a
href={`https://github.com/${target.repo}/pull/${target.number}`}
target="_blank"
rel="noreferrer"
className="font-mono text-token-xs text-mint hover:underline"
>
{target.repo}#{target.number}
</a>
<span className="text-token-xs text-muted-foreground">
{formatReversalEventType(target.eventType)} · {target.status}
</span>
</li>
))}
</ul>
) : (
<EmptyState
className="mt-4"
title="No reversals in window"
description="When a contributor reopens a bot-close or reverts a bot-merge, the pull request will appear here."
/>
)}
</section>
);
}
4 changes: 4 additions & 0 deletions apps/gittensory-ui/src/routes/app.analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import type { GateEvalReport } from "@/components/site/app-panels/gate-precision-card-model";
import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card";
import type { CycleTimeAggregate } from "@/components/site/app-panels/cycle-time-card-model";
import { ReversalHealthCard } from "@/components/site/app-panels/reversal-health-card";
import type { ReversalHealth } from "@/components/site/app-panels/reversal-health-card-model";
import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell";
import {
AcceptanceRateCard,
Expand Down Expand Up @@ -112,10 +114,11 @@
upstreamDrift?: { status?: string; openReportCount?: number } | null;
gateEval?: GateEvalReport;
cycleTime?: CycleTimeAggregate;
agentHealth?: ReversalHealth;
acceptance?: FindingAcceptance;
};

function ProductAnalytics() {

Check warning on line 121 in apps/gittensory-ui/src/routes/app.analytics.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option
const dashboard = useApiResource<OperatorDashboard>(
"/v1/app/operator-dashboard",
"Product analytics",
Expand Down Expand Up @@ -214,6 +217,7 @@
/>
)}

{data.agentHealth ? <ReversalHealthCard health={data.agentHealth} /> : null}
<AcceptanceRateCard acceptance={data.acceptance} />

{data.usageSummary ? (
Expand Down
3 changes: 3 additions & 0 deletions src/review/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface AgentHealth {
dlqTargets?: FailedTarget[];
reversals: number;
reversalRate: number;
/** Merged + closed auto-actions in the 7d anomaly window — the reversalRate denominator. */
recentAutoActions: number;
failedTargets?: FailedTarget[];
reversedTargets?: ReversedTarget[];
configIssues: string[];
Expand Down Expand Up @@ -229,6 +231,7 @@ export async function computeAgentHealth(env: Env, config: OpsAgentConfig, deps:
dlqTargets,
reversals,
reversalRate: recentAutoActions ? Number((reversals / recentAutoActions).toFixed(3)) : 0,
recentAutoActions,
failedTargets,
reversedTargets,
configIssues: deps.validateAgentConfig(config),
Expand Down
16 changes: 16 additions & 0 deletions src/services/operator-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
WeeklyValueReport,
} from "../types";
import { computeFleetAnalytics, type FleetAnalytics } from "../orb/analytics";
import { computeAgentHealth, type AgentHealth } from "../review/ops";
import { computeGateEval, type GateEvalReport } from "../review/parity";
import { computeCycleTimeAggregate, type CycleTimeAggregate } from "../review/stats";
import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset";
Expand Down Expand Up @@ -66,6 +67,8 @@ export type OperatorDashboardPayload = {
gateEval: GateEvalReport;
// PR review cycle-time percentiles (#2194): gate decision → outcome from review_audit; fail-safe empty aggregate.
cycleTime: CycleTimeAggregate;
// Agent reversal health (#2193): how often humans reopened/reverted bot auto-actions (ops.ts AgentHealth).
agentHealth: AgentHealth;
};

const USAGE_WINDOW_DAYS = 7;
Expand All @@ -91,6 +94,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
fleetMetrics,
gateEval,
cycleTime,
agentHealth,
] = await Promise.all([
listRepositories(env),
listInstallations(env),
Expand All @@ -112,6 +116,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
computeGateEval(env, { days: 90, nowMs: Date.now() }),
// #2194: cycle-time percentiles from the stats feed; fails safe to an empty aggregate.
computeCycleTimeAggregate(env, { days: 90, nowMs: Date.now() }),
computeAgentHealth(env, operatorAgentConfig(env)),
]);
const weeklyValueReport = buildWeeklyValueReport({
generatedAt: nowIso(),
Expand Down Expand Up @@ -207,9 +212,20 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
fleetMetrics,
gateEval,
cycleTime,
agentHealth,
};
}

function operatorAgentConfig(env: Env): { slug: string; secrets: Record<string, never> } {
const slug =
typeof env.GITHUB_APP_SLUG === "string" && env.GITHUB_APP_SLUG.trim()
? env.GITHUB_APP_SLUG.trim()
: "gittensory";
return { slug, secrets: {} };
}

export const __operatorDashboardInternals = { operatorAgentConfig };

export function latestUsageRollup(rollups: ProductUsageDailyRollupRecord[]): ProductUsageDailyRollupRecord | null {
if (rollups.length === 0) return null;
return [...rollups].sort((a, b) => b.day.localeCompare(a.day))[0]!;
Expand Down
52 changes: 51 additions & 1 deletion test/unit/operator-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildOperatorDashboardPayload, latestUsageRollup } from "../../src/services/operator-dashboard";
import { buildOperatorDashboardPayload, latestUsageRollup, __operatorDashboardInternals } from "../../src/services/operator-dashboard";
import type { ProductUsageDailyRollupRecord } from "../../src/types";
import { createTestEnv } from "../helpers/d1";

Expand Down Expand Up @@ -33,6 +33,13 @@ describe("operator dashboard payload", () => {
distribution: [],
sampleSize: 0,
});
expect(payload.agentHealth).toMatchObject({
reversals: 0,
reversalRate: 0,
manualRate: 0,
recentAutoActions: 0,
reversedTargets: [],
});
// Empty fleet → instanceCount 0, null precision card ("—"), no-outlier delta.
expect(payload.fleetMetrics.instanceCount).toBe(0);
expect(payload.metrics).toEqual(
Expand All @@ -43,6 +50,49 @@ describe("operator dashboard payload", () => {
);
});

it("falls back to the default agent slug when GITHUB_APP_SLUG is unset or blank", async () => {
const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "operator-dashboard-test-salt" });
delete (env as Partial<Env>).GITHUB_APP_SLUG;
const missingSlug = await buildOperatorDashboardPayload(env);
expect(missingSlug.agentHealth).toMatchObject({
reversals: 0,
reversalRate: 0,
manualRate: 0,
recentAutoActions: 0,
reversedTargets: [],
});

const blankSlug = await buildOperatorDashboardPayload(
createTestEnv({ PRODUCT_USAGE_HASH_SALT: "operator-dashboard-test-salt", GITHUB_APP_SLUG: " " }),
);
expect(blankSlug.agentHealth).toMatchObject({
reversals: 0,
reversalRate: 0,
manualRate: 0,
recentAutoActions: 0,
reversedTargets: [],
});
});

it("operatorAgentConfig trims a configured slug and falls back when absent", () => {
const { operatorAgentConfig } = __operatorDashboardInternals;
expect(operatorAgentConfig(createTestEnv({ GITHUB_APP_SLUG: " custom-app " }))).toEqual({
slug: "custom-app",
secrets: {},
});
expect(operatorAgentConfig(createTestEnv({ GITHUB_APP_SLUG: "gittensory" }))).toEqual({
slug: "gittensory",
secrets: {},
});
const unset = createTestEnv();
delete (unset as Partial<Env>).GITHUB_APP_SLUG;
expect(operatorAgentConfig(unset)).toEqual({ slug: "gittensory", secrets: {} });
expect(operatorAgentConfig(createTestEnv({ GITHUB_APP_SLUG: "" }))).toEqual({
slug: "gittensory",
secrets: {},
});
});

it("surfaces populated fleet metrics + outliers from orb_signals", async () => {
const env = createTestEnv();
let n = 0;
Expand Down
Loading
Loading