Skip to content
Closed
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,27 @@
// Slop-band calibration card model (#2196). UI-side mirror of SlopOutcomeCalibration from src/review/stats.ts.

export type SlopBand = "clean" | "low" | "elevated" | "high";

export type SlopBandCalibrationRow = {
band: SlopBand;
sampleSize: number;
merged: number;
closed: number;
mergeRate: number;
};

export type SlopBandCalibration = {
totalResolved: number;
bands: SlopBandCalibrationRow[];
overallMergeRate: number | null;
discriminates: boolean | null;
};

export function formatSlopBandLabel(band: SlopBand): string {
return band.charAt(0).toUpperCase() + band.slice(1);
}

export function formatMergeRate(rate: number, sampleSize: number): string {
if (sampleSize <= 0) return "—";
return `${Math.round(rate * 100)}%`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { SlopBandCalibrationCard } from "@/components/site/app-panels/slop-band-calibration-card";
import {
formatMergeRate,
formatSlopBandLabel,
type SlopBandCalibration,
} from "@/components/site/app-panels/slop-band-calibration-card-model";

function calibration(overrides: Partial<SlopBandCalibration> = {}): SlopBandCalibration {
return {
totalResolved: 0,
overallMergeRate: null,
discriminates: null,
bands: [
{ band: "clean", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "low", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "elevated", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "high", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
],
...overrides,
};
}

describe("slop-band calibration formatters", () => {
it("capitalizes band labels and renders merge rate or em dash", () => {
expect(formatSlopBandLabel("elevated")).toBe("Elevated");
expect(formatMergeRate(0.82, 11)).toBe("82%");
expect(formatMergeRate(0.5, 0)).toBe("—");
});
});

describe("SlopBandCalibrationCard", () => {
it("renders all four band rows when every band has samples", () => {
render(
<SlopBandCalibrationCard
calibration={calibration({
totalResolved: 24,
overallMergeRate: 0.75,
discriminates: true,
bands: [
{ band: "clean", sampleSize: 6, merged: 5, closed: 1, mergeRate: 5 / 6 },
{ band: "low", sampleSize: 6, merged: 4, closed: 2, mergeRate: 4 / 6 },
{ band: "elevated", sampleSize: 6, merged: 3, closed: 3, mergeRate: 0.5 },
{ band: "high", sampleSize: 6, merged: 1, closed: 5, mergeRate: 1 / 6 },
],
})}
/>,
);
expect(screen.getByText("Slop-band calibration")).toBeTruthy();
expect(screen.getByText("Clean")).toBeTruthy();
expect(screen.getByText("Low")).toBeTruthy();
expect(screen.getByText("Elevated")).toBeTruthy();
expect(screen.getByText("High")).toBeTruthy();
expect(screen.getByText("predictive")).toBeTruthy();
expect(screen.getByText("Overall merge rate across assessed bands: 75%")).toBeTruthy();
});

it("shows em dash for a single empty band while other bands still render", () => {
render(
<SlopBandCalibrationCard
calibration={calibration({
totalResolved: 12,
overallMergeRate: 0.67,
discriminates: null,
bands: [
{ band: "clean", sampleSize: 6, merged: 4, closed: 2, mergeRate: 4 / 6 },
{ band: "low", sampleSize: 6, merged: 4, closed: 2, mergeRate: 4 / 6 },
{ band: "elevated", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "high", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
],
})}
/>,
);
expect(screen.getAllByText("—")).toHaveLength(2);
expect(screen.getByText("insufficient per-band sample")).toBeTruthy();
});

it("shows inline empty copy and the no-data status pill arm when there are no samples", () => {
render(<SlopBandCalibrationCard calibration={calibration()} />);
expect(screen.getByText("no samples yet")).toBeTruthy();
expect(
screen.getByText(/Resolved pull requests with a persisted slop band will appear here/),
).toBeTruthy();
});

it("surfaces the not-discriminating status arm", () => {
render(
<SlopBandCalibrationCard
calibration={calibration({
totalResolved: 12,
discriminates: false,
bands: [
{ band: "clean", sampleSize: 6, merged: 1, closed: 5, mergeRate: 1 / 6 },
{ band: "low", sampleSize: 6, merged: 2, closed: 4, mergeRate: 2 / 6 },
{ band: "elevated", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "high", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
],
})}
/>,
);
expect(screen.getByText("not discriminating")).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { MiniSparkbar, Stat, StatusPill } from "@/components/site/control-primitives";
import {
formatMergeRate,
formatSlopBandLabel,
type SlopBandCalibration,
} from "@/components/site/app-panels/slop-band-calibration-card-model";

/** Analytics card (#2196): slop-band predicted severity vs realized merge/close outcomes from the stats feed.
* Renders band labels and aggregate rates only — never raw slop scores. */
export function SlopBandCalibrationCard({ calibration }: { calibration: SlopBandCalibration }) {
const hasSamples = calibration.totalResolved > 0;
const statusTone =
calibration.discriminates === true
? "ready"
: calibration.discriminates === false
? "warn"
: "info";
const statusLabel =
calibration.discriminates === true
? "predictive"
: calibration.discriminates === false
? "not discriminating"
: hasSamples
? "insufficient per-band sample"
: "no samples yet";

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">Slop-band calibration</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
Predicted slop band vs realized merge/close outcomes for resolved pull requests.
Public-safe band counts only.
</p>
</div>
<StatusPill status={statusTone}>{statusLabel}</StatusPill>
</div>

{hasSamples ? (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{calibration.bands.map((band) => (
<Stat
key={band.band}
label={formatSlopBandLabel(band.band)}
value={formatMergeRate(band.mergeRate, band.sampleSize)}
hint={
<span className="text-muted-foreground">
{band.sampleSize} assessed · {band.merged} merged · {band.closed} closed
</span>
}
trend={
band.sampleSize > 0 ? (
<MiniSparkbar values={[band.merged, band.closed]} className="w-12" />
) : undefined
}
/>
))}
</div>
{calibration.overallMergeRate !== null ? (
<p className="mt-3 text-token-xs text-muted-foreground">
Overall merge rate across assessed bands:{" "}
{Math.round(calibration.overallMergeRate * 100)}%
</p>
) : null}
</>
) : (
<p className="mt-4 text-token-sm text-muted-foreground">
Resolved pull requests with a persisted slop band will appear here once the gate has
enough outcome history in the analytics window.
</p>
)}
</section>
);
}
7 changes: 7 additions & 0 deletions apps/gittensory-ui/src/routes/app.analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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 { SlopBandCalibrationCard } from "@/components/site/app-panels/slop-band-calibration-card";
import type { SlopBandCalibration } from "@/components/site/app-panels/slop-band-calibration-card-model";
import { useApiResource } from "@/lib/api/use-api-resource";

export const Route = createFileRoute("/app/analytics")({
Expand Down Expand Up @@ -105,9 +107,10 @@
upstreamDrift?: { status?: string; openReportCount?: number } | null;
gateEval?: GateEvalReport;
cycleTime?: CycleTimeAggregate;
slopBandCalibration?: SlopBandCalibration;
};

function ProductAnalytics() {

Check warning on line 113 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 @@ -190,6 +193,10 @@

{data.cycleTime ? <CycleTimeCard cycleTime={data.cycleTime} /> : null}

{data.slopBandCalibration ? (
<SlopBandCalibrationCard calibration={data.slopBandCalibration} />
) : null}

{data.usageSummary ? (
<ProductUsageBreakdownPanel
byEvent={data.usageSummary.byEvent}
Expand Down
42 changes: 41 additions & 1 deletion src/review/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@
// `review_audit` ledgers above, but the maintainer dashboard's complexity read comes from the ACTIVE `audit_events`
// ledger (same `github_app.pr_public_surface_published` rows + `reviewEffortMinutes` metadata public-stats.ts uses).
// Bearer-gated here only — never folded into the public homepage counter.
import { listAllPullRequests } from "../db/repositories";
import {
buildSlopOutcomeCalibration,
type SlopOutcomeCalibration,
} from "../services/outcome-calibration";
import type { PullRequestRecord } from "../types";
import { bandFromMinutes } from "./review-effort";

export type { SlopOutcomeCalibration };

// ── Inlined report types (ported shapes from reviewbot src/core/{eval,tuning}.ts) ────────────────

export interface GateEvalRow {
Expand Down Expand Up @@ -195,6 +203,8 @@ export const EMPTY_CYCLE_TIME: CycleTimeAggregate = {
sampleSize: 0,
};

export const EMPTY_SLOP_BAND_CALIBRATION: SlopOutcomeCalibration = buildSlopOutcomeCalibration([]);

export interface StatsPayload {
generatedAt: string;
window: { fromIso: string; days: number; bucket: string };
Expand All @@ -216,6 +226,8 @@ export interface StatsPayload {
gateParity: GateParityReport & { cutoverReady: Array<{ project: string; ready: boolean }> };
/** PR review cycle-time percentiles (gate decision → outcome) from review_audit (#2194). */
cycleTime: CycleTimeAggregate;
/** Slop-band merge/close calibration over resolved PRs carrying a persisted band (#2196). Bands only — never raw scores. */
slopBandCalibration: SlopOutcomeCalibration;
}

/** ms between the gate decision and the resolution; null if implausible (NaN or negative). */
Expand Down Expand Up @@ -296,6 +308,32 @@ export async function computeCycleTimeAggregate(
}
}

function pullRequestResolvedInWindow(pr: PullRequestRecord, fromMs: number): boolean {
const outcomeAt =
pr.mergedAt ??
pr.closedAt ??
(pr.state === "closed" || pr.state === "merged" ? pr.updatedAt : null);
if (!outcomeAt) return false;
const ms = new Date(outcomeAt).getTime();
return Number.isFinite(ms) && ms >= fromMs;
}

/** Fleet-wide slop-band calibration for the stats feed (#2196). Fail-safe → empty calibration. */
export async function computeSlopBandCalibrationAggregate(
env: Env,
opts: { days: number; nowMs: number },
): Promise<SlopOutcomeCalibration> {
const days = Number.isFinite(opts.days) && opts.days > 0 ? Math.min(opts.days, 730) : 90;
const fromMs = opts.nowMs - days * 86_400_000;
try {
const pullRequests = await listAllPullRequests(env);
const inWindow = pullRequests.filter((pr) => pullRequestResolvedInWindow(pr, fromMs));
return buildSlopOutcomeCalibration(inWindow);
} catch {
return EMPTY_SLOP_BAND_CALIBRATION;
}
}

/** Fold per-PR persisted minutes into the maintainer aggregate (avg band + total minutes). */
export function aggregateReviewEffort(perPrMinutes: number[]): ReviewEffortAggregate {
if (perPrMinutes.length === 0) {
Expand Down Expand Up @@ -324,7 +362,7 @@ export async function computeStats(
const bucketExpr = BUCKET_SQL[bucket] ?? BUCKET_SQL.day;
const fromIso = new Date(opts.nowMs - days * 86_400_000).toISOString().slice(0, 10); // YYYY-MM-DD

const [decisionRows, reversalRows, effortRows, cycleTime] = await Promise.all([
const [decisionRows, reversalRows, effortRows, cycleTime, slopBandCalibration] = await Promise.all([
storage(env).prepare(
`SELECT ${bucketExpr} AS bucket, project, COALESCE(verdict, status) AS verdict, COUNT(*) AS n
FROM review_targets
Expand Down Expand Up @@ -359,6 +397,7 @@ export async function computeStats(
).bind(fromIso).all<{ minutes: number }>()
.catch(() => ({ results: [] as Array<{ minutes: number }> })),
computeCycleTimeAggregate(env, { days, nowMs: opts.nowMs }),
computeSlopBandCalibrationAggregate(env, { days, nowMs: opts.nowMs }),
]);

// Non-content gate decisions (incl. SHADOW would-actions) — recorded as `gate_decision` audit rows with
Expand Down Expand Up @@ -396,6 +435,7 @@ export async function computeStats(
recommendations,
gateParity: { ...parity, cutoverReady: parity.rows.map((r) => ({ project: r.project, ready: isParityCutoverReady(r) })) },
cycleTime,
slopBandCalibration,
};
}

Expand Down
12 changes: 11 additions & 1 deletion src/services/operator-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import type {
} from "../types";
import { computeFleetAnalytics, type FleetAnalytics } from "../orb/analytics";
import { computeGateEval, type GateEvalReport } from "../review/parity";
import { computeCycleTimeAggregate, type CycleTimeAggregate } from "../review/stats";
import {
computeCycleTimeAggregate,
computeSlopBandCalibrationAggregate,
type CycleTimeAggregate,
type SlopOutcomeCalibration,
} from "../review/stats";
import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset";
import { nowIso } from "../utils/json";
import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report";
Expand Down Expand Up @@ -66,6 +71,8 @@ export type OperatorDashboardPayload = {
gateEval: GateEvalReport;
// PR review cycle-time percentiles (#2194): gate decision → outcome from review_audit; fail-safe empty aggregate.
cycleTime: CycleTimeAggregate;
// Slop-band calibration (#2196): predicted band vs realized merge/close outcome; bands only, never raw scores.
slopBandCalibration: SlopOutcomeCalibration;
};

const USAGE_WINDOW_DAYS = 7;
Expand All @@ -91,6 +98,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
fleetMetrics,
gateEval,
cycleTime,
slopBandCalibration,
] = await Promise.all([
listRepositories(env),
listInstallations(env),
Expand All @@ -112,6 +120,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() }),
computeSlopBandCalibrationAggregate(env, { days: 90, nowMs: Date.now() }),
]);
const weeklyValueReport = buildWeeklyValueReport({
generatedAt: nowIso(),
Expand Down Expand Up @@ -207,6 +216,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
fleetMetrics,
gateEval,
cycleTime,
slopBandCalibration,
};
}

Expand Down
11 changes: 11 additions & 0 deletions test/unit/operator-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ describe("operator dashboard payload", () => {
distribution: [],
sampleSize: 0,
});
expect(payload.slopBandCalibration).toEqual({
totalResolved: 0,
overallMergeRate: null,
discriminates: null,
bands: [
{ band: "clean", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "low", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "elevated", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
{ band: "high", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
],
});
// Empty fleet → instanceCount 0, null precision card ("—"), no-outlier delta.
expect(payload.fleetMetrics.instanceCount).toBe(0);
expect(payload.metrics).toEqual(
Expand Down
Loading
Loading