diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ec51f..a4f9f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to Iris are documented here. The format is based on [Keep a --- +## Unreleased + +### Added + +- **Cycle Time dashboard section** (per-repo open→merge distribution). + The engine now emits `pr_mean_time_to_merge_hours`, + `pr_p90_time_to_merge_hours`, `pr_pct_merged_within_24h`, and + `pr_cycle_time_buckets` alongside the existing median. The platform + uses these to render a new "Cycle Time" section in the org + dashboard: 4 KPI cards (% within 24h, median, mean, P90), a sorted + horizontal bar chart of "% merged within 1 day per repo", and a + stacked bucket chart per repo. Bucket counts are summed exactly + across repos so the org-level distribution and percentage are not + approximations; the org-level median/mean are weighted by per-repo + merged count. + +--- + ## v1.0.7 — Datadog integration: post-launch fixes (2026-05-13) Five fixes that landed after the v1.0.6 cut, surfaced as the customer's diff --git a/docs/METRICS.md b/docs/METRICS.md index da61ebd..3e7fb28 100644 --- a/docs/METRICS.md +++ b/docs/METRICS.md @@ -340,6 +340,10 @@ All fields require GitHub PR data. |---|---|---|---| | `pr_merged_count` | int ≥ 0 | `analysis/pr_lifecycle.py` | no PR data | | `pr_median_time_to_merge_hours` | float ≥ 0 | same | same | +| `pr_mean_time_to_merge_hours` | float ≥ 0 | same | same | +| `pr_p90_time_to_merge_hours` | float ≥ 0 | same | same | +| `pr_pct_merged_within_24h` | float `0.0–1.0` | same | same | +| `pr_cycle_time_buckets` | `{same_day, one_day, two_to_three_days, four_to_seven_days, seven_plus_days}` ints | same | same | | `pr_median_size_files` | int ≥ 0 | same | same | | `pr_median_size_lines` | int ≥ 0 | same | same | | `pr_review_rounds_median` | float ≥ 0 | same | same | @@ -349,6 +353,14 @@ All fields require GitHub PR data. per PR. `pr_single_pass_rate` — fraction of PRs with zero `CHANGES_REQUESTED`. `pr_median_size_lines` — `additions + deletions`. +`pr_cycle_time_buckets` partitions merged PRs by open→merge duration +(boundaries: 24h, 48h, 4d, 8d). The five counts must sum to +`pr_merged_count`. `pr_pct_merged_within_24h` equals +`buckets.same_day / pr_merged_count` (engine emits it pre-computed so +percentile snapshots survive ingestion drops). The platform aggregates +these counts across repos to render the org-level Cycle Time view +without persisting individual PR durations. + --- ## 16. Duplicate block detection @@ -848,7 +860,7 @@ By-origin attribution: | `analysis/attribution_gap.py` | `attribution_gap` | | `analysis/churn_detail.py` | `churn_top_files`, `churn_couplings` | | `analysis/activity_timeline.py` | `activity_timeline`, `activity_patterns` | -| `analysis/pr_lifecycle.py` | `pr_merged_count`, `pr_median_time_to_merge_hours`, `pr_median_size_files`, `pr_median_size_lines`, `pr_review_rounds_median`, `pr_single_pass_rate` | +| `analysis/pr_lifecycle.py` | `pr_merged_count`, `pr_median_time_to_merge_hours`, `pr_mean_time_to_merge_hours`, `pr_p90_time_to_merge_hours`, `pr_pct_merged_within_24h`, `pr_cycle_time_buckets`, `pr_median_size_files`, `pr_median_size_lines`, `pr_review_rounds_median`, `pr_single_pass_rate` | | `analysis/flow_load.py` | `flow_load` | | `analysis/flow_efficiency.py` | `flow_efficiency_median`, `median_time_to_first_review_hours`, `time_in_phase_median_hours`, `flow_efficiency_by_intent`, `flow_efficiency_by_origin` | | `analysis/dora_real.py` | `dora_source`, `dora_deployments_total`, `dora_deployments_failed`, `dora_deployments_pending_evaluation`, `dora_incidents_total`, `dora_cfr`, `dora_mttr_per_deploy_seconds_median`, `dora_mttr_per_deploy_seconds_p90`, `dora_mttr_per_incident_seconds_median`, `dora_mttr_per_incident_seconds_p90`, `dora_rollback_rate`, `dora_rollbacks_total`, `dora_lead_time_seconds_median`, `dora_deploy_frequency_per_day`, `dora_remediation_distribution`, `dora_cfr_by_origin`, `dora_rollback_rate_by_origin`, `dora_cfr_by_origin_coverage_pct` | diff --git a/iris/analysis/pr_lifecycle.py b/iris/analysis/pr_lifecycle.py index f6656f7..5578475 100644 --- a/iris/analysis/pr_lifecycle.py +++ b/iris/analysis/pr_lifecycle.py @@ -3,6 +3,11 @@ Metrics produced: - pr_merged_count: Total PRs merged in the window - pr_median_time_to_merge_hours: Median hours from open to merge +- pr_mean_time_to_merge_hours: Mean (average) hours from open to merge +- pr_p90_time_to_merge_hours: 90th percentile hours from open to merge +- pr_pct_merged_within_24h: Fraction of merged PRs whose cycle time was ≤ 24h +- pr_cycle_time_buckets: Counts of merged PRs across coarse duration buckets + (same_day / one_day / two_to_three_days / four_to_seven_days / seven_plus_days) - pr_median_size_files: Median changed files per PR - pr_median_size_lines: Median total lines (additions + deletions) per PR - pr_review_rounds_median: Median CHANGES_REQUESTED count per PR @@ -12,18 +17,42 @@ Thresholds are NOT defined here — this module only computes values. """ +import math from dataclasses import dataclass -from statistics import median +from statistics import mean, median from iris.models.pull_request import PullRequest +# Cycle-time bucket boundaries in hours. Buckets are right-exclusive +# except the last one, which is open-ended. +_BUCKET_SAME_DAY_MAX_H = 24.0 +_BUCKET_ONE_DAY_MAX_H = 48.0 +_BUCKET_TWO_TO_THREE_DAYS_MAX_H = 96.0 # up to (and including) 4 days from open +_BUCKET_FOUR_TO_SEVEN_DAYS_MAX_H = 192.0 # up to 8 days from open + + +@dataclass(frozen=True) +class CycleTimeBuckets: + """Count of merged PRs whose open→merge duration falls in each bucket.""" + + same_day: int + one_day: int + two_to_three_days: int + four_to_seven_days: int + seven_plus_days: int + + @dataclass(frozen=True) class PRLifecycleResult: """Results from PR lifecycle analysis.""" pr_merged_count: int pr_median_time_to_merge_hours: float + pr_mean_time_to_merge_hours: float + pr_p90_time_to_merge_hours: float + pr_pct_merged_within_24h: float + pr_cycle_time_buckets: CycleTimeBuckets pr_median_size_files: int pr_median_size_lines: int pr_review_rounds_median: float @@ -41,7 +70,7 @@ def analyze_pr_lifecycle(prs: list[PullRequest]) -> PRLifecycleResult | None: prs: PRs from github_reader (may include open/closed/merged). Returns: - PRLifecycleResult with all 6 metrics populated, or None. + PRLifecycleResult with all metrics populated, or None. """ merged = [pr for pr in prs if pr.state == "merged" and pr.merged_at is not None] count = len(merged) @@ -67,11 +96,62 @@ def analyze_pr_lifecycle(prs: list[PullRequest]) -> PRLifecycleResult | None: # Single pass: PRs with zero CHANGES_REQUESTED single_pass_count = sum(1 for rounds in review_rounds if rounds == 0) + buckets = _bucketize(times_to_merge) + pct_within_24h = sum(1 for h in times_to_merge if h <= _BUCKET_SAME_DAY_MAX_H) / count + return PRLifecycleResult( pr_merged_count=count, pr_median_time_to_merge_hours=round(median(times_to_merge), 1), + pr_mean_time_to_merge_hours=round(mean(times_to_merge), 1), + pr_p90_time_to_merge_hours=round(_percentile(times_to_merge, 0.9), 1), + pr_pct_merged_within_24h=round(pct_within_24h, 4), + pr_cycle_time_buckets=buckets, pr_median_size_files=int(median(sizes_files)), pr_median_size_lines=int(median(sizes_lines)), pr_review_rounds_median=round(median(review_rounds), 1), pr_single_pass_rate=round(single_pass_count / count, 2), ) + + +def _percentile(values: list[float], p: float) -> float: + """Return the p-th percentile (0.0–1.0) using nearest-rank. + + Nearest-rank is preferred over linear interpolation here because + we report whole hours rounded to one decimal — interpolation noise + would not survive that rounding anyway. + """ + if not values: + return 0.0 + ordered = sorted(values) + # Nearest-rank index, 1-based then clamped. ceil keeps the + # percentile monotonic and avoids Python's banker's rounding + # quirks (e.g. round(4.5) == 4). + rank = max(1, min(len(ordered), math.ceil(p * len(ordered)))) + return ordered[rank - 1] + + +def _bucketize(hours: list[float]) -> CycleTimeBuckets: + """Count merged PR durations across the five reporting buckets.""" + same_day = 0 + one_day = 0 + two_three = 0 + four_seven = 0 + seven_plus = 0 + for h in hours: + if h <= _BUCKET_SAME_DAY_MAX_H: + same_day += 1 + elif h <= _BUCKET_ONE_DAY_MAX_H: + one_day += 1 + elif h <= _BUCKET_TWO_TO_THREE_DAYS_MAX_H: + two_three += 1 + elif h <= _BUCKET_FOUR_TO_SEVEN_DAYS_MAX_H: + four_seven += 1 + else: + seven_plus += 1 + return CycleTimeBuckets( + same_day=same_day, + one_day=one_day, + two_to_three_days=two_three, + four_to_seven_days=four_seven, + seven_plus_days=seven_plus, + ) diff --git a/iris/i18n.py b/iris/i18n.py index 084ace1..7833ed0 100644 --- a/iris/i18n.py +++ b/iris/i18n.py @@ -60,6 +60,9 @@ "section_pr_lifecycle": "PR Lifecycle", "metric_pr_merged_count": "PRs merged", "metric_pr_median_time_to_merge": "Median time to merge (hours)", + "metric_pr_mean_time_to_merge": "Mean time to merge (hours)", + "metric_pr_p90_time_to_merge": "P90 time to merge (hours)", + "metric_pr_pct_merged_within_24h": "% merged within 24h", "metric_pr_median_size_files": "Median PR size (files)", "metric_pr_median_size_lines": "Median PR size (lines)", "metric_pr_review_rounds_median": "Median review rounds", @@ -877,6 +880,9 @@ "section_pr_lifecycle": "Ciclo de Vida de PRs", "metric_pr_merged_count": "PRs merged", "metric_pr_median_time_to_merge": "Mediana de tempo para merge (horas)", + "metric_pr_mean_time_to_merge": "Média de tempo para merge (horas)", + "metric_pr_p90_time_to_merge": "P90 de tempo para merge (horas)", + "metric_pr_pct_merged_within_24h": "% merged em até 24h", "metric_pr_median_size_files": "Mediana de tamanho do PR (arquivos)", "metric_pr_median_size_lines": "Mediana de tamanho do PR (linhas)", "metric_pr_review_rounds_median": "Mediana de rodadas de revisão", diff --git a/iris/metrics/aggregator.py b/iris/metrics/aggregator.py index 7776c32..e1f11e2 100644 --- a/iris/metrics/aggregator.py +++ b/iris/metrics/aggregator.py @@ -377,6 +377,16 @@ def aggregate( pr_kwargs = { "pr_merged_count": pr_result.pr_merged_count, "pr_median_time_to_merge_hours": pr_result.pr_median_time_to_merge_hours, + "pr_mean_time_to_merge_hours": pr_result.pr_mean_time_to_merge_hours, + "pr_p90_time_to_merge_hours": pr_result.pr_p90_time_to_merge_hours, + "pr_pct_merged_within_24h": pr_result.pr_pct_merged_within_24h, + "pr_cycle_time_buckets": { + "same_day": pr_result.pr_cycle_time_buckets.same_day, + "one_day": pr_result.pr_cycle_time_buckets.one_day, + "two_to_three_days": pr_result.pr_cycle_time_buckets.two_to_three_days, + "four_to_seven_days": pr_result.pr_cycle_time_buckets.four_to_seven_days, + "seven_plus_days": pr_result.pr_cycle_time_buckets.seven_plus_days, + }, "pr_median_size_files": pr_result.pr_median_size_files, "pr_median_size_lines": pr_result.pr_median_size_lines, "pr_review_rounds_median": pr_result.pr_review_rounds_median, diff --git a/iris/models/metrics.py b/iris/models/metrics.py index 16e3983..d448329 100644 --- a/iris/models/metrics.py +++ b/iris/models/metrics.py @@ -82,6 +82,13 @@ class ReportMetrics: # PR lifecycle metrics (optional — None when no GitHub data available) pr_merged_count: int | None = None pr_median_time_to_merge_hours: float | None = None + # Cycle-time distribution — populated alongside pr_merged_count. + # Together they let the platform reconstruct org-level distribution + # by summing bucket counts across repos. + pr_mean_time_to_merge_hours: float | None = None + pr_p90_time_to_merge_hours: float | None = None + pr_pct_merged_within_24h: float | None = None + pr_cycle_time_buckets: dict[str, int] | None = None pr_median_size_files: int | None = None pr_median_size_lines: int | None = None pr_review_rounds_median: float | None = None diff --git a/iris/reports/writer.py b/iris/reports/writer.py index 889cfba..c2abf3d 100644 --- a/iris/reports/writer.py +++ b/iris/reports/writer.py @@ -856,19 +856,34 @@ def write_report_md( # PR Lifecycle section (conditional — only when PR data is available) if metrics.pr_merged_count is not None: - lines.extend([ + pr_rows: list[str] = [ f"## {s['section_pr_lifecycle']}", f"", f"| {s['table_metric']} | {s['table_value']} |", f"|---|---|", f"| {s['metric_pr_merged_count']} | {metrics.pr_merged_count} |", f"| {s['metric_pr_median_time_to_merge']} | {metrics.pr_median_time_to_merge_hours} |", + ] + if metrics.pr_mean_time_to_merge_hours is not None: + pr_rows.append( + f"| {s['metric_pr_mean_time_to_merge']} | {metrics.pr_mean_time_to_merge_hours} |" + ) + if metrics.pr_p90_time_to_merge_hours is not None: + pr_rows.append( + f"| {s['metric_pr_p90_time_to_merge']} | {metrics.pr_p90_time_to_merge_hours} |" + ) + if metrics.pr_pct_merged_within_24h is not None: + pr_rows.append( + f"| {s['metric_pr_pct_merged_within_24h']} | {metrics.pr_pct_merged_within_24h:.0%} |" + ) + pr_rows.extend([ f"| {s['metric_pr_median_size_files']} | {metrics.pr_median_size_files} |", f"| {s['metric_pr_median_size_lines']} | {metrics.pr_median_size_lines} |", f"| {s['metric_pr_review_rounds_median']} | {metrics.pr_review_rounds_median} |", f"| {s['metric_pr_single_pass_rate']} | {metrics.pr_single_pass_rate:.0%} |", f"", ]) + lines.extend(pr_rows) # Delivery Velocity section (conditional — only when enough commits) if velocity is not None: diff --git a/platform/lib/queries/org-summary.ts b/platform/lib/queries/org-summary.ts index f1fad3c..3369994 100644 --- a/platform/lib/queries/org-summary.ts +++ b/platform/lib/queries/org-summary.ts @@ -10,6 +10,7 @@ import type { OrgPulse, DeliveryQuality, AIvsHumanData, + CycleTimeData, IntentData, PRHealthData, HealthMapEntry, @@ -602,6 +603,97 @@ export function computePRHealth( }; } +// --------------------------------------------------------------------------- +// computeCycleTime — per-repo open→merge duration distribution +// --------------------------------------------------------------------------- + +/** + * Aggregate per-repo cycle-time fields emitted by the engine into the + * shape consumed by the CycleTime dashboard section. + * + * Org-level mean/median are weighted by per-repo merged count. We don't + * persist individual PR durations, so this is an approximation — but + * the bucket counts ARE summed exactly across repos, so the + * "% merged within 24h" KPI and the stacked distribution chart are + * not approximations. + */ +export function computeCycleTime( + repos: RepoSummary[], + payloads: Map, +): CycleTimeData | null { + const repoNameById = new Map(repos.map((r) => [r.id, r.name])); + + type Row = NonNullable[number]; + const rows: Row[] = []; + + let totalMerged = 0; + let totalWithin24h = 0; + let medianWeightedSum = 0; + let meanWeightedSum = 0; + let weightTotal = 0; + let maxP90: number | null = null; + + for (const [repoId, p] of payloads) { + const merged = p.pr_merged_count ?? 0; + const buckets = p.pr_cycle_time_buckets; + if (merged <= 0 || !buckets) continue; + + const sum = + buckets.same_day + + buckets.one_day + + buckets.two_to_three_days + + buckets.four_to_seven_days + + buckets.seven_plus_days; + + // The buckets should sum to merged. Trust the engine but fall back + // to the sum to keep percentages internally consistent if a future + // engine version ever changes the bucket boundaries. + const denom = sum > 0 ? sum : merged; + const pctWithin24h = p.pr_pct_merged_within_24h ?? buckets.same_day / denom; + + rows.push({ + name: repoNameById.get(repoId) ?? repoId, + merged, + pctWithin24h, + buckets, + }); + + totalMerged += merged; + totalWithin24h += buckets.same_day; + + if (p.pr_median_time_to_merge_hours !== undefined) { + medianWeightedSum += p.pr_median_time_to_merge_hours * merged; + weightTotal += merged; + } + if (p.pr_mean_time_to_merge_hours !== undefined) { + meanWeightedSum += p.pr_mean_time_to_merge_hours * merged; + } + if (p.pr_p90_time_to_merge_hours !== undefined) { + maxP90 = + maxP90 === null + ? p.pr_p90_time_to_merge_hours + : Math.max(maxP90, p.pr_p90_time_to_merge_hours); + } + } + + if (rows.length === 0) return null; + + // Sort fastest-first by the within-24h share, matching the reference + // dashboard. Ties broken by larger merged volume so a repo with one + // same-day PR doesn't outrank a repo with hundreds. + rows.sort((a, b) => b.pctWithin24h - a.pctWithin24h || b.merged - a.merged); + + return { + reposWithData: rows.length, + totalPRsMerged: totalMerged, + pctMergedWithin24h: totalMerged > 0 ? totalWithin24h / totalMerged : null, + medianHours: weightTotal > 0 ? medianWeightedSum / weightTotal : null, + meanHours: weightTotal > 0 ? meanWeightedSum / weightTotal : null, + p90Hours: maxP90, + perRepo: rows, + }; +} + // --------------------------------------------------------------------------- // computeHealthMap // --------------------------------------------------------------------------- diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 76060f4..70ae6a7 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -370,6 +370,39 @@ export const translations = { human: "Human", ai: "AI-Assisted", }, + cycleTime: { + 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.", + kpi: { + pctWithin24h: "PRs merged within 1 day", + median: "Median cycle time", + mean: "Mean cycle time", + p90: "P90 cycle time", + }, + charts: { + ranking: { + title: "% PRs Merged within 1 Day — by Repo", + subtitle: "Higher is faster", + }, + distribution: { + title: "Cycle Time Distribution — by Repo", + subtitle: "Stacked: more teal/green means faster", + }, + }, + buckets: { + sameDay: "Same day", + oneDay: "1 day", + twoThree: "2–3 days", + fourSeven: "4–7 days", + sevenPlus: "7+ days", + }, + tooltips: { + ranking: "{merged} merged PRs", + }, + }, healthMap: { title: "Health Map", subtitle: "Repository size by commits, colored by stabilization", @@ -1674,6 +1707,39 @@ export const translations = { human: "Humano", ai: "Assistido por IA", }, + cycleTime: { + 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.", + kpi: { + pctWithin24h: "PRs merged em até 1 dia", + median: "Mediana do cycle time", + mean: "Média do cycle time", + p90: "P90 do cycle time", + }, + charts: { + ranking: { + title: "% PRs Merged em até 1 Dia — por Repo", + subtitle: "Quanto maior, mais rápido o repositório entrega", + }, + distribution: { + title: "Distribuição de Cycle Time — por Repo", + subtitle: "Empilhado: quanto mais verde/ciano, mais rápido", + }, + }, + buckets: { + sameDay: "Mesmo dia", + oneDay: "1 dia", + twoThree: "2–3 dias", + fourSeven: "4–7 dias", + sevenPlus: "7+ dias", + }, + tooltips: { + ranking: "{merged} PRs mesclados", + }, + }, healthMap: { title: "Mapa de saúde", subtitle: diff --git a/platform/src/app/[tenant]/dashboard/page.tsx b/platform/src/app/[tenant]/dashboard/page.tsx index 9529de5..46df64c 100644 --- a/platform/src/app/[tenant]/dashboard/page.tsx +++ b/platform/src/app/[tenant]/dashboard/page.tsx @@ -6,6 +6,7 @@ import { getServerSession } from "next-auth/next"; import { RepoList } from "./repo-list"; import { AIDeliveryTimeline } from "./sections/AIDeliveryTimeline"; import { AIvsHuman } from "./sections/AIvsHuman"; +import { CycleTime } from "./sections/CycleTime"; import { DeliveryQuality } from "./sections/DeliveryQuality"; import { DORAOverview } from "./sections/DORAOverview"; import { HealthMap } from "./sections/HealthMap"; @@ -28,6 +29,7 @@ import { computeOrgPulse, computeDeliveryQuality, computeAIvsHuman, + computeCycleTime, computeIntentDistribution, computePRHealth, computeHealthMap, @@ -108,6 +110,7 @@ export default async function OrgDashboardPage({ const aiData = computeAIvsHuman(payloads); const intentData = computeIntentDistribution(payloads); const prData = computePRHealth(repoSummaries, payloads); + const cycleTimeData = computeCycleTime(repoSummaries, payloads); const healthMapEntries = computeHealthMap(repoSummaries); const timelineData = computeOrgTimeline(payloads); const hyperEngineers = computeHyperEngineers( @@ -184,6 +187,9 @@ export default async function OrgDashboardPage({ {/* PR health */} {prData && } + {/* Cycle time — open-to-merge duration distribution per repo */} + {cycleTimeData && } + {/* Health map */} diff --git a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx new file mode 100644 index 0000000..5a39b5a --- /dev/null +++ b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { Zap } from "lucide-react"; + +import { MetricCard } from "@/components/charts/MetricCard"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useTranslation } from "@/hooks/useTranslation"; +import { cn } from "@/lib/utils"; +import type { CycleTimeData } from "@/types/org-summary"; + +// Insight 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; + +// Cutoffs for the "% merged within 24h" horizontal 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/red. +const FAST_GREEN_PCT = 0.8; +const MID_YELLOW_PCT = 0.65; +const SLOW_ORANGE_PCT = 0.5; + +interface CycleTimeProps { + data: CycleTimeData; +} + +export function CycleTime({ data }: CycleTimeProps) { + const { t } = useTranslation(); + const showInsight = + data.totalPRsMerged >= INSIGHT_MIN_MERGED && + data.pctMergedWithin24h !== null; + + return ( +
+
+

+ {t("dashboard.cycleTime.title")} +

+

+ {t("dashboard.cycleTime.subtitle")} +

+
+ + {showInsight && ( + + + +

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

+
+
+ )} + +
+ + + + +
+ +
+ + + + {t("dashboard.cycleTime.charts.ranking.title")} + + + {t("dashboard.cycleTime.charts.ranking.subtitle")} + + + + + + + + + + + {t("dashboard.cycleTime.charts.distribution.title")} + + + {t("dashboard.cycleTime.charts.distribution.subtitle")} + + + + + + + +
+
+ ); +} + +interface Row { + name: string; + merged: number; + pctWithin24h: number; + buckets: CycleTimeData["perRepo"][number]["buckets"]; +} + +function RankingChart({ rows }: { rows: Row[] }) { + const { t } = useTranslation(); + return ( +
+ {rows.map((row) => ( +
+ + {row.name} + +
+
+
+ + {formatPct(row.pctWithin24h)} + +
+ ))} +
+ ); +} + +function DistributionChart({ rows }: { rows: Row[] }) { + return ( +
+ {rows.map((row) => { + const total = + row.buckets.same_day + + row.buckets.one_day + + row.buckets.two_to_three_days + + row.buckets.four_to_seven_days + + row.buckets.seven_plus_days; + if (total === 0) return null; + const pct = (n: number) => (n / total) * 100; + return ( +
+ + {row.name} + +
+ {row.buckets.same_day > 0 && ( + + )} + {row.buckets.one_day > 0 && ( + + )} + {row.buckets.two_to_three_days > 0 && ( + + )} + {row.buckets.four_to_seven_days > 0 && ( + + )} + {row.buckets.seven_plus_days > 0 && ( + + )} +
+
+ ); + })} +
+ ); +} + +function DistributionLegend() { + const { t } = useTranslation(); + const items = [ + { key: "sameDay", className: "bg-bucket-same-day" }, + { key: "oneDay", className: "bg-bucket-one-day" }, + { key: "twoThree", className: "bg-bucket-two-three" }, + { key: "fourSeven", className: "bg-bucket-four-seven" }, + { key: "sevenPlus", className: "bg-bucket-seven-plus" }, + ] as const; + return ( +
+ {items.map((i) => ( + + + {t(`dashboard.cycleTime.buckets.${i.key}`)} + + ))} +
+ ); +} + +function rampClass(pct: number): string { + if (pct >= FAST_GREEN_PCT) return "bg-signal-green"; + if (pct >= MID_YELLOW_PCT) return "bg-bucket-one-day"; // lime/green + if (pct >= SLOW_ORANGE_PCT) return "bg-signal-yellow"; + return "bg-bucket-four-seven"; // orange — slow repo +} + +function formatPct(value: number | null): string { + if (value === null || value === undefined) return "—"; + return `${(value * 100).toFixed(1).replace(".", ",")}%`; +} + +function formatHoursAsDays(hours: number | null): string { + if (hours === null || hours === undefined) return "—"; + const days = hours / 24; + if (days < 1) return `${hours.toFixed(0)} h`; + const rounded = Math.round(days * 10) / 10; + // Show "5 dias" not "5,0 dias" when the value is integral. + const label = Number.isInteger(rounded) + ? rounded.toFixed(0) + : rounded.toFixed(1).replace(".", ","); + return `${label} d`; +} diff --git a/platform/src/styles/globals.css b/platform/src/styles/globals.css index b945ef2..fc9a2ce 100644 --- a/platform/src/styles/globals.css +++ b/platform/src/styles/globals.css @@ -155,6 +155,15 @@ --color-signal-gray: #636363; --color-glow-primary: rgba(165, 40, 255, 0.2); + /* Cycle-time bucket ramp — ordinal scale from "same day" (best) + to "7+ days" (worst). Used by the CycleTime dashboard section + so the legend and stacked bars stay color-coordinated. */ + --color-bucket-same-day: #14b8a6; /* teal — fastest */ + --color-bucket-one-day: #4ade80; /* green */ + --color-bucket-two-three: #f5c542; /* amber */ + --color-bucket-four-seven: #f97316; /* orange — slowing down */ + --color-bucket-seven-plus: #ef4444; /* red — slowest */ + /* Iris categorical palette — pick from here when a chart has multiple unrelated series (e.g., commit intents). Sized for up to 6 distinct categories that read clearly on the dark background and don't all diff --git a/platform/src/types/metrics.ts b/platform/src/types/metrics.ts index f582d33..b825057 100644 --- a/platform/src/types/metrics.ts +++ b/platform/src/types/metrics.ts @@ -308,6 +308,19 @@ export interface ReportMetrics { // PR lifecycle pr_merged_count?: number; pr_median_time_to_merge_hours?: number; + // Cycle-time distribution — populated together with pr_merged_count. + // The platform aggregates bucket counts across repos to render an + // org-level distribution without persisting individual PR durations. + pr_mean_time_to_merge_hours?: number; + pr_p90_time_to_merge_hours?: number; + pr_pct_merged_within_24h?: number; + pr_cycle_time_buckets?: { + same_day: number; + one_day: number; + two_to_three_days: number; + four_to_seven_days: number; + seven_plus_days: number; + }; pr_median_size_files?: number; pr_median_size_lines?: number; pr_review_rounds_median?: number; diff --git a/platform/src/types/org-summary.ts b/platform/src/types/org-summary.ts index af87391..252616f 100644 --- a/platform/src/types/org-summary.ts +++ b/platform/src/types/org-summary.ts @@ -93,6 +93,41 @@ export interface PRHealthData { reposWithData: number; } +/** + * Cycle Time view — how long the org takes to go from PR open to merge. + * + * Aggregates the engine's per-repo cycle-time distribution into both + * org-wide totals (the four KPI cards) and per-repo breakdowns (the + * "% merged within 24h" ranking and the stacked bucket chart). + */ +export interface CycleTimeData { + /** Number of repos that contributed at least one merged PR in the window. */ + reposWithData: number; + /** Total merged PRs across all repos. */ + totalPRsMerged: number; + /** Fraction (0.0–1.0) of merged PRs whose cycle time was ≤ 24h. */ + pctMergedWithin24h: number | null; + /** Hours. Org-level median, weighted by per-repo merged count. */ + medianHours: number | null; + /** Hours. Org-level mean, weighted by per-repo merged count. */ + meanHours: number | null; + /** Hours. Worst-case P90 across repos (max of repo P90s). */ + p90Hours: number | null; + /** Per-repo rows, sorted from fastest to slowest. */ + perRepo: Array<{ + name: string; + merged: number; + pctWithin24h: number; + buckets: { + same_day: number; + one_day: number; + two_to_three_days: number; + four_to_seven_days: number; + seven_plus_days: number; + }; + }>; +} + /** Single entry for the health map treemap. */ export interface HealthMapEntry { name: string; diff --git a/tests/test_pr_lifecycle.py b/tests/test_pr_lifecycle.py new file mode 100644 index 0000000..df95525 --- /dev/null +++ b/tests/test_pr_lifecycle.py @@ -0,0 +1,87 @@ +"""Tests for iris.analysis.pr_lifecycle. + +Covers the cycle-time distribution metrics added so the platform's +"Cycle Time" dashboard section can aggregate per-repo numbers into an +org-wide view without re-storing every PR duration. +""" + +from datetime import datetime, timedelta + +from iris.analysis.pr_lifecycle import analyze_pr_lifecycle +from iris.models.pull_request import PullRequest + + +def _make_pr(number: int, hours_to_merge: float) -> PullRequest: + """Build a merged PR whose open→merge duration is `hours_to_merge`.""" + created = datetime(2026, 1, 1, 9, 0, 0) + merged = created + timedelta(hours=hours_to_merge) + return PullRequest( + number=number, + title=f"PR #{number}", + author="alice", + created_at=created, + additions=10, + deletions=2, + changed_files=3, + merged_at=merged, + closed_at=merged, + state="merged", + ) + + +def test_returns_none_when_no_merged_prs(): + assert analyze_pr_lifecycle([]) is None + + +def test_buckets_cover_all_five_ranges(): + # One PR per bucket: 1h (same_day), 30h (one_day), 72h (two_to_three), + # 150h (four_to_seven), 300h (seven_plus). + prs = [ + _make_pr(1, 1.0), + _make_pr(2, 30.0), + _make_pr(3, 72.0), + _make_pr(4, 150.0), + _make_pr(5, 300.0), + ] + result = analyze_pr_lifecycle(prs) + assert result is not None + b = result.pr_cycle_time_buckets + assert b.same_day == 1 + assert b.one_day == 1 + assert b.two_to_three_days == 1 + assert b.four_to_seven_days == 1 + assert b.seven_plus_days == 1 + + +def test_pct_within_24h_counts_boundary_inclusive(): + # 24.0h must count as within 24h; 24.1h must not. + prs = [_make_pr(1, 24.0), _make_pr(2, 24.1), _make_pr(3, 100.0)] + result = analyze_pr_lifecycle(prs) + assert result is not None + assert result.pr_pct_merged_within_24h == round(1 / 3, 4) + + +def test_mean_and_p90_reflect_inputs(): + # Mean of [2, 4, 6, 8, 100] = 24.0; P90 (nearest-rank index 5) = 100. + prs = [_make_pr(i, h) for i, h in enumerate([2.0, 4.0, 6.0, 8.0, 100.0], start=1)] + result = analyze_pr_lifecycle(prs) + assert result is not None + assert result.pr_mean_time_to_merge_hours == 24.0 + assert result.pr_p90_time_to_merge_hours == 100.0 + + +def test_ignores_non_merged_prs(): + open_pr = PullRequest( + number=99, + title="WIP", + author="bob", + created_at=datetime(2026, 1, 1), + additions=1, + deletions=0, + changed_files=1, + state="open", + ) + merged_pr = _make_pr(1, 5.0) + result = analyze_pr_lifecycle([open_pr, merged_pr]) + assert result is not None + assert result.pr_merged_count == 1