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,21 @@
// Cycle-time analytics card model (#2194). UI-side mirror of the CycleTimeAggregate shape produced by
// computeCycleTimeAggregate (src/review/stats.ts) and surfaced on the operator-dashboard payload. Types +
// pure formatters live here (not in the .tsx) so the component file exports only components
// (react-refresh/only-export-components).

/** PR review cycle-time percentiles (mirror of src/review/stats.ts CycleTimeAggregate). */
export interface CycleTimeAggregate {
p50Ms: number | null;
p90Ms: number | null;
p99Ms: number | null;
distribution: number[];
sampleSize: number;
}

/** Human-readable duration for percentile tiles; null/undefined → em dash. */
export function formatCycleTimeMs(v: number | null | undefined): string {
if (v == null) return "—";
if (v < 60_000) return `${Math.round(v / 1000)}s`;
if (v < 3_600_000) return `${Math.round(v / 60_000)}m`;
return `${(v / 3_600_000).toFixed(1)}h`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card";
import {
formatCycleTimeMs,
type CycleTimeAggregate,
} from "@/components/site/app-panels/cycle-time-card-model";

describe("formatCycleTimeMs", () => {
it("formats seconds, minutes, and hours for present values", () => {
expect(formatCycleTimeMs(45_000)).toBe("45s");
expect(formatCycleTimeMs(120_000)).toBe("2m");
expect(formatCycleTimeMs(7_200_000)).toBe("2.0h");
});

it("renders em dash for nullish percentiles (nullish arm)", () => {
expect(formatCycleTimeMs(null)).toBe("—");
expect(formatCycleTimeMs(undefined)).toBe("—");
});
});

describe("CycleTimeCard", () => {
it("renders p50/p90/p99 tiles and the distribution sparkbar for populated percentiles", () => {
const cycleTime: CycleTimeAggregate = {
p50Ms: 60_000,
p90Ms: 120_000,
p99Ms: 300_000,
distribution: [1, 3, 5, 2],
sampleSize: 11,
};
render(<CycleTimeCard cycleTime={cycleTime} />);
expect(screen.getByText("Review cycle time")).toBeTruthy();
expect(screen.getByText("1m")).toBeTruthy();
expect(screen.getByText("2m")).toBeTruthy();
expect(screen.getByText("5m")).toBeTruthy();
expect(screen.getByText("11 paired PR(s)")).toBeTruthy();
expect(screen.getByText("Cycle-time distribution")).toBeTruthy();
});

it("shows em dashes when percentiles are null (nullish arm)", () => {
const cycleTime: CycleTimeAggregate = {
p50Ms: null,
p90Ms: null,
p99Ms: null,
distribution: [2],
sampleSize: 2,
};
render(<CycleTimeCard cycleTime={cycleTime} />);
expect(screen.getAllByText("—")).toHaveLength(3);
expect(screen.getByText("2 paired PR(s)")).toBeTruthy();
});

it("shows inline empty copy when there are no samples", () => {
const cycleTime: CycleTimeAggregate = {
p50Ms: null,
p90Ms: null,
p99Ms: null,
distribution: [],
sampleSize: 0,
};
render(<CycleTimeCard cycleTime={cycleTime} />);
expect(screen.getByText("no samples yet")).toBeTruthy();
expect(screen.queryByText("Cycle-time distribution")).toBeNull();
});

it("omits the sparkbar when the distribution is empty", () => {
const cycleTime: CycleTimeAggregate = {
p50Ms: 60_000,
p90Ms: 90_000,
p99Ms: 120_000,
distribution: [],
sampleSize: 3,
};
render(<CycleTimeCard cycleTime={cycleTime} />);
expect(screen.getByText("3 paired PR(s)")).toBeTruthy();
expect(screen.queryByText("Cycle-time distribution")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { MiniSparkbar, Stat, StatusPill } from "@/components/site/control-primitives";
import {
formatCycleTimeMs,
type CycleTimeAggregate,
} from "@/components/site/app-panels/cycle-time-card-model";

/** Self-host maintainer analytics card (#2194): PR review cycle-time percentiles (p50/p90/p99) from the stats
* feed, read-only over the operator-dashboard payload. Shows an inline empty state when there are no paired
* gate_decision → pr_outcome samples in the window. */
export function CycleTimeCard({ cycleTime }: { cycleTime: CycleTimeAggregate }) {
const hasSamples = cycleTime.sampleSize > 0;
const hasDistribution = cycleTime.distribution.length > 0;

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">Review cycle time</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
Gate decision → PR outcome duration percentiles from review_audit. Public-safe
aggregates only.
</p>
</div>
<StatusPill status={hasSamples ? "ready" : "info"}>
{hasSamples ? `${cycleTime.sampleSize} paired PR(s)` : "no samples yet"}
</StatusPill>
</div>

{hasSamples ? (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Stat
label="p50"
value={formatCycleTimeMs(cycleTime.p50Ms)}
hint={<span className="text-muted-foreground">median cycle time</span>}
/>
<Stat
label="p90"
value={formatCycleTimeMs(cycleTime.p90Ms)}
hint={<span className="text-muted-foreground">90th percentile</span>}
/>
<Stat
label="p99"
value={formatCycleTimeMs(cycleTime.p99Ms)}
hint={<span className="text-muted-foreground">99th percentile</span>}
/>
</div>
{hasDistribution ? (
<div className="mt-4 rounded-token border border-border bg-background/40 p-3">
<div className="text-token-xs text-muted-foreground">Cycle-time distribution</div>
<MiniSparkbar values={cycleTime.distribution} className="mt-2" />
</div>
) : null}
</>
) : (
<p className="mt-4 text-token-sm text-muted-foreground">
Paired gate decisions and PR outcomes will appear here once the gate has resolved pull
requests in the analytics window.
</p>
)}
</section>
);
}
5 changes: 5 additions & 0 deletions apps/gittensory-ui/src/routes/app.analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
} from "@/components/site/usage-analytics-panels";
import { GatePrecisionCard } from "@/components/site/app-panels/gate-precision-card";
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 { useApiResource } from "@/lib/api/use-api-resource";

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

function ProductAnalytics() {

Check warning on line 109 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 @@ -183,6 +186,8 @@

{data.gateEval ? <GatePrecisionCard report={data.gateEval} /> : null}

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

{data.usageSummary ? (
<ProductUsageBreakdownPanel
byEvent={data.usageSummary.byEvent}
Expand Down
104 changes: 103 additions & 1 deletion src/review/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,26 @@ export interface ReviewEffortAggregate {
totalEstimatedMinutes: number;
}

/** PR review cycle-time percentiles (gate decision → PR outcome) for the maintainer stats feed (#2194). */
export interface CycleTimeAggregate {
/** Milliseconds from gate decision to PR outcome; null when no samples in the window. */
p50Ms: number | null;
p90Ms: number | null;
p99Ms: number | null;
/** Histogram bucket counts for sparkbar visualization; empty when there are no samples. */
distribution: number[];
/** Count of PRs with a paired gate_decision + pr_outcome in the window. */
sampleSize: number;
}

export const EMPTY_CYCLE_TIME: CycleTimeAggregate = {
p50Ms: null,
p90Ms: null,
p99Ms: null,
distribution: [],
sampleSize: 0,
};

export interface StatsPayload {
generatedAt: string;
window: { fromIso: string; days: number; bucket: string };
Expand All @@ -194,6 +214,86 @@ export interface StatsPayload {
recommendations: TuningRec[];
/** Cross-system gate-decision parity: a SHADOW writer's gate decisions vs the authoritative ones. */
gateParity: GateParityReport & { cutoverReady: Array<{ project: string; ready: boolean }> };
/** PR review cycle-time percentiles (gate decision → outcome) from review_audit (#2194). */
cycleTime: CycleTimeAggregate;
}

/** ms between the gate decision and the resolution; null if implausible (NaN or negative). */
export function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {
const ms = new Date(outcomeAt).getTime() - new Date(decidedAt).getTime();
return Number.isFinite(ms) && ms >= 0 ? ms : null;
}

/** Nearest-rank percentile on a pre-sorted sample; null when empty (mirrors orb/analytics.ts). */
export function percentileNearestRank(sorted: number[], p: number): number | null {
if (sorted.length === 0) return null;
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[idx]!;
}

/** Fold cycle-time samples into histogram buckets for the sparkbar; empty input → []. */
export function buildCycleTimeDistribution(samplesMs: number[], bucketCount = 12): number[] {
if (samplesMs.length === 0) return [];
const max = Math.max(...samplesMs);
const min = Math.min(...samplesMs);
if (max === min) return [samplesMs.length];
const buckets = Array.from({ length: bucketCount }, () => 0);
const span = max - min;
for (const ms of samplesMs) {
const idx = Math.min(bucketCount - 1, Math.floor(((ms - min) / span) * bucketCount));
buckets[idx]! += 1;
}
return buckets;
}

/** Pure fold: cycle-time samples → p50/p90/p99 + distribution (#2194). */
export function aggregateCycleTimePercentiles(samplesMs: number[]): CycleTimeAggregate {
const sorted = samplesMs.filter((ms) => Number.isFinite(ms) && ms >= 0).sort((a, b) => a - b);
if (sorted.length === 0) return EMPTY_CYCLE_TIME;
return {
p50Ms: percentileNearestRank(sorted, 50),
p90Ms: percentileNearestRank(sorted, 90),
p99Ms: percentileNearestRank(sorted, 99),
distribution: buildCycleTimeDistribution(sorted),
sampleSize: sorted.length,
};
}

const CYCLE_TIME_SQL = `WITH gd AS (
SELECT target_id, created_at AS decided_at,
ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY created_at DESC) AS rn
FROM review_audit
WHERE event_type = 'gate_decision' AND decision IS NOT NULL AND created_at >= ?
),
po AS (
SELECT target_id, created_at AS outcome_at, ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY created_at DESC) AS rn
FROM review_audit
WHERE event_type = 'pr_outcome' AND decision IS NOT NULL AND created_at >= ?
)
SELECT gd.decided_at AS decided_at, po.outcome_at AS outcome_at
FROM gd
JOIN po ON gd.target_id = po.target_id
WHERE gd.rn = 1 AND po.rn = 1`;

/** Load paired gate_decision → pr_outcome cycle times for the stats window. Fail-safe → empty aggregate. */
export async function computeCycleTimeAggregate(
env: Env,
opts: { days: number; nowMs: number },
): Promise<CycleTimeAggregate> {
const days = Number.isFinite(opts.days) && opts.days > 0 ? Math.min(opts.days, 730) : 90;
const fromIso = new Date(opts.nowMs - days * 86_400_000).toISOString().slice(0, 10);
try {
const rows = await storage(env)
.prepare(CYCLE_TIME_SQL)
.bind(fromIso, fromIso)
.all<{ decided_at: string; outcome_at: string }>();
const samples = (rows.results ?? [])
.map((row) => cycleTimeMs(row.decided_at, row.outcome_at))
.filter((ms): ms is number => ms !== null);
return aggregateCycleTimePercentiles(samples);
} catch {
return EMPTY_CYCLE_TIME;
}
}

/** Fold per-PR persisted minutes into the maintainer aggregate (avg band + total minutes). */
Expand Down Expand Up @@ -224,7 +324,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] = await Promise.all([
const [decisionRows, reversalRows, effortRows, cycleTime] = 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 @@ -258,6 +358,7 @@ export async function computeStats(
)`,
).bind(fromIso).all<{ minutes: number }>()
.catch(() => ({ results: [] as Array<{ minutes: number }> })),
computeCycleTimeAggregate(env, { days, nowMs: opts.nowMs }),
]);

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

Expand Down
7 changes: 7 additions & 0 deletions src/services/operator-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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 { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset";
import { nowIso } from "../utils/json";
import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report";
Expand Down Expand Up @@ -63,6 +64,8 @@ export type OperatorDashboardPayload = {
// Gate-precision eval (#2191): the per-project confusion matrix + precisions from computeGateEval, surfaced
// read-only for the maintainer analytics card. Fail-safe empty report when there is no review_audit signal.
gateEval: GateEvalReport;
// PR review cycle-time percentiles (#2194): gate decision → outcome from review_audit; fail-safe empty aggregate.
cycleTime: CycleTimeAggregate;
};

const USAGE_WINDOW_DAYS = 7;
Expand All @@ -87,6 +90,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
recommendationQuality,
fleetMetrics,
gateEval,
cycleTime,
] = await Promise.all([
listRepositories(env),
listInstallations(env),
Expand All @@ -106,6 +110,8 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
computeFleetAnalytics(env, { windowDays: 90 }),
// #2191: reuse the existing eval (no new compute); it fails safe to an empty report on any read error.
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() }),
]);
const weeklyValueReport = buildWeeklyValueReport({
generatedAt: nowIso(),
Expand Down Expand Up @@ -200,6 +206,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
upstreamDrift,
fleetMetrics,
gateEval,
cycleTime,
};
}

Expand Down
7 changes: 7 additions & 0 deletions test/unit/operator-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ describe("operator dashboard payload", () => {
// #2191: gate-eval report is surfaced read-only; with no review_audit signal it fails safe to an empty
// report (no rows, no signal) rather than being absent.
expect(payload.gateEval).toEqual({ rows: [], hasSignal: false });
expect(payload.cycleTime).toEqual({
p50Ms: null,
p90Ms: null,
p99Ms: null,
distribution: [],
sampleSize: 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