Skip to content
Open
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
3 changes: 3 additions & 0 deletions iris/metrics/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions iris/models/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
208 changes: 208 additions & 0 deletions platform/lib/queries/cycle-time-flow.ts
Original file line number Diff line number Diff line change
@@ -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<FlowPhaseKey> = new Set<FlowPhaseKey>([
"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<Record<FlowPhaseKey, number>>;
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<FlowPhaseKey, number> = {
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<FlowPhaseKey, number>;
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 };
}
17 changes: 17 additions & 0 deletions platform/lib/queries/org-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -630,6 +631,9 @@ export function computeCycleTime(

type Row = NonNullable<CycleTimeData["perRepo"]>[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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}
Expand Down
38 changes: 34 additions & 4 deletions platform/lib/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading