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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
84 changes: 82 additions & 2 deletions iris/analysis/pr_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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,
)
6 changes: 6 additions & 0 deletions iris/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions iris/metrics/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions iris/models/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion iris/reports/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions platform/lib/queries/org-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
OrgPulse,
DeliveryQuality,
AIvsHumanData,
CycleTimeData,
IntentData,
PRHealthData,
HealthMapEntry,
Expand Down Expand Up @@ -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<string, ReportMetrics>,
): CycleTimeData | null {
const repoNameById = new Map(repos.map((r) => [r.id, r.name]));

type Row = NonNullable<CycleTimeData["perRepo"]>[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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading