Cycle time scatter
GitHub closed PRs, merge time with percentile lines
Example from the compendium of canonical charts
Python Code
"""Cycle time scatter — GitHub closed PRs, merge time with percentile lines."""
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import requests, warnings, time
REPO = "plotly/plotly.js"
PRS_URL = f"https://api.github.com/repos/{REPO}/pulls"
def fetch_prs():
warnings.filterwarnings("ignore")
requests.packages.urllib3.disable_warnings()
headers = {"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"}
prs = []
page = 1
while len(prs) < 300:
r = requests.get(
PRS_URL,
params={"state": "closed", "per_page": 100, "page": page},
headers=headers,
verify=False,
timeout=30,
)
if r.status_code != 200:
print(f" page {page}: status={r.status_code}")
break
batch = r.json()
if not batch:
break
# Only include merged PRs (not just closed)
merged = [p for p in batch if p.get("merged_at")]
prs.extend(merged)
print(f" page {page}: +{len(merged)} merged → {len(prs)} total")
page += 1
time.sleep(0.3)
return prs
def generate():
print(f"fetching merged PRs for {REPO} …")
try:
prs = fetch_prs()
if not prs:
raise ValueError("No PRs returned")
rows = []
for pr in prs:
created = pd.to_datetime(pr["created_at"])
merged = pd.to_datetime(pr["merged_at"])
cycle_h = (merged - created).total_seconds() / 3600
rows.append({
"merged_at": merged,
"cycle_hours": cycle_h,
"title": pr["title"][:50],
})
df = pd.DataFrame(rows).sort_values("merged_at")
source = REPO
except Exception as e:
print(f" GitHub PR API failed ({e}); using synthetic cycle time distribution")
rng = np.random.default_rng(31)
n = 250
dates = pd.date_range("2022-01-01", periods=n, freq="1D")
# Lognormal cycle time: median ~24h, long tail
cycle_h = rng.lognormal(np.log(24), 1.0, n)
df = pd.DataFrame({
"merged_at": dates,
"cycle_hours": cycle_h,
"title": [f"PR #{i}" for i in range(n)],
})
source = "Synthetic"
# Drop bad rows; cap outliers at 2 years (730 days)
df["cycle_hours"] = pd.to_numeric(df["cycle_hours"], errors="coerce")
df = df.dropna(subset=["cycle_hours"])
df = df[df["cycle_hours"] > 0]
df["cycle_days"] = (df["cycle_hours"] / 24).clip(upper=730)
cycle_days = df["cycle_days"].values
p50 = float(np.percentile(cycle_days, 50))
p85 = float(np.percentile(cycle_days, 85))
p95 = float(np.percentile(cycle_days, 95))
fig = go.Figure()
fig.add_trace(go.Scatter(
x=df["merged_at"],
y=df["cycle_days"].values,
mode="markers",
marker=dict(
color=VIOLET,
size=8,
opacity=0.8,
line=dict(color="white", width=1.5),
),
text=df["title"],
hovertemplate="%{x|%Y-%m-%d}<br>%{y:.1f} days<br>%{text}<extra></extra>",
name="PR",
))
# Percentile lines — use actual day values directly
for pct, val, color, label in [
(50, p50, GREEN, "p50"),
(85, p85, TEAL, "p85"),
(95, p95, PINK, "p95"),
]:
fig.add_hline(
y=val,
line=dict(color=color, width=2, dash="dot"),
annotation_text=f"p{pct} = {val:.1f}d",
annotation_position="top right",
annotation_font=dict(size=11, color=color),
annotation_bgcolor="rgba(255,255,255,0.85)",
)
fig.update_layout(
title=dict(text=f"PR Cycle Time — {source}", x=0.5),
xaxis=dict(title="Merge date"),
yaxis=dict(title="Cycle time (days)", type="log", range=[-2, 3]),
legend=dict(orientation="h", y=1.08),
margin=dict(t=60, b=50, l=70, r=120),
height=460,
)
return fig
if __name__ == "__main__":
generate()
Made with Plotly