diff --git a/iris/metrics/aggregator.py b/iris/metrics/aggregator.py index 7a7658c..7d0569a 100644 --- a/iris/metrics/aggregator.py +++ b/iris/metrics/aggregator.py @@ -311,6 +311,9 @@ def aggregate( flow_efficiency_kwargs["time_in_phase_median_hours"] = ( flow_efficiency_result.time_in_phase_median_hours ) + flow_efficiency_kwargs["flow_pr_count"] = ( + flow_efficiency_result.pr_count + ) if flow_efficiency_result.median_time_to_first_review_hours is not None: flow_efficiency_kwargs["median_time_to_first_review_hours"] = ( flow_efficiency_result.median_time_to_first_review_hours diff --git a/iris/models/metrics.py b/iris/models/metrics.py index bbf743e..95aa490 100644 --- a/iris/models/metrics.py +++ b/iris/models/metrics.py @@ -132,6 +132,10 @@ class ReportMetrics: flow_efficiency_by_origin: dict[str, float] | None = None time_in_phase_median_hours: dict[str, float] | None = None median_time_to_first_review_hours: float | None = None + # Count of merged PRs that survived Flow Efficiency's filters and carry a + # phase decomposition. The honest denominator for flow coverage — NOT + # pr_merged_count (which also counts PRs with no phase data). + flow_pr_count: int | None = None # Human Review Coverage — fraction of merged PRs with genuine human review # (optional — None when no merged PR exists in the window). Disambiguates diff --git a/platform/lib/queries/cycle-time-flow.ts b/platform/lib/queries/cycle-time-flow.ts new file mode 100644 index 0000000..41ccfb4 --- /dev/null +++ b/platform/lib/queries/cycle-time-flow.ts @@ -0,0 +1,208 @@ +/** + * Cycle Time — flow decomposition + honest verdict selection. + * + * Pure functions behind the dashboard's Cycle Time card. They turn the + * per-repo phase medians the engine already emits (`time_in_phase_median_hours`) + * into an org-level decomposition and a *computed* verdict — replacing the old + * hardcoded editorial sentence about where bottlenecks "live". + * + * The verdict only ever describes the code window it actually measures + * (PR open -> merge); it never claims anything about the "before" (demand) + * or "after" (deploy) windows, which the engine does not measure here. + * + * Because of that, the dominant phase and its share are computed over + * WINDOW_PHASES only — `coding` (first commit -> PR open) is authoring time + * that happens BEFORE the window, so it never wins the verdict. And the share + * denominator is the SUM of per-phase medians: an approximation of where time + * concentrates, NOT the cycle-time median (which comes from a separate + * open->merge aggregation and is shown alongside). + * + * No I/O, no per-PR data: fully unit-testable. + */ +import type { + DominantPhase, + FlowDecomposition, + FlowPhaseKey, +} from "@/types/org-summary"; + +/** Canonical phase order (earliest lifecycle stage first). */ +export const FLOW_PHASE_ORDER: readonly FlowPhaseKey[] = [ + "coding", + "awaiting_first_review", + "in_review_active", + "in_review_wait", + "awaiting_merge", +]; + +/** + * Phases inside the measured window (PR open -> merge). Excludes `coding`, + * which is pre-open authoring time. The verdict's dominant phase and its + * share are computed over these only, so the card never blames a stage that + * happens before the PR exists. + */ +export const WINDOW_PHASES: readonly FlowPhaseKey[] = [ + "awaiting_first_review", + "in_review_active", + "in_review_wait", + "awaiting_merge", +]; + +/** Phases that are *waiting* (queue) rather than *active* (work being done). */ +export const WAIT_PHASES: ReadonlySet = new Set([ + "awaiting_first_review", + "in_review_wait", + "awaiting_merge", +]); + +/** One repo's contribution to the org-level flow aggregation. */ +export interface FlowRow { + /** + * Merged PRs that actually carry a phase decomposition (engine + * `flow_pr_count`) — both the aggregation weight and the coverage + * numerator. Using the real decomposed count (not total merged) is what + * keeps `flowCoveragePct` honest instead of pinned at ~100%. + */ + weight: number; + /** Per-phase median hours for this repo (engine output). */ + phases: Partial>; + ttfrHours?: number | null; + flowEfficiency?: number | null; +} + +function round(value: number, places: number): number { + const factor = 10 ** places; + return Math.round(value * factor) / factor; +} + +/** + * Aggregate per-repo phase medians into an org-level decomposition, weighted + * by each repo's decomposed-PR count (`FlowRow.weight`). This is a weighted + * average of per-repo medians — an approximation (per-PR timings are never + * persisted, by design), so callers MUST gate display on `flowCoveragePct`. + * The dominant phase + share are computed over WINDOW_PHASES (coding is + * pre-open authoring time and never wins). Returns null when no row carried + * decomposed phase data. + */ +export function summarizeFlow( + rows: FlowRow[], + totalMerged: number, +): FlowDecomposition | null { + const weighted: Record = { + coding: 0, + awaiting_first_review: 0, + in_review_active: 0, + in_review_wait: 0, + awaiting_merge: 0, + }; + let weight = 0; + let ttfrWeighted = 0; + let ttfrWeight = 0; + let effWeighted = 0; + let effWeight = 0; + + for (const row of rows) { + if (!row.phases || row.weight <= 0) continue; + weight += row.weight; + for (const key of FLOW_PHASE_ORDER) { + weighted[key] += (row.phases[key] ?? 0) * row.weight; + } + if (row.ttfrHours != null) { + ttfrWeighted += row.ttfrHours * row.weight; + ttfrWeight += row.weight; + } + if (row.flowEfficiency != null) { + effWeighted += row.flowEfficiency * row.weight; + effWeight += row.weight; + } + } + + if (weight <= 0) return null; + + const phaseMedianHours = {} as Record; + for (const key of FLOW_PHASE_ORDER) { + phaseMedianHours[key] = round(weighted[key] / weight, 1); + } + + // Dominant phase + share are computed over the measured window only + // (WINDOW_PHASES excludes `coding`, the pre-PR-open authoring time). + // windowHours is the SUM of those phase medians — an approximation of where + // time concentrates, NOT the cycle-time median. + const windowHours = WINDOW_PHASES.reduce( + (sum, key) => sum + phaseMedianHours[key], + 0, + ); + let dominantPhase: DominantPhase | null = null; + if (windowHours > 0) { + // Ties resolve to the earlier (lower-index) window phase — deterministic. + const key = WINDOW_PHASES.reduce((best, candidate) => + phaseMedianHours[candidate] > phaseMedianHours[best] ? candidate : best, + ); + dominantPhase = { + key, + hours: phaseMedianHours[key], + sharePct: round((phaseMedianHours[key] / windowHours) * 100, 1), + }; + } + + return { + phaseMedianHours, + medianTimeToFirstReviewHours: + ttfrWeight > 0 ? round(ttfrWeighted / ttfrWeight, 1) : null, + flowEfficiencyMedian: + effWeight > 0 ? round(effWeighted / effWeight, 3) : null, + dominantPhase, + prsWithFlow: weight, + flowCoveragePct: totalMerged > 0 ? weight / totalMerged : null, + }; +} + +export type CycleTimeVerdictVariant = + | "verdict" + | "lowCoverage" + | "noFlow" + | "none"; + +export interface CycleTimeVerdict { + variant: CycleTimeVerdictVariant; + dominantPhase: DominantPhase | null; + prsWithFlow: number | null; + /** 0.0–1.0 */ + flowCoveragePct: number | null; +} + +/** + * Decide which honest verdict the card shows. + * + * - `none` — not enough merged PRs (or no within-24h figure): show nothing. + * - `noFlow` — dense enough, but no phase decomposition: state the KPIs and + * explicitly make NO claim about where the bottleneck is. + * - `lowCoverage` — decomposition exists but covers too little: show as a sample. + * - `verdict` — full computed read of the dominant code-window phase. + */ +export function selectCycleTimeVerdict( + data: { + totalPRsMerged: number; + pctMergedWithin24h: number | null; + flow: FlowDecomposition | null; + }, + opts: { minMerged: number; coverageFloor: number }, +): CycleTimeVerdict { + const base = { + dominantPhase: data.flow?.dominantPhase ?? null, + prsWithFlow: data.flow?.prsWithFlow ?? null, + flowCoveragePct: data.flow?.flowCoveragePct ?? null, + }; + if ( + data.totalPRsMerged < opts.minMerged || + data.pctMergedWithin24h === null + ) { + return { variant: "none", ...base }; + } + if (!data.flow || !data.flow.dominantPhase) { + return { variant: "noFlow", ...base }; + } + if ((data.flow.flowCoveragePct ?? 0) < opts.coverageFloor) { + return { variant: "lowCoverage", ...base }; + } + return { variant: "verdict", ...base }; +} diff --git a/platform/lib/queries/org-summary.ts b/platform/lib/queries/org-summary.ts index e065156..96cd8ae 100644 --- a/platform/lib/queries/org-summary.ts +++ b/platform/lib/queries/org-summary.ts @@ -5,6 +5,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import { summarizeFlow, type FlowRow } from "@/lib/queries/cycle-time-flow"; import { DEFAULT_WINDOW_DAYS } from "@/lib/queries/temporal"; import type { ReportMetrics } from "@/types/metrics"; import type { @@ -630,6 +631,9 @@ export function computeCycleTime( type Row = NonNullable[number]; const rows: Row[] = []; + // Per-repo phase decomposition the engine already emits — aggregated below + // into the org-level "where does PR time go" read (see summarizeFlow). + const flowRows: FlowRow[] = []; let totalMerged = 0; let totalWithin24h = 0; @@ -666,6 +670,18 @@ export function computeCycleTime( totalMerged += merged; totalWithin24h += buckets.same_day; + // Only contribute to the flow decomposition when the engine reported the + // real count of decomposed PRs (flow_pr_count). Older payloads without it + // are skipped so coverage stays honest instead of pinning at ~100%. + if (p.time_in_phase_median_hours && (p.flow_pr_count ?? 0) > 0) { + flowRows.push({ + weight: p.flow_pr_count as number, + phases: p.time_in_phase_median_hours, + ttfrHours: p.median_time_to_first_review_hours ?? null, + flowEfficiency: p.flow_efficiency_median ?? null, + }); + } + if (p.pr_median_time_to_merge_hours !== undefined) { medianWeightedSum += p.pr_median_time_to_merge_hours * merged; weightTotal += merged; @@ -695,6 +711,7 @@ export function computeCycleTime( medianHours: weightTotal > 0 ? medianWeightedSum / weightTotal : null, meanHours: weightTotal > 0 ? meanWeightedSum / weightTotal : null, p90Hours: maxP90, + flow: summarizeFlow(flowRows, totalMerged), perRepo: rows, }; } diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index c272ff6..12edd62 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -378,8 +378,23 @@ export const translations = { title: "Cycle Time", subtitle: "How long engineering takes to deliver, from PR open to merge.", - insight: - "Engineering ships fast. {pct} of PRs are merged within a day. Median cycle time is {median}. Lead-time bottlenecks live before (demand/product) and after (infra, environments, deploy) — not in code execution.", + verdict: + "Within the code window (PR open → merge), the stage that consumes the most time is {phase}: {phaseHours} median, {sharePct} of measured phase time. Median cycle time: {median} · {pct} of PRs within 1 day. Based on {n} PRs with phase decomposition ({coverage} coverage).", + verdictLowCoverage: + "Partial sample: only {coverage} of merged PRs carry a phase decomposition — a hint, not a verdict. Where data exists, the longest stage is {phase} ({phaseHours}).", + verdictNoFlow: + "Median cycle time {median}; {pct} of PRs within 1 day. Phase decomposition isn't available for this view yet — without it, the dashboard makes no claim about where the bottleneck is.", + flowBarTitle: "Where PR time goes", + flowBarSubtitle: + "Code-window time by phase (median, weighted by decomposed PRs).", + waitNote: "Hatched = queue (wait) time.", + phaseLabels: { + coding: "Coding", + awaiting_first_review: "Awaiting first review", + in_review_active: "In review · active", + in_review_wait: "In review · wait", + awaiting_merge: "Awaiting merge", + }, kpi: { pctWithin24h: "PRs merged within 1 day", median: "Median cycle time", @@ -1757,8 +1772,23 @@ export const translations = { title: "Cycle Time", subtitle: "Quanto tempo a engenharia leva para entregar, do PR aberto ao merge.", - insight: - "A engenharia entrega rápido. {pct} dos PRs são mesclados em até 1 dia. A mediana do cycle time é {median}. Os gargalos de lead time estão antes (definição de demanda/produto) e depois (infra, ambientes, deploy) — não na execução do código.", + verdict: + "Dentro da janela de código (PR aberto → merge), a etapa que mais consome tempo é {phase}: mediana {phaseHours}, {sharePct} do tempo das fases medidas. Mediana do cycle time: {median} · {pct} dos PRs em ≤1 dia. Base: {n} PRs com decomposição de fase ({coverage} de cobertura).", + verdictLowCoverage: + "Amostra parcial: só {coverage} dos PRs mesclados têm decomposição de fase — indício, não veredito. Onde há dado, a etapa mais longa é {phase} ({phaseHours}).", + verdictNoFlow: + "Mediana do cycle time {median}; {pct} dos PRs em ≤1 dia. A decomposição por fase ainda não está disponível para este recorte — sem ela, o dashboard não afirma onde está o gargalo.", + flowBarTitle: "Onde o tempo do PR é gasto", + flowBarSubtitle: + "Tempo da janela de código por fase (mediana, ponderada por PRs decompostos).", + waitNote: "Hachurado = tempo em fila (espera).", + phaseLabels: { + coding: "Escrita de código", + awaiting_first_review: "Espera pelo 1º review", + in_review_active: "Em review · ativo", + in_review_wait: "Em review · espera", + awaiting_merge: "Espera pelo merge", + }, kpi: { pctWithin24h: "PRs merged em até 1 dia", median: "Mediana do cycle time", diff --git a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx index a651c95..c0e8605 100644 --- a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx +++ b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx @@ -12,6 +12,7 @@ import { Cell, } from "recharts"; +import { FlowPhaseBar } from "@/components/charts/FlowPhaseBar"; import { MetricCard } from "@/components/charts/MetricCard"; import { Card, @@ -21,13 +22,22 @@ import { CardTitle, } from "@/components/ui/card"; import { useTranslation } from "@/hooks/useTranslation"; -import type { CycleTimeData } from "@/types/org-summary"; +import { + selectCycleTimeVerdict, + type CycleTimeVerdict, +} from "@/lib/queries/cycle-time-flow"; +import { cn } from "@/lib/utils"; +import type { CycleTimeData, FlowPhaseKey } from "@/types/org-summary"; -// Insight banner is only shown once cycle-time data is dense enough +// The verdict banner is only shown once cycle-time data is dense enough // to make a confident statement. Below this many merged PRs we still // render the section but hide the headline. const INSIGHT_MIN_MERGED = 50; +// Below this coverage the phase decomposition is shown as a partial sample, +// never as a verdict. See selectCycleTimeVerdict. +const COVERAGE_FLOOR = 0.6; + // Cutoffs for the "% merged within 24h" bar color ramp. Tuned so a repo that // ships in a day most of the time reads green, "mixed" reads yellow, and slow // repos read orange. @@ -74,9 +84,15 @@ interface CycleTimeProps { export function CycleTime({ data }: CycleTimeProps) { const { t } = useTranslation(); - const showInsight = - data.totalPRsMerged >= INSIGHT_MIN_MERGED && - data.pctMergedWithin24h !== null; + const verdict = selectCycleTimeVerdict( + { + totalPRsMerged: data.totalPRsMerged, + pctMergedWithin24h: data.pctMergedWithin24h, + flow: data.flow, + }, + { minMerged: INSIGHT_MIN_MERGED, coverageFloor: COVERAGE_FLOOR }, + ); + const verdictText = buildVerdictText(verdict, data, t); return (
@@ -89,16 +105,44 @@ export function CycleTime({ data }: CycleTimeProps) {

- {showInsight && ( - + {verdictText && ( + - -

- {t("dashboard.cycleTime.insight", { - pct: formatPct(data.pctMergedWithin24h), - median: formatHoursAsDays(data.medianHours), - })} -

+ +

{verdictText.text}

+
+
+ )} + + {verdict.variant === "verdict" && data.flow && verdict.dominantPhase && ( + + + {t("dashboard.cycleTime.flowBarTitle")} + + {t("dashboard.cycleTime.flowBarSubtitle")} + + + + )} @@ -339,3 +383,77 @@ function formatHoursAsDays(hours: number | null): string { : rounded.toFixed(1).replace(".", ","); return `${label} d`; } + +type Translate = ( + path: string, + params?: Record, +) => string; + +function phaseLabels(t: Translate): Record { + return { + coding: t("dashboard.cycleTime.phaseLabels.coding"), + awaiting_first_review: t( + "dashboard.cycleTime.phaseLabels.awaiting_first_review", + ), + in_review_active: t("dashboard.cycleTime.phaseLabels.in_review_active"), + in_review_wait: t("dashboard.cycleTime.phaseLabels.in_review_wait"), + awaiting_merge: t("dashboard.cycleTime.phaseLabels.awaiting_merge"), + }; +} + +type VerdictText = { text: string; tone: "signal" | "muted" }; + +/** + * Turn a computed verdict into the banner copy. Only ever describes the code + * window it measured — never claims anything about "before" or "after". + */ +function buildVerdictText( + verdict: CycleTimeVerdict, + data: CycleTimeData, + t: Translate, +): VerdictText | null { + if (verdict.variant === "none") return null; + + const median = formatHoursAsDays(data.medianHours); + const pct = formatPct(data.pctMergedWithin24h); + + if (verdict.variant === "noFlow") { + return { + tone: "muted", + text: t("dashboard.cycleTime.verdictNoFlow", { median, pct }), + }; + } + + const dp = verdict.dominantPhase; + if (!dp) return null; + const phase = phaseLabels(t)[dp.key]; + const phaseHours = formatHoursAsDays(dp.hours); + const coverage = + verdict.flowCoveragePct !== null + ? `${Math.round(verdict.flowCoveragePct * 100)}%` + : "—"; + + if (verdict.variant === "lowCoverage") { + return { + tone: "muted", + text: t("dashboard.cycleTime.verdictLowCoverage", { + coverage, + phase, + phaseHours, + }), + }; + } + + return { + tone: "signal", + text: t("dashboard.cycleTime.verdict", { + phase, + phaseHours, + sharePct: `${dp.sharePct.toFixed(0)}%`, + median, + pct, + n: verdict.prsWithFlow ?? 0, + coverage, + }), + }; +} diff --git a/platform/src/components/charts/FlowPhaseBar.tsx b/platform/src/components/charts/FlowPhaseBar.tsx new file mode 100644 index 0000000..36d5bd1 --- /dev/null +++ b/platform/src/components/charts/FlowPhaseBar.tsx @@ -0,0 +1,115 @@ +import { WAIT_PHASES, WINDOW_PHASES } from "@/lib/queries/cycle-time-flow"; +import { cn } from "@/lib/utils"; +import type { FlowPhaseKey } from "@/types/org-summary"; + +/** + * Tailwind colors per lifecycle phase. Mirrors the per-repo Flow Efficiency + * card so the org-level bar reads consistently across the product. + */ +export const FLOW_PHASE_COLORS: Record = { + coding: "bg-signal-green", + awaiting_first_review: "bg-signal-yellow", + in_review_active: "bg-signal-purple", + in_review_wait: "bg-signal-red", + awaiting_merge: "bg-signal-gray", +}; + +interface FlowPhaseBarProps { + /** Median hours per phase (org-aggregated). */ + phaseHours: Partial>; + /** Localized label per phase. */ + labels: Record; + /** Format a phase duration for display (e.g. "14 h"). */ + formatHours: (hours: number) => string; + /** Phase to highlight as the dominant (widest) one, if any. */ + dominantKey?: FlowPhaseKey | null; + /** Localized footnote explaining the wait-phase hatch (optional). */ + waitNote?: string; +} + +/** + * Horizontal stacked bar + legend showing how the measured code window + * (PR open -> merge) splits across its phases. Excludes `coding` (pre-open + * authoring time), matching the verdict's window. Presentational only. + */ +export function FlowPhaseBar({ + phaseHours, + labels, + formatHours, + dominantKey, + waitNote, +}: FlowPhaseBarProps) { + const total = WINDOW_PHASES.reduce( + (sum, key) => sum + (phaseHours[key] ?? 0), + 0, + ); + if (total <= 0) return null; + + return ( +
+
+ {WINDOW_PHASES.map((key) => { + const hours = phaseHours[key] ?? 0; + if (hours === 0) return null; + const pct = (hours / total) * 100; + return ( +
+ ); + })} +
+
+ {WINDOW_PHASES.map((key) => { + const hours = phaseHours[key] ?? 0; + if (hours === 0) return null; + return ( +
+ + + + {labels[key]} + + + + {formatHours(hours)} + +
+ ); + })} +
+ {waitNote && ( +

+ {waitNote} +

+ )} +
+ ); +} diff --git a/platform/src/types/metrics.ts b/platform/src/types/metrics.ts index 2c9ce81..b09a030 100644 --- a/platform/src/types/metrics.ts +++ b/platform/src/types/metrics.ts @@ -261,6 +261,9 @@ export interface ReportMetrics { awaiting_merge?: number; }; median_time_to_first_review_hours?: number; + // Merged PRs that actually carry a phase decomposition — the honest + // denominator for flow coverage (not pr_merged_count). + flow_pr_count?: number; // Human Review Coverage — fraction of merged PRs with genuine human review. // Disambiguates pr_single_pass_rate: "merged in one pass" vs "no human looked". diff --git a/platform/src/types/org-summary.ts b/platform/src/types/org-summary.ts index 962f604..4951de0 100644 --- a/platform/src/types/org-summary.ts +++ b/platform/src/types/org-summary.ts @@ -93,6 +93,44 @@ export interface PRHealthData { reposWithData: number; } +/** The five lifecycle phases of a merged PR, in canonical order. */ +export type FlowPhaseKey = + | "coding" + | "awaiting_first_review" + | "in_review_active" + | "in_review_wait" + | "awaiting_merge"; + +/** The phase that consumes the most time within the measured window. */ +export interface DominantPhase { + key: FlowPhaseKey; + /** Org-weighted median hours spent in this phase. */ + hours: number; + /** + * This phase's share of the summed window-phase medians (an approximation + * of where time concentrates — NOT a share of the cycle-time median), 0–100. + * Computed over the post-open window only (excludes `coding`). + */ + sharePct: number; +} + +/** + * Org-level decomposition of the code window (PR open → merge) into phases. + * Weighted by each repo's decomposed-PR count — an approximation (per-PR + * timings are never persisted, by design), so display is gated on + * `flowCoveragePct`. `dominantPhase` is chosen over the post-open window only. + */ +export interface FlowDecomposition { + phaseMedianHours: Record; + medianTimeToFirstReviewHours: number | null; + flowEfficiencyMedian: number | null; + dominantPhase: DominantPhase | null; + /** Merged PRs that carried a phase decomposition (the aggregation weight). */ + prsWithFlow: number; + /** prsWithFlow ÷ total merged PRs, 0.0–1.0. */ + flowCoveragePct: number | null; +} + /** * Cycle Time view — how long the org takes to go from PR open to merge. * @@ -113,6 +151,12 @@ export interface CycleTimeData { meanHours: number | null; /** Hours. Worst-case P90 across repos (max of repo P90s). */ p90Hours: number | null; + /** + * Code-window phase decomposition + dominant phase. Null when no repo in + * the window carried phase data (older payloads). This is what replaces the + * old hardcoded "bottleneck" sentence with a computed read of the tenant. + */ + flow: FlowDecomposition | null; /** Per-repo rows, sorted from fastest to slowest. */ perRepo: Array<{ name: string; diff --git a/platform/tests/cycle-time-flow.test.ts b/platform/tests/cycle-time-flow.test.ts new file mode 100644 index 0000000..a372ff5 --- /dev/null +++ b/platform/tests/cycle-time-flow.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it } from "vitest"; + +import { + FLOW_PHASE_ORDER, + WAIT_PHASES, + WINDOW_PHASES, + selectCycleTimeVerdict, + summarizeFlow, + type FlowRow, +} from "@/lib/queries/cycle-time-flow"; +import { computeCycleTime } from "@/lib/queries/org-summary"; +import type { ReportMetrics } from "@/types/metrics"; +import type { FlowDecomposition } from "@/types/org-summary"; +import type { RepoSummary } from "@/types/temporal"; + +// --------------------------------------------------------------------------- +// summarizeFlow +// --------------------------------------------------------------------------- + +describe("summarizeFlow", () => { + it("weights per-repo phase medians by the decomposed-PR count and picks the window dominant", () => { + const rows: FlowRow[] = [ + { weight: 10, phases: { awaiting_first_review: 8, in_review_active: 2 } }, + { weight: 30, phases: { awaiting_first_review: 2, in_review_active: 6 } }, + ]; + const out = summarizeFlow(rows, 40)!; + + // awaiting = (8*10 + 2*30)/40 = 3.5 ; active = (2*10 + 6*30)/40 = 5 + expect(out.phaseMedianHours.awaiting_first_review).toBe(3.5); + expect(out.phaseMedianHours.in_review_active).toBe(5); + expect(out.dominantPhase).toEqual({ + key: "in_review_active", + hours: 5, + sharePct: 58.8, // round(5 / 8.5 * 100, 1) + }); + expect(WAIT_PHASES.has(out.dominantPhase!.key)).toBe(false); + expect(out.prsWithFlow).toBe(40); + expect(out.flowCoveragePct).toBe(1); + }); + + it("never lets 'coding' (pre-PR-open authoring) win the verdict — M2", () => { + // coding is the largest phase, but it is OUTSIDE the measured window. + const rows: FlowRow[] = [ + { + weight: 10, + phases: { coding: 20, awaiting_first_review: 8, in_review_wait: 2 }, + }, + ]; + const out = summarizeFlow(rows, 10)!; + // coding is still computed in the data... + expect(out.phaseMedianHours.coding).toBe(20); + // ...but the dominant is the largest WINDOW phase, and the share denominator + // is the window total (8 + 2 = 10), not the all-phase total. + expect(out.dominantPhase?.key).toBe("awaiting_first_review"); + expect(out.dominantPhase?.hours).toBe(8); + expect(out.dominantPhase?.sharePct).toBe(80); // 8 / 10 window total + expect(WAIT_PHASES.has(out.dominantPhase!.key)).toBe(true); + }); + + it("reports partial coverage from the decomposed count, not total merged — M3", () => { + const rows: FlowRow[] = [{ weight: 20, phases: { in_review_wait: 4 } }]; + const out = summarizeFlow(rows, 50)!; // 50 merged, only 20 decomposed + expect(out.prsWithFlow).toBe(20); + expect(out.flowCoveragePct).toBe(0.4); + }); + + it("weights TTFR and flow efficiency only over rows that carry them", () => { + const rows: FlowRow[] = [ + { + weight: 10, + phases: { awaiting_first_review: 1 }, + ttfrHours: 12, + flowEfficiency: 0.5, + }, + { weight: 30, phases: { awaiting_first_review: 1 }, ttfrHours: null }, + ]; + const out = summarizeFlow(rows, 40)!; + expect(out.medianTimeToFirstReviewHours).toBe(12); + expect(out.flowEfficiencyMedian).toBe(0.5); + }); + + it("returns null when no row carries decomposed phase data", () => { + expect(summarizeFlow([], 10)).toBeNull(); + expect( + summarizeFlow([{ weight: 0, phases: { awaiting_first_review: 5 } }], 10), + ).toBeNull(); + }); + + it("has no dominant phase when every window phase is zero (even if coding > 0)", () => { + const out = summarizeFlow([{ weight: 5, phases: { coding: 9 } }], 5)!; + expect(out.dominantPhase).toBeNull(); + expect(out.phaseMedianHours.coding).toBe(9); + expect(out.prsWithFlow).toBe(5); + expect(out.flowCoveragePct).toBe(1); + }); + + it("returns null coverage when the total merged count is unknown", () => { + const out = summarizeFlow( + [{ weight: 5, phases: { awaiting_first_review: 2 } }], + 0, + )!; + expect(out.flowCoveragePct).toBeNull(); + }); + + it("breaks ties toward the earlier window phase deterministically", () => { + const out = summarizeFlow( + [{ weight: 1, phases: { awaiting_first_review: 4, awaiting_merge: 4 } }], + 1, + )!; + expect(out.dominantPhase?.key).toBe("awaiting_first_review"); + }); + + it("exposes the canonical order, the window phases, and the wait-phase set", () => { + expect(FLOW_PHASE_ORDER).toEqual([ + "coding", + "awaiting_first_review", + "in_review_active", + "in_review_wait", + "awaiting_merge", + ]); + expect(WINDOW_PHASES).toEqual([ + "awaiting_first_review", + "in_review_active", + "in_review_wait", + "awaiting_merge", + ]); + expect(WINDOW_PHASES).not.toContain("coding"); + expect(WAIT_PHASES.has("in_review_wait")).toBe(true); + expect(WAIT_PHASES.has("in_review_active")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// selectCycleTimeVerdict +// --------------------------------------------------------------------------- + +const OPTS = { minMerged: 50, coverageFloor: 0.6 }; + +function flow(over: Partial): FlowDecomposition { + return { + phaseMedianHours: { + coding: 1, + awaiting_first_review: 4, + in_review_active: 1, + in_review_wait: 1, + awaiting_merge: 1, + }, + medianTimeToFirstReviewHours: 4, + flowEfficiencyMedian: 0.3, + dominantPhase: { + key: "awaiting_first_review", + hours: 4, + sharePct: 57.1, + }, + prsWithFlow: 80, + flowCoveragePct: 0.9, + ...over, + }; +} + +describe("selectCycleTimeVerdict", () => { + it("returns none when there are too few merged PRs", () => { + const v = selectCycleTimeVerdict( + { totalPRsMerged: 10, pctMergedWithin24h: 0.7, flow: flow({}) }, + OPTS, + ); + expect(v.variant).toBe("none"); + }); + + it("returns none when the within-24h figure is missing", () => { + const v = selectCycleTimeVerdict( + { totalPRsMerged: 100, pctMergedWithin24h: null, flow: flow({}) }, + OPTS, + ); + expect(v.variant).toBe("none"); + }); + + it("returns noFlow when there is no decomposition", () => { + const v = selectCycleTimeVerdict( + { totalPRsMerged: 100, pctMergedWithin24h: 0.7, flow: null }, + OPTS, + ); + expect(v.variant).toBe("noFlow"); + expect(v.dominantPhase).toBeNull(); + }); + + it("returns noFlow when the decomposition has no dominant phase", () => { + const v = selectCycleTimeVerdict( + { + totalPRsMerged: 100, + pctMergedWithin24h: 0.7, + flow: flow({ dominantPhase: null }), + }, + OPTS, + ); + expect(v.variant).toBe("noFlow"); + }); + + it("returns lowCoverage below the coverage floor", () => { + const v = selectCycleTimeVerdict( + { + totalPRsMerged: 100, + pctMergedWithin24h: 0.7, + flow: flow({ flowCoveragePct: 0.4 }), + }, + OPTS, + ); + expect(v.variant).toBe("lowCoverage"); + expect(v.flowCoveragePct).toBe(0.4); + }); + + it("returns the full verdict at or above the coverage floor", () => { + const v = selectCycleTimeVerdict( + { + totalPRsMerged: 100, + pctMergedWithin24h: 0.7, + flow: flow({ flowCoveragePct: 0.6 }), + }, + OPTS, + ); + expect(v.variant).toBe("verdict"); + expect(v.dominantPhase?.key).toBe("awaiting_first_review"); + expect(v.prsWithFlow).toBe(80); + }); +}); + +// --------------------------------------------------------------------------- +// computeCycleTime — flow wiring (integration) +// --------------------------------------------------------------------------- + +const repos = [ + { id: "r1", name: "checkout" }, + { id: "r2", name: "orders" }, +] as unknown as RepoSummary[]; + +function metrics(over: Partial): ReportMetrics { + return { + commits_total: 0, + commits_revert: 0, + revert_rate: 0, + churn_events: 0, + churn_lines_affected: 0, + files_touched: 0, + files_stabilized: 0, + stabilization_ratio: 0, + pr_cycle_time_buckets: { + same_day: 0, + one_day: 0, + two_to_three_days: 0, + four_to_seven_days: 0, + seven_plus_days: 0, + }, + ...over, + } as ReportMetrics; +} + +describe("computeCycleTime — flow decomposition", () => { + it("aggregates phase data weighted by flow_pr_count and excludes coding", () => { + const payloads = new Map(); + payloads.set( + "r1", + metrics({ + pr_merged_count: 20, + flow_pr_count: 20, + pr_cycle_time_buckets: { + same_day: 12, + one_day: 4, + two_to_three_days: 2, + four_to_seven_days: 1, + seven_plus_days: 1, + }, + time_in_phase_median_hours: { coding: 30, awaiting_first_review: 10 }, + }), + ); + payloads.set( + "r2", + metrics({ + pr_merged_count: 30, + flow_pr_count: 30, + pr_cycle_time_buckets: { + same_day: 20, + one_day: 6, + two_to_three_days: 2, + four_to_seven_days: 1, + seven_plus_days: 1, + }, + time_in_phase_median_hours: { coding: 30, awaiting_first_review: 3 }, + }), + ); + + const out = computeCycleTime(repos, payloads)!; + expect(out.flow).not.toBeNull(); + // awaiting = (10*20 + 3*30)/50 = 5.8 ; coding present but EXCLUDED from dominant + expect(out.flow!.phaseMedianHours.awaiting_first_review).toBe(5.8); + expect(out.flow!.phaseMedianHours.coding).toBe(30); + expect(out.flow!.dominantPhase?.key).toBe("awaiting_first_review"); + expect(out.flow!.flowCoveragePct).toBe(1); // 50 decomposed / 50 merged + }); + + it("yields partial coverage when flow_pr_count < merged — M3 guard-rail is live", () => { + const payloads = new Map(); + payloads.set( + "r1", + metrics({ + pr_merged_count: 100, + flow_pr_count: 40, // only 40 of 100 merged PRs were decomposed + pr_cycle_time_buckets: { + same_day: 60, + one_day: 20, + two_to_three_days: 10, + four_to_seven_days: 6, + seven_plus_days: 4, + }, + time_in_phase_median_hours: { awaiting_first_review: 9 }, + }), + ); + const out = computeCycleTime(repos, payloads)!; + expect(out.flow!.flowCoveragePct).toBe(0.4); + expect(out.flow!.prsWithFlow).toBe(40); + }); + + it("leaves data.flow null when phase data has no flow_pr_count (old payloads) — M3", () => { + const payloads = new Map(); + payloads.set( + "r1", + metrics({ + pr_merged_count: 60, + // time_in_phase present but NO flow_pr_count → cannot trust coverage + time_in_phase_median_hours: { awaiting_first_review: 5 }, + pr_cycle_time_buckets: { + same_day: 40, + one_day: 12, + two_to_three_days: 4, + four_to_seven_days: 2, + seven_plus_days: 2, + }, + }), + ); + const out = computeCycleTime(repos, payloads)!; + expect(out.flow).toBeNull(); + }); + + it("leaves data.flow null when no payload carries phase data", () => { + const payloads = new Map(); + payloads.set( + "r1", + metrics({ + pr_merged_count: 10, + flow_pr_count: 10, + pr_cycle_time_buckets: { + same_day: 8, + one_day: 2, + two_to_three_days: 0, + four_to_seven_days: 0, + seven_plus_days: 0, + }, + }), + ); + const out = computeCycleTime(repos, payloads)!; + expect(out.flow).toBeNull(); + }); +});