1943 lines
76 KiB
Python
1943 lines
76 KiB
Python
#!/usr/bin/env python3
|
|
"""Collect CI job/step timings from the GitHub API and generate an HTML diff report.
|
|
|
|
In CI, the script reads GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, and
|
|
GITHUB_SHA from the environment to collect timings via the REST API.
|
|
|
|
If a baseline JSON file (ci-timings-baseline.json by default) exists, the
|
|
report includes a diff with per-job and per-step deltas, plus a gantt chart
|
|
overlaying current vs baseline bars.
|
|
|
|
Usage:
|
|
# Collect from API (CI mode):
|
|
python scripts/ci/timings_report.py
|
|
|
|
# Regenerate HTML from saved JSON (testing):
|
|
python scripts/ci/timings_report.py --from-json ci-timings.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import difflib
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from html import escape
|
|
|
|
API_BASE = "https://api.github.com"
|
|
|
|
# Retry policy for GitHub API calls. The repo-scoped GITHUB_TOKEN shares a
|
|
# rate-limit budget across every concurrent workflow run; when several PRs
|
|
# run CI at once, this report job (which makes dozens of paginated calls)
|
|
# regularly hits 403 rate-limit responses. Those are transient — retry with
|
|
# backoff, honoring Retry-After / X-RateLimit-Reset when present.
|
|
_RETRY_STATUSES = {403, 429, 500, 502, 503, 504}
|
|
_MAX_ATTEMPTS = 5
|
|
_MAX_RETRY_WAIT_S = 120.0
|
|
|
|
|
|
class TimingsUnavailable(Exception):
|
|
"""GitHub API data could not be collected (rate limit, outage, ...).
|
|
|
|
This is a REPORT job — never a reason to fail the PR's checks. main()
|
|
catches this and exits 0 with a degraded summary.
|
|
"""
|
|
|
|
|
|
def _retry_wait_s(headers, attempt: int) -> float:
|
|
"""Seconds to wait before the next attempt, honoring server hints."""
|
|
retry_after = (headers.get("Retry-After") or "").strip() if headers else ""
|
|
if retry_after.isdigit():
|
|
return min(float(retry_after), _MAX_RETRY_WAIT_S)
|
|
reset = (headers.get("X-RateLimit-Reset") or "").strip() if headers else ""
|
|
remaining = (headers.get("X-RateLimit-Remaining") or "").strip() if headers else ""
|
|
if remaining == "0" and reset.isdigit():
|
|
return min(max(float(reset) - time.time(), 1.0), _MAX_RETRY_WAIT_S)
|
|
return min(2.0 ** attempt * 2.0, _MAX_RETRY_WAIT_S) # 4s, 8s, 16s, 32s
|
|
|
|
|
|
def _urlopen_with_retry(req: urllib.request.Request):
|
|
"""urlopen with backoff on rate-limit/transient statuses.
|
|
|
|
Returns (parsed_json, link_header). Raises TimingsUnavailable when
|
|
attempts are exhausted — callers treat that as "no report this run",
|
|
not a job failure.
|
|
"""
|
|
last_err: Exception | None = None
|
|
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
try:
|
|
with urllib.request.urlopen(req) as resp:
|
|
return json.loads(resp.read()), resp.headers.get("Link", "")
|
|
except urllib.error.HTTPError as e:
|
|
last_err = e
|
|
if e.code not in _RETRY_STATUSES or attempt == _MAX_ATTEMPTS:
|
|
break
|
|
wait = _retry_wait_s(e.headers, attempt)
|
|
print(f"GitHub API {e.code} on {req.full_url} — "
|
|
f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s",
|
|
file=sys.stderr)
|
|
time.sleep(wait)
|
|
except urllib.error.URLError as e:
|
|
last_err = e
|
|
if attempt == _MAX_ATTEMPTS:
|
|
break
|
|
wait = _retry_wait_s(None, attempt)
|
|
print(f"GitHub API connection error on {req.full_url} ({e.reason}) — "
|
|
f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s",
|
|
file=sys.stderr)
|
|
time.sleep(wait)
|
|
raise TimingsUnavailable(
|
|
f"GitHub API unavailable after {_MAX_ATTEMPTS} attempts: {last_err}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GitHub API helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def api_get(path: str, token: str, params: dict | None = None,
|
|
list_key: str | None = None) -> list | dict:
|
|
"""Authenticated GitHub API GET with automatic pagination.
|
|
|
|
For list endpoints, pass list_key to extract items from the paginated
|
|
wrapper response (e.g. list_key='jobs' for {'total_count': N, 'jobs': [...]}).
|
|
When list_key is omitted, a non-list response is returned as-is (single object).
|
|
|
|
Transient failures (403 rate limit, 429, 5xx, connection errors) are
|
|
retried with backoff; exhausted retries raise TimingsUnavailable.
|
|
"""
|
|
url = f"{API_BASE}{path}"
|
|
if params:
|
|
url += "?" + urllib.parse.urlencode(params)
|
|
|
|
results: list = []
|
|
while url:
|
|
req = urllib.request.Request(url, headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "ci-timings-report",
|
|
})
|
|
data, link_header = _urlopen_with_retry(req)
|
|
|
|
if list_key:
|
|
results.extend(data.get(list_key, []))
|
|
elif isinstance(data, list):
|
|
results.extend(data)
|
|
else:
|
|
return data
|
|
|
|
next_url = None
|
|
for part in link_header.split(","):
|
|
part = part.strip()
|
|
if 'rel="next"' in part:
|
|
next_url = part[part.find("<") + 1:part.find(">")]
|
|
break
|
|
url = next_url
|
|
|
|
return results
|
|
|
|
|
|
def parse_ts(ts: str | None) -> datetime | None:
|
|
if not ts:
|
|
return None
|
|
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
|
|
|
|
def dur_s(started: str | None, completed: str | None) -> float | None:
|
|
s = parse_ts(started)
|
|
e = parse_ts(completed)
|
|
if not s or not e:
|
|
return None
|
|
return (e - s).total_seconds()
|
|
|
|
|
|
def is_skipped(job: dict) -> bool:
|
|
"""A job is 'skipped' when GitHub didn't actually run it.
|
|
|
|
Skipped jobs have conclusion == 'skipped' and typically have null or
|
|
equal started_at/completed_at timestamps, yielding duration_s of None
|
|
or 0. They should be excluded from delta comparisons, gantt bars,
|
|
regression tables, and aggregate stats (wall/compute).
|
|
"""
|
|
return job.get("conclusion") == "skipped"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Timings collection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _normalize_job(raw: dict) -> dict:
|
|
steps = []
|
|
for step in (raw.get("steps") or []):
|
|
steps.append({
|
|
"name": step.get("name", ""),
|
|
"number": step.get("number", 0),
|
|
"status": step.get("status", ""),
|
|
"conclusion": step.get("conclusion", ""),
|
|
"started_at": step.get("started_at"),
|
|
"completed_at": step.get("completed_at"),
|
|
"duration_s": dur_s(step.get("started_at"), step.get("completed_at")),
|
|
})
|
|
return {
|
|
"name": raw.get("name", "unknown"),
|
|
"workflow_name": raw.get("_workflow_name", ""),
|
|
"job_id": raw.get("id"),
|
|
"status": raw.get("status", ""),
|
|
"conclusion": raw.get("conclusion", ""),
|
|
"started_at": raw.get("started_at"),
|
|
"completed_at": raw.get("completed_at"),
|
|
"duration_s": dur_s(raw.get("started_at"), raw.get("completed_at")),
|
|
"html_url": raw.get("html_url", ""),
|
|
"steps": steps,
|
|
}
|
|
|
|
|
|
# Step categories. "Overhead" is everything a job pays before and after its
|
|
# actual work: runner setup, checkout, dependency restore/install, teardown.
|
|
# Naming is GitHub's, not ours — a `uses:` step is reported as
|
|
# "Run <owner>/<action>@<sha>" and its cleanup as "Post Run <...>", while a
|
|
# `run:` step keeps whatever `name:` the workflow gave it. So the setup
|
|
# patterns match action refs and the well-known implicit steps, and anything
|
|
# unmatched is treated as work (better to under-report overhead than to
|
|
# silently classify a real test step as setup).
|
|
STEP_SETUP = "setup"
|
|
STEP_WORK = "work"
|
|
STEP_TEARDOWN = "teardown"
|
|
|
|
_SETUP_PATTERNS = (
|
|
"set up job",
|
|
"set up python",
|
|
"set up node",
|
|
"checkout",
|
|
"actions/checkout",
|
|
"actions/setup-",
|
|
"actions/cache",
|
|
"restore ", # "Restore uv cache", "Restore baseline cache", ...
|
|
"install ", # "Install dependencies", "Install ruff + ty", ...
|
|
"minimize uv cache",
|
|
"uv-cache",
|
|
"set up docker buildx",
|
|
"log in to",
|
|
"authenticate to",
|
|
"mint read-only cache token",
|
|
"pull ghcr.io/",
|
|
"get-app-token",
|
|
"determine base ref",
|
|
)
|
|
|
|
_TEARDOWN_PATTERNS = (
|
|
"complete job",
|
|
"stop containers",
|
|
"upload",
|
|
"export results",
|
|
)
|
|
|
|
|
|
def classify_step(name: str) -> str:
|
|
"""Bucket a step into setup / work / teardown.
|
|
|
|
Any ``Post ...`` step is teardown regardless of what it post-processes —
|
|
that's GitHub's own cleanup phase for a ``uses:`` step.
|
|
"""
|
|
low = (name or "").strip().lower()
|
|
if low.startswith("post "):
|
|
return STEP_TEARDOWN
|
|
for pat in _TEARDOWN_PATTERNS:
|
|
if pat in low:
|
|
return STEP_TEARDOWN
|
|
for pat in _SETUP_PATTERNS:
|
|
if pat in low:
|
|
return STEP_SETUP
|
|
return STEP_WORK
|
|
|
|
|
|
def display_step_name(name: str) -> str:
|
|
"""Strip the pinned SHA from an action ref for display.
|
|
|
|
GitHub names a ``uses:`` step after the full pinned ref, e.g.
|
|
``Run actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd``. The
|
|
40-char SHA crowds out the part a reader cares about and makes the
|
|
overhead table unreadable, so drop it. Only the DISPLAY changes — the
|
|
raw name stays the key for baseline comparison and aggregation, since
|
|
two different pins are genuinely two different steps.
|
|
"""
|
|
if not name:
|
|
return ""
|
|
return re.sub(r"@[0-9a-f]{7,40}\b", "", name)
|
|
|
|
|
|
def compute_overhead(timings: dict) -> dict:
|
|
"""Aggregate setup/work/teardown seconds across every non-skipped job.
|
|
|
|
Returns totals plus the worst individual setup steps, so the report can
|
|
answer "how much of CI is spent getting ready to work" with a number
|
|
instead of an impression.
|
|
"""
|
|
totals = {STEP_SETUP: 0.0, STEP_WORK: 0.0, STEP_TEARDOWN: 0.0}
|
|
by_step: dict[str, dict] = {}
|
|
jobs_with_steps = 0
|
|
|
|
for j in timings.get("jobs", []):
|
|
if is_skipped(j) or not j.get("steps"):
|
|
continue
|
|
jobs_with_steps += 1
|
|
for s in j["steps"]:
|
|
dur = s.get("duration_s")
|
|
if dur is None or dur < 0:
|
|
continue
|
|
cat = classify_step(s.get("name", ""))
|
|
totals[cat] += dur
|
|
if cat != STEP_WORK:
|
|
agg = by_step.setdefault(
|
|
s.get("name", ""), {"name": s.get("name", ""),
|
|
"total_s": 0.0, "count": 0, "category": cat}
|
|
)
|
|
agg["total_s"] += dur
|
|
agg["count"] += 1
|
|
|
|
accounted = sum(totals.values())
|
|
overhead = totals[STEP_SETUP] + totals[STEP_TEARDOWN]
|
|
return {
|
|
"setup_s": totals[STEP_SETUP],
|
|
"work_s": totals[STEP_WORK],
|
|
"teardown_s": totals[STEP_TEARDOWN],
|
|
"overhead_s": overhead,
|
|
"accounted_s": accounted,
|
|
"overhead_pct": (overhead / accounted * 100) if accounted > 0 else 0.0,
|
|
"jobs_with_steps": jobs_with_steps,
|
|
"top_overhead_steps": sorted(
|
|
by_step.values(), key=lambda x: -x["total_s"]
|
|
)[:8],
|
|
}
|
|
|
|
|
|
def _annotate_wait_times(jobs: list[dict]) -> None:
|
|
"""Annotate each job with ``wait_s`` — how long it sat idle before starting.
|
|
|
|
Wait time = ``started_at - max(completed_at of all jobs that finished
|
|
before this job started)``. Jobs with no predecessor (e.g. ``detect``)
|
|
get ``wait_s = 0``. Skipped jobs get ``wait_s = None``.
|
|
|
|
This is a timestamp heuristic, not a workflow-YAML dependency parse: it
|
|
infers dependencies from temporal ordering rather than ``needs:``
|
|
declarations. It's accurate for pipeline-shaped CI where the critical
|
|
path is linear at each stage (detect → parallel lanes → gate → report).
|
|
"""
|
|
for j in jobs:
|
|
if is_skipped(j):
|
|
j["wait_s"] = None
|
|
continue
|
|
started = parse_ts(j.get("started_at"))
|
|
if started is None:
|
|
j["wait_s"] = None
|
|
continue
|
|
latest_dep_end: datetime | None = None
|
|
for other in jobs:
|
|
if other is j or is_skipped(other):
|
|
continue
|
|
other_end = parse_ts(other.get("completed_at"))
|
|
if other_end is None or other_end > started:
|
|
continue
|
|
if latest_dep_end is None or other_end > latest_dep_end:
|
|
latest_dep_end = other_end
|
|
j["wait_s"] = (started - latest_dep_end).total_seconds() if latest_dep_end else 0.0
|
|
|
|
|
|
def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict:
|
|
"""Collect job/step timings from the GitHub API.
|
|
|
|
1. Get orchestrator run's direct jobs (detect, all-checks-pass, etc.).
|
|
Skip workflow-call placeholder jobs (step name starts with
|
|
"Run ./.github/workflows/").
|
|
2. Find sub-workflow runs via head_sha + event=workflow_call.
|
|
3. Get each sub-workflow run's jobs with full step timing.
|
|
"""
|
|
owner, repo_name = repo.split("/")
|
|
|
|
# Orchestrator run info
|
|
run_info = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}", token)
|
|
created_at = run_info.get("created_at", "")
|
|
|
|
# Orchestrator direct jobs
|
|
orch_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs",
|
|
token, list_key="jobs")
|
|
|
|
direct = []
|
|
for job in orch_jobs:
|
|
steps = job.get("steps") or []
|
|
if any(s.get("name", "").startswith("Run ./.github/workflows/") for s in steps):
|
|
continue # workflow-call placeholder
|
|
if job.get("status") in ("in_progress", "queued"):
|
|
continue # skip self / unfinished
|
|
direct.append(job)
|
|
|
|
# Sub-workflow runs
|
|
sub_runs = api_get(f"/repos/{owner}/{repo_name}/actions/runs", token, params={
|
|
"head_sha": head_sha,
|
|
"event": "workflow_call",
|
|
"per_page": 100,
|
|
}, list_key="workflow_runs")
|
|
sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at]
|
|
|
|
sub_jobs_raw = []
|
|
for sr in sub_runs:
|
|
sr_id = sr["id"]
|
|
sr_name = sr.get("name", "")
|
|
sr_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{sr_id}/jobs",
|
|
token, list_key="jobs")
|
|
for j in sr_jobs:
|
|
j["_workflow_name"] = sr_name
|
|
j["_workflow_run_id"] = sr_id
|
|
sub_jobs_raw.append(j)
|
|
|
|
# Normalize + sort
|
|
all_jobs = [_normalize_job(j) for j in direct + sub_jobs_raw]
|
|
all_jobs = [j for j in all_jobs if j["status"] not in ("in_progress", "queued")]
|
|
all_jobs.sort(key=lambda j: j.get("started_at") or "")
|
|
_annotate_wait_times(all_jobs)
|
|
|
|
return {
|
|
"run_id": run_id,
|
|
"head_sha": head_sha,
|
|
"created_at": created_at,
|
|
"jobs": all_jobs,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Formatting helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fmt_dur(seconds: float | None) -> str:
|
|
if seconds is None:
|
|
return "—"
|
|
if seconds < 60:
|
|
return f"{seconds:.1f}s"
|
|
m = int(seconds // 60)
|
|
s = seconds % 60
|
|
if s == 0:
|
|
return f"{m}m"
|
|
return f"{m}m{s:.0f}s"
|
|
|
|
|
|
def fmt_delta(current: float | None, baseline: float | None) -> tuple[str, str]:
|
|
"""Return (text, css_class) for a delta."""
|
|
if current is None or baseline is None:
|
|
return ("—", "neutral")
|
|
delta = current - baseline
|
|
if baseline == 0:
|
|
pct_str = "new" if delta > 0 else "0%"
|
|
else:
|
|
pct = (delta / baseline) * 100
|
|
pct_str = f"{pct:+.1f}%"
|
|
if abs(delta) < 1.0:
|
|
cls = "neutral"
|
|
elif delta > 0:
|
|
cls = "slower"
|
|
else:
|
|
cls = "faster"
|
|
sign = "+" if delta >= 0 else ""
|
|
return (f"{sign}{delta:.1f}s ({pct_str})", cls)
|
|
|
|
|
|
def nice_ticks(max_seconds: float, num_ticks: int = 8) -> list[int]:
|
|
if max_seconds <= 0:
|
|
return [0]
|
|
raw = max_seconds / num_ticks
|
|
for nice in [5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 7200]:
|
|
if nice >= raw:
|
|
step = nice
|
|
break
|
|
else:
|
|
step = max(int(raw), 3600)
|
|
return list(range(0, int(max_seconds) + step + 1, step))
|
|
|
|
|
|
def fmt_tick(seconds: int) -> str:
|
|
if seconds < 60:
|
|
return f"{seconds}s"
|
|
m, s = divmod(seconds, 60)
|
|
if s == 0:
|
|
return f"{m}m"
|
|
return f"{m}m{s}s"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stats computation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def compute_stats(timings: dict, baseline: dict | None = None) -> dict:
|
|
jobs_all = timings.get("jobs", [])
|
|
jobs = [j for j in jobs_all if not is_skipped(j)]
|
|
bl_jobs_all = (baseline or {}).get("jobs", [])
|
|
bl_jobs = [j for j in bl_jobs_all if not is_skipped(j)]
|
|
bl_map = {j["name"]: j for j in bl_jobs}
|
|
|
|
# Wall time (skipped jobs have no real timestamps)
|
|
starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
|
|
ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
|
|
wall = (max(ends) - min(starts)).total_seconds() if starts and ends else 0
|
|
compute = sum(j.get("duration_s") or 0 for j in jobs)
|
|
|
|
# Baseline wall/compute
|
|
bl_wall = None
|
|
bl_compute = None
|
|
if baseline:
|
|
bl_starts = [s for s in (parse_ts(j.get("started_at")) for j in bl_jobs) if s is not None]
|
|
bl_ends = [e for e in (parse_ts(j.get("completed_at")) for j in bl_jobs) if e is not None]
|
|
if bl_starts and bl_ends:
|
|
bl_wall = (max(bl_ends) - min(bl_starts)).total_seconds()
|
|
bl_compute = sum(j.get("duration_s") or 0 for j in bl_jobs)
|
|
|
|
# Per-job deltas (skipped excluded)
|
|
faster = 0
|
|
slower = 0
|
|
unchanged = 0
|
|
no_baseline = 0
|
|
for j in jobs:
|
|
bl = bl_map.get(j["name"])
|
|
if not bl:
|
|
no_baseline += 1
|
|
continue
|
|
cur_d = j.get("duration_s") or 0
|
|
bl_d = bl.get("duration_s") or 0
|
|
if abs(cur_d - bl_d) < 1.0:
|
|
unchanged += 1
|
|
elif cur_d > bl_d:
|
|
slower += 1
|
|
else:
|
|
faster += 1
|
|
|
|
skipped = sum(1 for j in jobs_all if is_skipped(j))
|
|
bl_skipped = sum(1 for j in bl_jobs_all if is_skipped(j))
|
|
total_wait = sum(j.get("wait_s") or 0 for j in jobs)
|
|
bl_total_wait = sum(j.get("wait_s") or 0 for j in bl_jobs)
|
|
|
|
return {
|
|
"wall": wall,
|
|
"compute": compute,
|
|
"bl_wall": bl_wall,
|
|
"bl_compute": bl_compute,
|
|
"faster": faster,
|
|
"slower": slower,
|
|
"unchanged": unchanged,
|
|
"no_baseline": no_baseline,
|
|
"skipped": skipped,
|
|
"bl_skipped": bl_skipped,
|
|
"total_wait": total_wait,
|
|
"bl_total_wait": bl_total_wait,
|
|
"total_jobs": len(jobs_all),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resource profile loading + bottleneck analysis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_resource_profiles(directory: str) -> dict[str, dict]:
|
|
"""Load all resource-profile-*/resource-profile.json artifacts.
|
|
|
|
Returns {label: profile_dict}. Labels are derived from the artifact
|
|
directory name (resource-profile-<label> → <label>).
|
|
"""
|
|
profiles: dict[str, dict] = {}
|
|
if not directory or not os.path.isdir(directory):
|
|
return profiles
|
|
|
|
for path in glob.glob(os.path.join(directory, "**", "resource-profile.json"), recursive=True):
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
profile = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
label = profile.get("label") or os.path.basename(os.path.dirname(path))
|
|
profiles[label] = profile
|
|
|
|
return profiles
|
|
|
|
|
|
def _match_tokens(text: str) -> list[str]:
|
|
"""Lowercase alphanumeric tokens, for fuzzy label/job-name matching."""
|
|
return [t for t in re.sub(r"[^a-z0-9]+", " ", text.lower()).split() if t]
|
|
|
|
|
|
def _match_score(label: str, job_name: str) -> float:
|
|
"""Score how well a profile label matches a job name (0.0-1.0).
|
|
|
|
Profile labels are hand-written in the workflow (``tests-slice-3``)
|
|
while job names come from GitHub (``Python tests / Run tests slice
|
|
3/8``), so there is no exact key to join on. Three signals, blended:
|
|
|
|
* containment — what fraction of the label's tokens the job has.
|
|
The primary signal, since the label is the shorter string.
|
|
* Jaccard — penalises a job name that matches by being huge.
|
|
* sequence — orders otherwise-tied candidates by surface shape.
|
|
|
|
Digits are a hard gate rather than a weighted term: ``tests-slice-3``
|
|
and ``tests-slice-8`` differ in exactly one token, and without the
|
|
gate they score within noise of each other. Any digit token in the
|
|
label must appear in the job name or the pair scores 0.
|
|
"""
|
|
lt, jt = _match_tokens(label), _match_tokens(job_name)
|
|
if not lt or not jt:
|
|
return 0.0
|
|
ls, jss = set(lt), set(jt)
|
|
|
|
label_digits = {t for t in ls if t.isdigit()}
|
|
if label_digits and not label_digits <= jss:
|
|
return 0.0
|
|
|
|
containment = len(ls & jss) / len(ls)
|
|
jaccard = len(ls & jss) / len(ls | jss)
|
|
seq = difflib.SequenceMatcher(None, " ".join(lt), " ".join(jt)).ratio()
|
|
return 0.45 * containment + 0.35 * jaccard + 0.20 * seq
|
|
|
|
|
|
# Below this score a "best match" is more likely coincidence than a real
|
|
# pairing, so the profile is left unattached rather than overlaid onto an
|
|
# unrelated job's bar. Calibrated against a real run: true pairs scored
|
|
# 0.44-0.90, the best false pair 0.31.
|
|
_MATCH_THRESHOLD = 0.40
|
|
|
|
|
|
def match_profiles_to_jobs(timings: dict, profiles: dict[str, dict]) -> dict[str, dict]:
|
|
"""Return {job_name: profile} for profiles confidently matched to a job.
|
|
|
|
Greedy best-first over all (profile, job) pairs: the highest-scoring
|
|
pair is committed, then both sides are removed from contention. This
|
|
keeps one profile per job and one job per profile even when several
|
|
labels look alike (the eight ``tests-slice-N`` profiles against the
|
|
eight ``Run tests slice N/8`` jobs), which a per-profile argmax does
|
|
not guarantee.
|
|
"""
|
|
jobs = [j for j in timings.get("jobs", []) if not is_skipped(j)]
|
|
if not jobs or not profiles:
|
|
return {}
|
|
|
|
scored = []
|
|
for label, profile in profiles.items():
|
|
for job in jobs:
|
|
score = _match_score(label, job.get("name", ""))
|
|
if score >= _MATCH_THRESHOLD:
|
|
scored.append((score, label, job.get("name", ""), profile))
|
|
|
|
# Sort by score desc; ties broken on names so the result is stable
|
|
# regardless of dict iteration order.
|
|
scored.sort(key=lambda t: (-t[0], t[1], t[2]))
|
|
|
|
matched: dict[str, dict] = {}
|
|
used_labels: set[str] = set()
|
|
for _score, label, job_name, profile in scored:
|
|
if label in used_labels or job_name in matched:
|
|
continue
|
|
used_labels.add(label)
|
|
matched[job_name] = profile
|
|
return matched
|
|
|
|
|
|
def _sparkline_svg(series: list[int], color: str, klass: str) -> str:
|
|
"""Render a 0-100 series as a filled+stroked SVG sparkline that stretches.
|
|
|
|
``preserveAspectRatio="none"`` plus a viewBox in series-index space
|
|
lets one SVG scale to any bar width without recomputing points, so
|
|
the same markup serves both the 3px-tall collapsed strip and the
|
|
full-height expanded overlay.
|
|
|
|
The fill alone is too faint to read once three translucent layers are
|
|
stacked, so each series also gets a 1px stroked polyline on top. The
|
|
stroke carries ``vector-effect="non-scaling-stroke"``: without it the
|
|
non-uniform viewBox scaling (a ~200x180px box showing a 180x100
|
|
viewBox) stretches the stroke into an unreadable smear.
|
|
"""
|
|
if not series:
|
|
return ""
|
|
n = len(series)
|
|
width = max(n - 1, 1)
|
|
# y is inverted: SVG's origin is top-left, but 100% should draw at the top.
|
|
pts = " ".join(f"{i},{100 - v}" for i, v in enumerate(series))
|
|
# Close the polygon along the bottom edge so it reads as an area chart.
|
|
poly = f"0,100 {pts} {width},100"
|
|
return (
|
|
f'<svg class="res-spark {klass}" viewBox="0 0 {width} 100" '
|
|
f'preserveAspectRatio="none" aria-hidden="true">'
|
|
f'<polygon points="{poly}" fill="{color}"/>'
|
|
f'<polyline points="{pts}" fill="none" stroke="{color}" '
|
|
f'stroke-width="1" vector-effect="non-scaling-stroke"/>'
|
|
f'</svg>'
|
|
)
|
|
|
|
|
|
# Overlay series: (profile series key, color, css class).
|
|
_RES_SERIES = (
|
|
("cpu_pct", "#3fb950", "cpu"), # green
|
|
("mem_pct", "#d29922", "mem"), # amber
|
|
("disk_pct", "#f85149", "disk"), # red
|
|
)
|
|
|
|
|
|
def _resource_overlay(profile: dict | None, expanded: bool) -> str:
|
|
"""Render one job's CPU/RAM/disk overlay, or "" if there is no series.
|
|
|
|
Callers pass an unmatched job's ``None`` straight through, so this is
|
|
also the guard for "this job was never profiled". Old artifacts
|
|
predating the ``series`` field degrade the same way: table only, no
|
|
overlay. ``expanded`` selects which CSS state the layer renders in.
|
|
"""
|
|
if not profile:
|
|
return ""
|
|
series = profile.get("series") or {}
|
|
layers = []
|
|
for key, color, name in _RES_SERIES:
|
|
values = series.get(key) or []
|
|
if values:
|
|
layers.append(_sparkline_svg(values, color, name))
|
|
if not layers:
|
|
return ""
|
|
|
|
cpu = profile.get("cpu", {})
|
|
mem = profile.get("memory", {})
|
|
disk = profile.get("disk", {})
|
|
tip = (
|
|
f'CPU avg {cpu.get("avg_usage_pct", 0):.0f}% / peak {cpu.get("peak_usage_pct", 0):.0f}% · '
|
|
f'RAM peak {mem.get("peak_mb", 0):.0f} MB · '
|
|
f'disk util avg {disk.get("avg_util_pct", 0):.0f}%'
|
|
)
|
|
klass = "res-overlay expanded" if expanded else "res-overlay collapsed"
|
|
return f'<div class="{klass}" title="{escape(tip)}">{"".join(layers)}</div>'
|
|
|
|
|
|
def _profile_window_frac(profile: dict | None, job_start, job_end) -> tuple[float, float]:
|
|
"""Return (start, width) of the profiled window as fractions of the job bar.
|
|
|
|
``(0.0, 1.0)`` means "the whole bar" and is the fallback for every case
|
|
we cannot place confidently — that is exactly the old behaviour, so a
|
|
miss degrades to stretching rather than to a missing overlay.
|
|
|
|
Fractions rather than percentages because the two overlay states live in
|
|
different coordinate spaces: the collapsed strip is a child of the bar,
|
|
the expanded layer sits in the track. Each scales this once, instead of
|
|
one of them undoing the other's scaling.
|
|
|
|
Placement matters because the profiler wraps ONE step
|
|
(``.github/actions/profile``): on a job whose other steps dominate —
|
|
checkout, uv sync, post-job cleanup — the samples describe a slice in
|
|
the middle, and stretching them across the bar puts a CPU spike under a
|
|
step that never ran.
|
|
"""
|
|
if not profile or job_start is None or job_end is None:
|
|
return 0.0, 1.0
|
|
|
|
p_s = parse_ts(profile.get("started_at"))
|
|
p_e = parse_ts(profile.get("completed_at"))
|
|
if p_s is None or p_e is None:
|
|
return 0.0, 1.0 # artifact predates the timestamps
|
|
|
|
span = (job_end - job_start).total_seconds()
|
|
if span <= 0:
|
|
return 0.0, 1.0
|
|
|
|
# Clamped into the bar: a profiler signalled just after its step ends can
|
|
# outrun the job's completed_at by a second or two.
|
|
start = max(0.0, (p_s - job_start).total_seconds() / span)
|
|
end = min(1.0, (p_e - job_start).total_seconds() / span)
|
|
if end <= start:
|
|
return 0.0, 1.0 # clock skew put the window outside the job
|
|
|
|
# Floor keeps a hairline visible for a very short profile on a long job.
|
|
return start, max(end - start, 0.005)
|
|
|
|
|
|
def classify_bottleneck(timings: dict, profiles: dict[str, dict]) -> str:
|
|
"""Return a one-line bottleneck classification.
|
|
|
|
Examines wall time, compute time, wait time, and resource profiles
|
|
to identify the dominant constraint.
|
|
|
|
Possible verdicts:
|
|
- "CPU-bound: <job> at <pct>% CPU for <dur>"
|
|
- "Memory-bound: <job> peaked at <mb> MB"
|
|
- "Disk IO-bound: <job> at <ops>/s for <dur>"
|
|
- "Wait-bound: <wait>s idle waiting for dependencies"
|
|
- "Evenly distributed: no single bottleneck"
|
|
- "Insufficient data: <reason>"
|
|
"""
|
|
stats = compute_stats(timings, None)
|
|
jobs = [j for j in timings.get("jobs", []) if not is_skipped(j)]
|
|
|
|
if not jobs:
|
|
return "Insufficient data: no jobs in timings"
|
|
|
|
# --- Wait-bound: if total wait > 50% of wall time ---
|
|
wall = stats["wall"]
|
|
total_wait = stats["total_wait"]
|
|
if wall > 0 and total_wait > 0:
|
|
wait_pct = total_wait / wall * 100
|
|
if wait_pct > 50:
|
|
return (f"Wait-bound: {fmt_dur(total_wait)} idle "
|
|
f"({wait_pct:.0f}% of {fmt_dur(wall)} wall) waiting for dependencies")
|
|
|
|
# --- Resource-bound: check profiles for CPU/mem/disk extremes ---
|
|
if profiles:
|
|
cpu_hotspot = None
|
|
mem_hotspot = None
|
|
disk_hotspot = None
|
|
max_cpu = 0.0
|
|
max_mem_frac = 0.0
|
|
max_disk_ops = 0.0
|
|
|
|
for label, p in profiles.items():
|
|
cpu_info = p.get("cpu", {})
|
|
mem_info = p.get("memory", {})
|
|
disk_info = p.get("disk", {})
|
|
|
|
cpu_avg = cpu_info.get("avg_usage_pct", 0)
|
|
cpu_peak = cpu_info.get("peak_usage_pct", 0)
|
|
if cpu_avg > max_cpu:
|
|
max_cpu = cpu_avg
|
|
cpu_hotspot = (label, cpu_avg, cpu_peak, p.get("duration_s", 0))
|
|
|
|
# Memory is USED MB. Compare against the machine's total when the
|
|
# profile carries it; otherwise fall back to an absolute floor.
|
|
mem_peak = mem_info.get("peak_mb", 0)
|
|
mem_total = mem_info.get("total_mb", 0)
|
|
mem_frac = (mem_peak / mem_total) if mem_total > 0 else (mem_peak / 16000.0)
|
|
if mem_frac > max_mem_frac:
|
|
max_mem_frac = mem_frac
|
|
mem_hotspot = (label, mem_peak, mem_total)
|
|
|
|
disk_ops = disk_info.get("avg_ops_per_s", 0)
|
|
disk_mb = disk_info.get("total_mb", 0)
|
|
if disk_ops > max_disk_ops:
|
|
max_disk_ops = disk_ops
|
|
disk_hotspot = (label, disk_ops, disk_mb, p.get("duration_s", 0))
|
|
|
|
# Classify: pick the most extreme dimension.
|
|
# Each candidate's sort key is normalized to roughly 0-100 so the
|
|
# dimensions are comparable:
|
|
# CPU — avg usage pct (bound when > 80)
|
|
# Disk — avg completed IOs/s / 10 (bound when > 500 ops/s)
|
|
# Mem — peak used as pct of total (bound when > 85%)
|
|
|
|
candidates = []
|
|
if cpu_hotspot and cpu_hotspot[1] > 80:
|
|
candidates.append((
|
|
cpu_hotspot[1], # sort key
|
|
f"CPU-bound: {cpu_hotspot[0]} at {cpu_hotspot[1]:.0f}% avg CPU "
|
|
f"(peak {cpu_hotspot[2]:.0f}%) for {fmt_dur(cpu_hotspot[3])}"
|
|
))
|
|
if disk_hotspot and disk_hotspot[1] > 500:
|
|
candidates.append((
|
|
disk_hotspot[1] / 10,
|
|
f"Disk IO-bound: {disk_hotspot[0]} at {disk_hotspot[1]:.0f} ops/s "
|
|
f"({disk_hotspot[2]:.0f} MB total) for {fmt_dur(disk_hotspot[3])}"
|
|
))
|
|
if mem_hotspot and max_mem_frac > 0.85:
|
|
total_note = f" of {mem_hotspot[2]:.0f} MB" if mem_hotspot[2] else ""
|
|
candidates.append((
|
|
max_mem_frac * 100,
|
|
f"Memory-bound: {mem_hotspot[0]} peaked at "
|
|
f"{mem_hotspot[1]:.0f} MB used{total_note}"
|
|
))
|
|
|
|
if candidates:
|
|
candidates.sort(reverse=True)
|
|
return candidates[0][1]
|
|
|
|
# --- No resource profiles, but check wall vs compute ---
|
|
compute = stats["compute"]
|
|
if wall > 0 and compute > 0:
|
|
parallelism = compute / wall
|
|
if parallelism < 1.2 and len(jobs) > 2:
|
|
# Low parallelism ratio means jobs are serial
|
|
slowest = max(jobs, key=lambda j: j.get("duration_s") or 0)
|
|
slow_dur = slowest.get("duration_s") or 0
|
|
slow_pct = slow_dur / wall * 100 if wall > 0 else 0
|
|
if slow_pct > 40:
|
|
return (f"Serial bottleneck: {slowest['name']} takes "
|
|
f"{fmt_dur(slow_dur)} ({slow_pct:.0f}% of wall)")
|
|
|
|
# --- Fallback: the single slowest job ---
|
|
slowest = max(jobs, key=lambda j: j.get("duration_s") or 0)
|
|
slow_dur = slowest.get("duration_s") or 0
|
|
if slow_dur > 0 and wall > 0:
|
|
slow_pct = slow_dur / wall * 100
|
|
if slow_pct > 40 and len(jobs) > 1:
|
|
return (f"Dominated by {slowest['name']}: "
|
|
f"{fmt_dur(slow_dur)} ({slow_pct:.0f}% of wall)")
|
|
|
|
return "Evenly distributed: no single bottleneck"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CSS = """
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
background: #0d1117; color: #e6edf3; line-height: 1.5; padding: 24px;
|
|
}
|
|
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 8px; }
|
|
.meta { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
|
|
h2 { font-size: 18px; margin: 32px 0 12px; }
|
|
|
|
/* Stats cards */
|
|
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
|
|
.stat-card {
|
|
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 14px 18px; min-width: 140px;
|
|
}
|
|
.stat-label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.stat-value { font-size: 22px; font-weight: 600; margin: 4px 0; }
|
|
.stat-delta { font-size: 13px; }
|
|
.faster { color: #3fb950; }
|
|
.slower { color: #f85149; }
|
|
.neutral { color: #8b949e; }
|
|
|
|
/* Gantt */
|
|
.gantt-wrap { overflow-x: auto; }
|
|
.gantt { min-width: 700px; }
|
|
.gantt-row { display: flex; align-items: center; height: 28px; }
|
|
.gantt-label {
|
|
width: 220px; padding-right: 12px; text-align: right;
|
|
font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.gantt-track { flex: 1; position: relative; height: 100%; border-left: 1px solid #21262d; }
|
|
.gantt-bar {
|
|
position: absolute; height: 18px; border-radius: 3px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 10px; color: transparent; overflow: hidden;
|
|
transition: color 0.15s;
|
|
}
|
|
.gantt-bar:hover { color: #fff; z-index: 10; }
|
|
.gantt-bar.current { background: #1f6feb; top: 5px; z-index: 2; }
|
|
.gantt-bar.wait {
|
|
background: repeating-linear-gradient(
|
|
45deg, #30363d, #30363d 3px, transparent 3px, transparent 6px
|
|
);
|
|
top: 5px; z-index: 1; opacity: 0.6;
|
|
}
|
|
.gantt-bar.baseline {
|
|
background: transparent; border: 1px dashed #8b949e; top: 2px; height: 24px; z-index: 1;
|
|
}
|
|
|
|
/* Step segmentation inside the job bar. Segments are children of
|
|
.gantt-bar.current, so their percentages are relative to the bar. The
|
|
right border is the divider between consecutive steps. */
|
|
.gantt-seg {
|
|
position: absolute; top: 0; height: 100%;
|
|
border-right: 1px solid rgba(1,4,9,0.85);
|
|
box-sizing: border-box;
|
|
}
|
|
.gantt-seg:last-child { border-right: none; }
|
|
.gantt-seg.setup { background: #d29922; }
|
|
.gantt-seg.work { background: #1f6feb; }
|
|
.gantt-seg.teardown { background: #8957e5; }
|
|
.gantt-seg:hover { filter: brightness(1.45); }
|
|
|
|
/* Expandable job rows */
|
|
.gantt-row.job.expandable { cursor: pointer; }
|
|
.gantt-row.job.expandable:hover .gantt-label { color: #58a6ff; }
|
|
.caret {
|
|
display: inline-block; margin-right: 4px; font-size: 9px; color: #8b949e;
|
|
transition: transform 0.15s;
|
|
}
|
|
.gantt-group.open .caret { transform: rotate(90deg); }
|
|
.gantt-steps { display: none; }
|
|
.gantt-group.open .gantt-steps { display: block; min-height: 26px; }
|
|
|
|
/* Resource overlay (CPU / RAM / disk sparklines over a job bar), in two
|
|
states of the same series:
|
|
.collapsed — a 3px strip inside .gantt-bar.current, clipped to the job's
|
|
extent and adding no row height. Enough to spot a saturated
|
|
job while scanning.
|
|
.expanded — the same curves over the full height of the open group, so
|
|
they read against the step rows that caused them.
|
|
.gantt-steps is the positioning context for the expanded layer, with a
|
|
min-height so a profiled job with no step detail still shows one.
|
|
pointer-events:none keeps step tooltips and click-to-expand working
|
|
through the overlay. */
|
|
.gantt-steps { position: relative; }
|
|
.res-layer {
|
|
position: absolute; inset: 0; display: flex;
|
|
pointer-events: none; z-index: 0;
|
|
}
|
|
.res-layer .gantt-label { height: 100%; }
|
|
.res-layer .gantt-track { height: 100%; border-left: none; }
|
|
.res-overlay { position: absolute; left: 0; width: 100%; pointer-events: none; }
|
|
.res-overlay.collapsed { bottom: 0; height: 3px; }
|
|
.res-overlay.expanded { top: 0; height: 100%; }
|
|
/* Positions the collapsed strip over the profiled window within the bar.
|
|
The strip itself is 100%-wide of THIS box, not of the job bar, so a job
|
|
whose profiled step is a slice in the middle shows the sparkline only
|
|
under that slice. */
|
|
.res-clip { position: absolute; bottom: 0; height: 3px; pointer-events: none; }
|
|
.res-spark { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
/* Area fills so all three read stacked; the stroke on top is what actually
|
|
makes each curve legible. At 3px the fill needs near-full opacity to
|
|
register and the stroke would just be noise. */
|
|
.res-spark polygon { fill-opacity: 0.3; }
|
|
.res-spark polyline { opacity: 0.95; }
|
|
.res-overlay.collapsed .res-spark polygon { fill-opacity: 0.9; }
|
|
.res-overlay.collapsed .res-spark polyline { display: none; }
|
|
.res-holder {
|
|
position: absolute; top: 0; bottom: 0;
|
|
background: rgba(1,4,9,0.55);
|
|
border-left: 1px solid #21262d; border-right: 1px solid #21262d;
|
|
overflow: hidden;
|
|
}
|
|
/* Step bars must stay above the overlay. */
|
|
.gantt-row.step { position: relative; z-index: 1; height: 18px; }
|
|
.gantt-label.step {
|
|
font-size: 11px; color: #8b949e; padding-left: 18px;
|
|
text-align: right; direction: rtl;
|
|
}
|
|
.gantt-bar.step { height: 10px; top: 4px; opacity: 0.85; z-index: 2; }
|
|
.gantt-bar.step.setup { background: #d29922; }
|
|
.gantt-bar.step.work { background: #1f6feb; }
|
|
.gantt-bar.step.teardown { background: #8957e5; }
|
|
|
|
/* Overhead breakdown bar */
|
|
.overhead-bar {
|
|
display: flex; height: 26px; border-radius: 4px; overflow: hidden;
|
|
margin: 8px 0 6px; border: 1px solid #30363d;
|
|
}
|
|
.overhead-seg {
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 11px; font-weight: 600; color: #010409; white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
.overhead-seg.setup { background: #d29922; }
|
|
.overhead-seg.work { background: #1f6feb; color: #fff; }
|
|
.overhead-seg.teardown { background: #8957e5; color: #fff; }
|
|
.gantt-hint { font-size: 12px; color: #8b949e; margin-bottom: 8px; }
|
|
.gantt-hint button {
|
|
background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
|
|
border-radius: 5px; padding: 2px 10px; font-size: 12px; cursor: pointer;
|
|
font-family: inherit; margin-left: 4px;
|
|
}
|
|
.gantt-hint button:hover { background: #30363d; border-color: #8b949e; }
|
|
.gantt-axis { display: flex; height: 20px; position: relative; border-top: 1px solid #30363d; margin-top: 4px; }
|
|
.gantt-tick { position: absolute; font-size: 10px; color: #8b949e; transform: translateX(-50%); top: 4px; }
|
|
.gantt-tick::before { content: ''; position: absolute; top: -4px; left: 50%; width: 1px; height: 4px; background: #30363d; }
|
|
.legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: #8b949e; }
|
|
.legend-swatch { display: inline-block; width: 16px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
|
.legend-sep { color: #30363d; }
|
|
|
|
/* Tables */
|
|
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 16px; }
|
|
th, td { border: 1px solid #30363d; padding: 6px 10px; text-align: left; }
|
|
th { background: #161b22; font-weight: 600; position: sticky; top: 0; }
|
|
tr:hover td { background: #161b22; }
|
|
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
.job-name { font-weight: 500; }
|
|
|
|
/* Step details */
|
|
details { margin-bottom: 8px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
|
|
summary { padding: 8px 12px; cursor: pointer; font-weight: 500; font-size: 14px; user-select: none; }
|
|
summary:hover { background: #21262d; }
|
|
details[open] summary { border-bottom: 1px solid #30363d; }
|
|
details table { border: none; margin: 0; }
|
|
details td, details th { font-size: 12px; }
|
|
|
|
/* Worst regressions */
|
|
.regressions { margin-bottom: 24px; }
|
|
.regressions table { font-size: 13px; }
|
|
.tag {
|
|
display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500;
|
|
}
|
|
.tag.slow { background: rgba(248,81,73,0.15); color: #f85149; }
|
|
.tag.fast { background: rgba(63,185,80,0.15); color: #3fb950; }
|
|
"""
|
|
|
|
|
|
def _gantt_bars(timings: dict, baseline: dict | None,
|
|
profiles: dict[str, dict] | None = None) -> str:
|
|
"""Render the gantt chart HTML.
|
|
|
|
Both current and baseline timelines are normalized to start at t=0
|
|
(relative to each run's earliest job start). The axis scale spans
|
|
0..max_end across both runs so bars are directly comparable.
|
|
|
|
When resource profiles are supplied, each matched job also carries a
|
|
CPU/RAM/disk overlay: a thin strip inside the collapsed bar, and a
|
|
full-height readable version once the job is expanded.
|
|
"""
|
|
jobs = [j for j in timings.get("jobs", [])
|
|
if j.get("started_at") and j.get("completed_at") and not is_skipped(j)]
|
|
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
|
job_profiles = match_profiles_to_jobs(timings, profiles or {})
|
|
|
|
# Current run: relative offsets from earliest start
|
|
cur_starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
|
|
cur_ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
|
|
if not cur_starts or not cur_ends:
|
|
return '<p style="color:#8b949e">No timing data available.</p>'
|
|
cur_t0 = min(cur_starts)
|
|
cur_max = (max(cur_ends) - cur_t0).total_seconds()
|
|
|
|
# Baseline run: max duration (for axis scale only — bars are aligned
|
|
# to the current job's start, not to the baseline timeline)
|
|
bl_max_dur = 0.0
|
|
for bl_j in bl_map.values():
|
|
if is_skipped(bl_j):
|
|
continue
|
|
s = parse_ts(bl_j.get("started_at"))
|
|
e = parse_ts(bl_j.get("completed_at"))
|
|
if s is not None and e is not None:
|
|
bl_max_dur = max(bl_max_dur, (e - s).total_seconds())
|
|
|
|
total_s = max(cur_max, bl_max_dur)
|
|
if total_s <= 0:
|
|
total_s = 1
|
|
|
|
rows = []
|
|
for j in jobs:
|
|
s = parse_ts(j.get("started_at"))
|
|
e = parse_ts(j.get("completed_at"))
|
|
if s is None or e is None:
|
|
continue
|
|
left = (s - cur_t0).total_seconds() / total_s * 100
|
|
width = max((e - s).total_seconds() / total_s * 100, 0.5) # min 0.5% for visibility
|
|
dur = j.get("duration_s") or 0
|
|
|
|
bl = bl_map.get(j["name"])
|
|
bl_bar = ""
|
|
if bl and not is_skipped(bl):
|
|
bl_s = parse_ts(bl.get("started_at"))
|
|
bl_e = parse_ts(bl.get("completed_at"))
|
|
if bl_s is not None and bl_e is not None:
|
|
# Align baseline bar to the current job's start so the
|
|
# two durations are directly comparable — the baseline's
|
|
# own wait/position is irrelevant for a duration diff.
|
|
bl_left = left
|
|
bl_width = max((bl_e - bl_s).total_seconds() / total_s * 100, 0.5)
|
|
bl_dur = bl.get("duration_s") or 0
|
|
bl_bar = (
|
|
f'<div class="gantt-bar baseline" '
|
|
f'style="left:{bl_left:.2f}%;width:{bl_width:.2f}%" '
|
|
f'title="baseline: {fmt_dur(bl_dur)}"></div>'
|
|
)
|
|
|
|
name_display = escape(j["name"])
|
|
if j.get("workflow_name"):
|
|
name_display = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
|
|
|
|
delta_info = ""
|
|
if bl and not is_skipped(bl) and bl.get("duration_s") is not None:
|
|
d_text, d_cls = fmt_delta(dur, bl.get("duration_s"))
|
|
delta_info = f' — {d_text}'
|
|
|
|
# Wait bar: shows idle time before the job started running
|
|
wait_bar = ""
|
|
wait_s = j.get("wait_s")
|
|
if wait_s and wait_s >= 1.0:
|
|
wait_left = (s - cur_t0).total_seconds() - wait_s
|
|
wait_left_pct = max(wait_left / total_s * 100, 0)
|
|
wait_width_pct = max(wait_s / total_s * 100, 0.3)
|
|
wait_bar = (
|
|
f'<div class="gantt-bar wait" '
|
|
f'style="left:{wait_left_pct:.2f}%;width:{wait_width_pct:.2f}%" '
|
|
f'title="{escape(j["name"])} — waited: {fmt_dur(wait_s)}"></div>'
|
|
)
|
|
|
|
# Segment the job bar by step, so the composition of a job is visible
|
|
# without expanding it. Segments are nested INSIDE the job bar, so
|
|
# their offsets are percentages of the bar's own width (not of the
|
|
# whole track) — hence job_span, not total_s, as the denominator.
|
|
# The expanded step rows sit in the track instead, so those use the
|
|
# track scale.
|
|
#
|
|
# GitHub reports step timestamps at second granularity, so a
|
|
# sub-second step has started_at == completed_at and would render
|
|
# zero-width. Every segment gets a small minimum width; the tooltip
|
|
# carries the true duration. Short steps stay hoverable at the cost
|
|
# of segments summing to slightly over 100% on very short jobs.
|
|
job_span = (e - s).total_seconds() or 1.0
|
|
segments = []
|
|
step_rows = []
|
|
for st in (j.get("steps") or []):
|
|
st_s = parse_ts(st.get("started_at"))
|
|
st_e = parse_ts(st.get("completed_at"))
|
|
if st_s is None or st_e is None:
|
|
continue
|
|
cat = classify_step(st.get("name", ""))
|
|
st_dur = st.get("duration_s") or 0
|
|
st_name = escape(display_step_name(st.get("name", "")))
|
|
tip = f'{st_name} — {fmt_dur(st_dur)} [{cat}]'
|
|
|
|
# Within-bar coordinates (percent of the job bar).
|
|
in_left = max((st_s - s).total_seconds() / job_span * 100, 0)
|
|
in_width = max((st_e - st_s).total_seconds() / job_span * 100, 0.4)
|
|
segments.append(
|
|
f'<div class="gantt-seg {cat}" '
|
|
f'style="left:{in_left:.3f}%;width:{in_width:.3f}%" '
|
|
f'title="{tip}"></div>'
|
|
)
|
|
|
|
# Track coordinates (percent of the whole timeline) for the
|
|
# expanded rows.
|
|
tr_left = (st_s - cur_t0).total_seconds() / total_s * 100
|
|
tr_width = max((st_e - st_s).total_seconds() / total_s * 100, 0.15)
|
|
step_rows.append(
|
|
f'<div class="gantt-row step">'
|
|
f'<div class="gantt-label step" title="{st_name}">{st_name}</div>'
|
|
f'<div class="gantt-track">'
|
|
f'<div class="gantt-bar step {cat}" '
|
|
f'style="left:{tr_left:.3f}%;width:{tr_width:.3f}%" '
|
|
f'title="{tip}"></div>'
|
|
f'</div></div>'
|
|
)
|
|
|
|
seg_html = "".join(segments)
|
|
|
|
# Resource overlay, drawn over the PROFILED WINDOW rather than the
|
|
# whole job — see _profile_window_frac. The collapsed strip is a
|
|
# child of the bar (clipped to it, adds no row height) so it scales
|
|
# the fraction against the bar; the expanded layer is its own track
|
|
# row, so it scales against the track.
|
|
profile = job_profiles.get(j["name"])
|
|
f_left, f_width = _profile_window_frac(profile, s, e)
|
|
|
|
collapsed_overlay = _resource_overlay(profile, expanded=False)
|
|
if collapsed_overlay:
|
|
collapsed_overlay = (
|
|
f'<div class="res-clip" '
|
|
f'style="left:{f_left * 100:.2f}%;width:{f_width * 100:.2f}%">'
|
|
f'{collapsed_overlay}</div>'
|
|
)
|
|
|
|
expanded_overlay = ""
|
|
inner = _resource_overlay(profile, expanded=True)
|
|
if inner:
|
|
expanded_overlay = (
|
|
f'<div class="res-layer">'
|
|
f'<div class="gantt-label"></div>'
|
|
f'<div class="gantt-track">'
|
|
f'<div class="res-holder" '
|
|
f'style="left:{left + f_left * width:.2f}%;'
|
|
f'width:{f_width * width:.2f}%">{inner}</div>'
|
|
f'</div></div>'
|
|
)
|
|
|
|
# Only offer expansion when there is detail to show.
|
|
has_detail = bool(step_rows or expanded_overlay)
|
|
expandable = " expandable" if has_detail else ""
|
|
caret = '<span class="caret">▸</span>' if has_detail else ""
|
|
|
|
rows.append(
|
|
f'<div class="gantt-group">'
|
|
f'<div class="gantt-row job{expandable}">'
|
|
f'<div class="gantt-label" title="{escape(j["name"])}">{caret}{name_display}</div>'
|
|
f'<div class="gantt-track">'
|
|
f'{bl_bar}'
|
|
f'{wait_bar}'
|
|
f'<div class="gantt-bar current" '
|
|
f'style="left:{left:.2f}%;width:{width:.2f}%" '
|
|
f'title="{escape(j["name"])}: {fmt_dur(dur)}{delta_info}">'
|
|
f'{seg_html}{collapsed_overlay}</div>'
|
|
f'</div></div>'
|
|
f'<div class="gantt-steps">{expanded_overlay}{"".join(step_rows)}</div>'
|
|
f'</div>'
|
|
)
|
|
|
|
# Axis
|
|
ticks = nice_ticks(total_s)
|
|
tick_html = "".join(
|
|
f'<span class="gantt-tick" style="left:{(t / total_s * 100):.1f}%">{fmt_tick(t)}</span>'
|
|
for t in ticks
|
|
)
|
|
# Empty label spacer keeps the axis track aligned with the bar tracks
|
|
# (each gantt-row is [label][track]; without the spacer the axis spans
|
|
# the label column too and every tick lands left of its true position).
|
|
axis = (
|
|
f'<div class="gantt-axis">'
|
|
f'<div class="gantt-label"></div>'
|
|
f'<div class="gantt-track">{tick_html}</div>'
|
|
f'</div>'
|
|
)
|
|
|
|
legend = (
|
|
'<div class="legend">'
|
|
'<span><span class="legend-swatch" style="background:#d29922"></span>Setup</span>'
|
|
'<span><span class="legend-swatch" style="background:#1f6feb"></span>Work</span>'
|
|
'<span><span class="legend-swatch" style="background:#8957e5"></span>Teardown</span>'
|
|
'<span><span class="legend-swatch" style="background:repeating-linear-gradient(45deg,#30363d,#30363d 3px,transparent 3px,transparent 6px);opacity:0.6"></span>Wait</span>'
|
|
)
|
|
if baseline:
|
|
legend += '<span><span class="legend-swatch" style="border:1px dashed #8b949e"></span>Baseline (main)</span>'
|
|
if job_profiles:
|
|
legend += (
|
|
'<span class="legend-sep">|</span>'
|
|
'<span><span class="legend-swatch" style="background:#3fb950"></span>CPU %</span>'
|
|
'<span><span class="legend-swatch" style="background:#d29922"></span>RAM %</span>'
|
|
'<span><span class="legend-swatch" style="background:#f85149"></span>Disk %</span>'
|
|
)
|
|
legend += '</div>'
|
|
|
|
hint_extra = ""
|
|
if job_profiles:
|
|
hint_extra = (
|
|
'The thin strip along the bottom of a bar is node CPU / RAM / disk '
|
|
f'utilisation over that job ({len(job_profiles)} profiled); expand for a readable version. '
|
|
)
|
|
hint = (
|
|
'<div class="gantt-hint">'
|
|
'Bars are segmented by step — hover a segment for its name and duration. '
|
|
f'{hint_extra}'
|
|
'Click a job to expand its steps. '
|
|
'<button type="button" id="gantt-toggle-all">Expand all</button>'
|
|
'</div>'
|
|
)
|
|
|
|
# Vanilla JS, no deps: the report is a single self-contained HTML file
|
|
# served from an artifact URL.
|
|
script = """
|
|
<script>
|
|
(function () {
|
|
var groups = Array.prototype.slice.call(
|
|
document.querySelectorAll('.gantt-group')
|
|
);
|
|
groups.forEach(function (g) {
|
|
var row = g.querySelector('.gantt-row.job.expandable');
|
|
if (!row) return;
|
|
row.addEventListener('click', function () { g.classList.toggle('open'); });
|
|
});
|
|
var btn = document.getElementById('gantt-toggle-all');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', function () {
|
|
var expandable = groups.filter(function (g) {
|
|
return g.querySelector('.gantt-row.job.expandable');
|
|
});
|
|
var anyClosed = expandable.some(function (g) {
|
|
return !g.classList.contains('open');
|
|
});
|
|
expandable.forEach(function (g) { g.classList.toggle('open', anyClosed); });
|
|
btn.textContent = anyClosed ? 'Collapse all' : 'Expand all';
|
|
});
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
return (
|
|
f'{hint}<div class="gantt-wrap"><div class="gantt">{"".join(rows)}{axis}</div></div>'
|
|
f'{legend}{script}'
|
|
)
|
|
|
|
|
|
def _overhead_section(timings: dict) -> str:
|
|
"""Quantify how much CI compute goes to setup/teardown rather than work.
|
|
|
|
This is the "is checkout the bottleneck?" answer: a stacked bar over all
|
|
accounted step time, plus the individual overhead steps that cost the
|
|
most summed across every job.
|
|
"""
|
|
ov = compute_overhead(timings)
|
|
total = ov["accounted_s"]
|
|
if total <= 0:
|
|
return ('<p style="color:#8b949e">No per-step timing data available '
|
|
'(GitHub returns steps only for jobs this token can read).</p>')
|
|
|
|
setup_pct = ov["setup_s"] / total * 100
|
|
work_pct = ov["work_s"] / total * 100
|
|
teardown_pct = ov["teardown_s"] / total * 100
|
|
|
|
def seg(cls, pct, label):
|
|
if pct < 0.5:
|
|
return ""
|
|
# Only label a segment wide enough to hold text.
|
|
text = label if pct >= 8 else ""
|
|
return (f'<div class="overhead-seg {cls}" style="width:{pct:.2f}%" '
|
|
f'title="{label}">{text}</div>')
|
|
|
|
bar = (
|
|
'<div class="overhead-bar">'
|
|
+ seg("setup", setup_pct, f'Setup {fmt_dur(ov["setup_s"])} ({setup_pct:.0f}%)')
|
|
+ seg("work", work_pct, f'Work {fmt_dur(ov["work_s"])} ({work_pct:.0f}%)')
|
|
+ seg("teardown", teardown_pct,
|
|
f'Teardown {fmt_dur(ov["teardown_s"])} ({teardown_pct:.0f}%)')
|
|
+ '</div>'
|
|
)
|
|
|
|
headline = (
|
|
f'<p style="margin:4px 0 12px"><strong>{ov["overhead_pct"]:.0f}%</strong> of '
|
|
f'accounted step time ({fmt_dur(ov["overhead_s"])} of {fmt_dur(total)}) is '
|
|
f'setup + teardown rather than useful work, across {ov["jobs_with_steps"]} jobs.</p>'
|
|
)
|
|
|
|
rows = []
|
|
for s in ov["top_overhead_steps"]:
|
|
pct = s["total_s"] / total * 100
|
|
rows.append(
|
|
f'<tr><td class="job-name">{escape(display_step_name(s["name"]))}</td>'
|
|
f'<td>{s["category"]}</td>'
|
|
f'<td class="num">{s["count"]}</td>'
|
|
f'<td class="num">{fmt_dur(s["total_s"])}</td>'
|
|
f'<td class="num">{pct:.1f}%</td></tr>'
|
|
)
|
|
table = (
|
|
'<table><thead><tr><th>Overhead step</th><th>Kind</th>'
|
|
'<th class="num">Jobs</th><th class="num">Total</th>'
|
|
'<th class="num">% of step time</th></tr></thead>'
|
|
f'<tbody>{"".join(rows)}</tbody></table>'
|
|
) if rows else ""
|
|
|
|
note = (
|
|
'<p style="font-size:12px;color:#8b949e">Percentages are of summed '
|
|
'step time across jobs (not wall time — jobs run in parallel). '
|
|
'Unrecognized step names count as work, so this under-reports rather '
|
|
'than inflates overhead.</p>'
|
|
)
|
|
return headline + bar + table + note
|
|
|
|
|
|
def _stats_cards(stats: dict) -> str:
|
|
wall_text = fmt_dur(stats["wall"])
|
|
wall_delta = ""
|
|
if stats["bl_wall"] is not None:
|
|
d, cls = fmt_delta(stats["wall"], stats["bl_wall"])
|
|
wall_delta = f'<span class="stat-delta {cls}">{d}</span>'
|
|
|
|
compute_text = fmt_dur(stats["compute"])
|
|
compute_delta = ""
|
|
if stats["bl_compute"] is not None:
|
|
d, cls = fmt_delta(stats["compute"], stats["bl_compute"])
|
|
compute_delta = f'<span class="stat-delta {cls}">{d}</span>'
|
|
|
|
cards = [
|
|
f'<div class="stat-card"><span class="stat-label">Wall Time</span>'
|
|
f'<div class="stat-value">{wall_text}</div>{wall_delta}</div>',
|
|
f'<div class="stat-card"><span class="stat-label">Total Compute</span>'
|
|
f'<div class="stat-value">{compute_text}</div>{compute_delta}</div>',
|
|
f'<div class="stat-card"><span class="stat-label">Jobs Faster</span>'
|
|
f'<div class="stat-value faster">{stats["faster"]}</div></div>',
|
|
f'<div class="stat-card"><span class="stat-label">Jobs Slower</span>'
|
|
f'<div class="stat-value slower">{stats["slower"]}</div></div>',
|
|
f'<div class="stat-card"><span class="stat-label">Unchanged</span>'
|
|
f'<div class="stat-value neutral">{stats["unchanged"]}</div></div>',
|
|
f'<div class="stat-card"><span class="stat-label">Skipped</span>'
|
|
f'<div class="stat-value neutral">{stats["skipped"]}</div></div>',
|
|
f'<div class="stat-card"><span class="stat-label">No Baseline</span>'
|
|
f'<div class="stat-value neutral">{stats["no_baseline"]}</div></div>',
|
|
]
|
|
return f'<div class="stats">{"".join(cards)}</div>'
|
|
|
|
|
|
def _job_table(timings: dict, baseline: dict | None) -> str:
|
|
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
|
rows = []
|
|
for j in timings.get("jobs", []):
|
|
name = escape(j["name"])
|
|
if j.get("workflow_name"):
|
|
name = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
|
|
|
|
concl = j.get("conclusion", "")
|
|
concl_icon = {"success": "✓", "failure": "✗", "skipped": "⊘"}.get(concl, "?")
|
|
concl_cls = {"success": "faster", "failure": "slower", "skipped": "neutral"}.get(concl, "neutral")
|
|
|
|
if is_skipped(j):
|
|
rows.append(
|
|
f'<tr>'
|
|
f'<td class="job-name">{name}</td>'
|
|
f'<td class="num neutral">skipped</td>'
|
|
f'<td class="num neutral">—</td>'
|
|
f'<td class="num neutral">—</td>'
|
|
f'<td class="num neutral">—</td>'
|
|
f'<td class="num neutral">—</td>'
|
|
f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
|
|
f'</tr>'
|
|
)
|
|
continue
|
|
|
|
dur = j.get("duration_s")
|
|
wait = j.get("wait_s")
|
|
bl = bl_map.get(j["name"])
|
|
bl_dur = bl.get("duration_s") if bl and not is_skipped(bl) else None
|
|
bl_wait = bl.get("wait_s") if bl and not is_skipped(bl) else None
|
|
delta_text, delta_cls = fmt_delta(dur, bl_dur)
|
|
wait_delta_text, wait_delta_cls = fmt_delta(wait, bl_wait)
|
|
|
|
rows.append(
|
|
f'<tr>'
|
|
f'<td class="job-name">{name}</td>'
|
|
f'<td class="num">{fmt_dur(dur)}</td>'
|
|
f'<td class="num">{fmt_dur(wait)}</td>'
|
|
f'<td class="num">{fmt_dur(bl_dur)}</td>'
|
|
f'<td class="num {delta_cls}">{delta_text}</td>'
|
|
f'<td class="num {wait_delta_cls}">{wait_delta_text}</td>'
|
|
f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
|
|
f'</tr>'
|
|
)
|
|
|
|
return (
|
|
'<table><thead><tr>'
|
|
'<th>Job</th><th class="num">Run</th><th class="num">Wait</th>'
|
|
'<th class="num">Baseline</th><th class="num">Δ Run</th>'
|
|
'<th class="num">Δ Wait</th><th>Status</th>'
|
|
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
|
|
)
|
|
|
|
|
|
def _step_details(timings: dict, baseline: dict | None) -> str:
|
|
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
|
blocks = []
|
|
for j in timings.get("jobs", []):
|
|
if not j.get("steps"):
|
|
continue
|
|
if is_skipped(j):
|
|
continue
|
|
bl = bl_map.get(j["name"], {})
|
|
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
|
|
|
|
dur = j.get("duration_s") or 0
|
|
wait = j.get("wait_s")
|
|
bl_dur = bl.get("duration_s") if bl and not is_skipped(bl) else None
|
|
delta_text, delta_cls = fmt_delta(dur, bl_dur)
|
|
|
|
summary_text = f'{escape(j["name"])} — {fmt_dur(dur)}'
|
|
if wait is not None and wait >= 1.0:
|
|
summary_text += f' <span class="neutral">(wait {fmt_dur(wait)})</span>'
|
|
if bl_dur is not None:
|
|
summary_text += f' <span class="{delta_cls}">({delta_text})</span>'
|
|
|
|
step_rows = []
|
|
for s in j["steps"]:
|
|
s_dur = s.get("duration_s")
|
|
bl_s = bl_steps.get(s["name"])
|
|
bl_s_dur = bl_s.get("duration_s") if bl_s else None
|
|
s_delta, s_cls = fmt_delta(s_dur, bl_s_dur)
|
|
|
|
step_rows.append(
|
|
f'<tr>'
|
|
f'<td>{escape(display_step_name(s["name"]))}</td>'
|
|
f'<td class="num">{fmt_dur(s_dur)}</td>'
|
|
f'<td class="num">{fmt_dur(bl_s_dur)}</td>'
|
|
f'<td class="num {s_cls}">{s_delta}</td>'
|
|
f'</tr>'
|
|
)
|
|
|
|
blocks.append(
|
|
f'<details><summary>{summary_text}</summary>'
|
|
f'<table><thead><tr>'
|
|
'<th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
|
|
'<th class="num">Delta</th>'
|
|
f'</tr></thead><tbody>{"".join(step_rows)}</tbody></table>'
|
|
f'</details>'
|
|
)
|
|
|
|
return "".join(blocks) if blocks else '<p style="color:#8b949e">No step data available.</p>'
|
|
|
|
|
|
def _regressions(timings: dict, baseline: dict | None) -> str:
|
|
"""Show top 10 biggest absolute regressions/improvements across all steps."""
|
|
if not baseline:
|
|
return ""
|
|
bl_map = {j["name"]: j for j in baseline.get("jobs", [])}
|
|
|
|
deltas = [] # (abs_delta, job_name, step_name, current, baseline, is_slower)
|
|
for j in timings.get("jobs", []):
|
|
if is_skipped(j):
|
|
continue
|
|
bl = bl_map.get(j["name"])
|
|
if not bl or is_skipped(bl):
|
|
continue
|
|
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
|
|
for s in j.get("steps", []):
|
|
bl_s = bl_steps.get(s["name"])
|
|
if not bl_s:
|
|
continue
|
|
cur = s.get("duration_s") or 0
|
|
bl_d = bl_s.get("duration_s") or 0
|
|
diff = cur - bl_d
|
|
if abs(diff) < 1.0:
|
|
continue
|
|
deltas.append((abs(diff), diff, j["name"], s["name"], cur, bl_d))
|
|
|
|
deltas.sort(key=lambda x: x[0], reverse=True)
|
|
top = deltas[:10]
|
|
if not top:
|
|
return ""
|
|
|
|
rows = []
|
|
for _, diff, job, step, cur, bl_d in top:
|
|
cls = "slower" if diff > 0 else "faster"
|
|
tag = f'<span class="tag {"slow" if diff > 0 else "fast"}">{"+" if diff > 0 else ""}{diff:.1f}s</span>'
|
|
rows.append(
|
|
f'<tr>'
|
|
f'<td class="job-name">{escape(job)}</td>'
|
|
f'<td>{escape(display_step_name(step))}</td>'
|
|
f'<td class="num">{fmt_dur(cur)}</td>'
|
|
f'<td class="num">{fmt_dur(bl_d)}</td>'
|
|
f'<td>{tag}</td>'
|
|
f'</tr>'
|
|
)
|
|
|
|
return (
|
|
'<div class="regressions">'
|
|
'<table><thead><tr>'
|
|
'<th>Job</th><th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
|
|
'<th>Delta</th>'
|
|
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
|
|
'</div>'
|
|
)
|
|
|
|
|
|
def _resource_table(profiles: dict[str, dict]) -> str:
|
|
"""Render per-job resource usage as an HTML table."""
|
|
if not profiles:
|
|
return ""
|
|
|
|
rows = []
|
|
for label in sorted(profiles):
|
|
p = profiles[label]
|
|
cpu = p.get("cpu", {})
|
|
mem = p.get("memory", {})
|
|
disk = p.get("disk", {})
|
|
|
|
rows.append(
|
|
f'<tr>'
|
|
f'<td class="job-name">{escape(label)}</td>'
|
|
f'<td class="num">{fmt_dur(p.get("duration_s"))}</td>'
|
|
f'<td class="num">{cpu.get("avg_usage_pct", 0):.0f}%</td>'
|
|
f'<td class="num">{cpu.get("peak_usage_pct", 0):.0f}%</td>'
|
|
f'<td class="num">{mem.get("avg_mb", 0):.0f}</td>'
|
|
f'<td class="num">{mem.get("peak_mb", 0):.0f}</td>'
|
|
f'<td class="num">{disk.get("total_mb", 0):.0f}</td>'
|
|
f'<td class="num">{disk.get("avg_ops_per_s", 0):.0f}</td>'
|
|
f'<td class="num">{disk.get("avg_util_pct", 0):.0f}%</td>'
|
|
f'</tr>'
|
|
)
|
|
|
|
return (
|
|
'<table><thead><tr>'
|
|
'<th>Job</th><th class="num">Duration</th>'
|
|
'<th class="num">CPU avg</th><th class="num">CPU peak</th>'
|
|
'<th class="num">Mem avg (MB)</th><th class="num">Mem peak (MB)</th>'
|
|
'<th class="num">Disk (MB)</th><th class="num">Disk ops/s</th>'
|
|
'<th class="num">Disk util</th>'
|
|
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
|
|
)
|
|
|
|
|
|
def _bottleneck_box(timings: dict, profiles: dict[str, dict]) -> str:
|
|
"""Render the bottleneck analysis as a callout box."""
|
|
verdict = classify_bottleneck(timings, profiles)
|
|
return (
|
|
f'<div style="background:#161b22;border:1px solid #30363d;'
|
|
f'border-radius:8px;padding:16px;margin-bottom:24px">'
|
|
f'<div style="font-size:12px;color:#8b949e;text-transform:uppercase;'
|
|
f'letter-spacing:0.5px;margin-bottom:4px">Bottleneck Analysis</div>'
|
|
f'<div style="font-size:16px;font-weight:500">{escape(verdict)}</div>'
|
|
f'</div>'
|
|
)
|
|
|
|
|
|
def generate_html(timings: dict, baseline: dict | None = None,
|
|
profiles: dict[str, dict] | None = None) -> str:
|
|
stats = compute_stats(timings, baseline)
|
|
|
|
sha_short = (timings.get("head_sha") or "")[:7]
|
|
run_id = timings.get("run_id", "?")
|
|
created = timings.get("created_at", "")
|
|
|
|
bl_info = ""
|
|
if baseline:
|
|
bl_sha = (baseline.get("head_sha") or "")[:7]
|
|
bl_info = f' | Baseline: <code>{bl_sha}</code> (main)'
|
|
|
|
html = (
|
|
f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
|
f'<meta charset="utf-8">\n'
|
|
f'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
f'<title>CI Timing Report — {sha_short}</title>\n'
|
|
f'<style>{CSS}</style>\n'
|
|
f'</head>\n<body>\n'
|
|
f'<h1>CI Timing Report</h1>\n'
|
|
f'<div class="meta">Run <code>{escape(run_id)}</code> | SHA <code>{sha_short}</code>'
|
|
f' | Generated {escape(created)}{bl_info}</div>\n'
|
|
)
|
|
|
|
html += '<h2>Global Stats</h2>\n'
|
|
html += _stats_cards(stats)
|
|
|
|
html += _bottleneck_box(timings, profiles or {})
|
|
|
|
html += '<h2>Setup vs Work</h2>\n'
|
|
html += _overhead_section(timings)
|
|
|
|
if profiles:
|
|
html += '<h2>Resource Usage</h2>\n'
|
|
html += _resource_table(profiles)
|
|
|
|
if baseline:
|
|
html += '<h2>Top Regressions & Improvements</h2>\n'
|
|
html += _regressions(timings, baseline)
|
|
|
|
html += '<h2>Gantt Chart</h2>\n'
|
|
html += _gantt_bars(timings, baseline, profiles)
|
|
|
|
html += '<h2>Per-Job Comparison</h2>\n'
|
|
html += _job_table(timings, baseline)
|
|
|
|
html += '<h2>Step Details</h2>\n'
|
|
html += _step_details(timings, baseline)
|
|
|
|
html += '</body>\n</html>\n'
|
|
return html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown summary for $GITHUB_STEP_SUMMARY
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_summary(timings: dict, baseline: dict | None = None,
|
|
profiles: dict[str, dict] | None = None) -> str:
|
|
stats = compute_stats(timings, baseline)
|
|
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
|
|
|
lines = ["## CI Timing Summary\n"]
|
|
|
|
# Bottleneck analysis
|
|
bottleneck = classify_bottleneck(timings, profiles or {})
|
|
lines.append(f"**Bottleneck:** {bottleneck}")
|
|
lines.append("")
|
|
|
|
# Global stats table
|
|
lines.append("| Metric | Current | Baseline | Delta |")
|
|
lines.append("|--------|---------|----------|-------|")
|
|
|
|
wall_d = ""
|
|
if stats["bl_wall"] is not None:
|
|
d, _ = fmt_delta(stats["wall"], stats["bl_wall"])
|
|
wall_d = d
|
|
lines.append(f"| Wall time | {fmt_dur(stats['wall'])} | {fmt_dur(stats['bl_wall'])} | {wall_d} |")
|
|
|
|
compute_d = ""
|
|
if stats["bl_compute"] is not None:
|
|
d, _ = fmt_delta(stats["compute"], stats["bl_compute"])
|
|
compute_d = d
|
|
lines.append(f"| Total compute | {fmt_dur(stats['compute'])} | {fmt_dur(stats['bl_compute'])} | {compute_d} |")
|
|
|
|
wait_d = ""
|
|
if stats["bl_total_wait"] is not None:
|
|
d, _ = fmt_delta(stats["total_wait"], stats["bl_total_wait"])
|
|
wait_d = d
|
|
lines.append(f"| Total wait | {fmt_dur(stats['total_wait'])} | {fmt_dur(stats['bl_total_wait'])} | {wait_d} |")
|
|
|
|
lines.append(f"| Jobs faster | {stats['faster']} | — | — |")
|
|
lines.append(f"| Jobs slower | {stats['slower']} | — | — |")
|
|
lines.append(f"| Jobs unchanged | {stats['unchanged']} | — | — |")
|
|
lines.append(f"| Jobs skipped | {stats['skipped']} | {stats['bl_skipped']} | — |")
|
|
lines.append(f"| Jobs without baseline | {stats['no_baseline']} | — | — |")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Review status JSON for the unified PR comment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Wall-time regressions above this fraction of baseline are "warning" severity.
|
|
_TIMINGS_WARN_PCT = 0.25
|
|
|
|
|
|
def generate_review_status(
|
|
timings: dict, baseline: dict | None, report_url: str | None = None,
|
|
profiles: dict[str, dict] | None = None
|
|
) -> list[dict]:
|
|
"""Produce a review_status JSON array for the CI timings review section.
|
|
|
|
Returns a list with one ``{source, results: [...]}`` entry. The
|
|
result kind is ``"debug"`` or ``"warning"`` (timings is never error —
|
|
it's an observability job). *summary* is a single short line suitable
|
|
for the PR comment. *detail* has the per-job deltas as a markdown
|
|
fragment.
|
|
"""
|
|
stats = compute_stats(timings, baseline)
|
|
bottleneck = classify_bottleneck(timings, profiles or {})
|
|
|
|
if baseline is None:
|
|
severity = "debug"
|
|
summary = f"Wall time {fmt_dur(stats['wall'])} (no baseline yet)."
|
|
else:
|
|
wall = stats["wall"]
|
|
bl_wall = stats["bl_wall"] or 0
|
|
if bl_wall > 0:
|
|
pct = (wall - bl_wall) / bl_wall * 100
|
|
wall_str = f"Wall time {fmt_dur(wall)} vs {fmt_dur(bl_wall)} ({pct:+.1f}%)."
|
|
if pct > _TIMINGS_WARN_PCT * 100:
|
|
severity = "warning"
|
|
else:
|
|
severity = "debug"
|
|
else:
|
|
wall_str = f"Wall time {fmt_dur(wall)}."
|
|
severity = "debug"
|
|
|
|
if stats["slower"]:
|
|
wall_str += f" {stats['slower']} job(s) slower,"
|
|
if stats["faster"]:
|
|
wall_str += f" {stats['faster']} faster,"
|
|
if stats["unchanged"]:
|
|
wall_str += f" {stats['unchanged']} unchanged."
|
|
summary = wall_str
|
|
|
|
summary += f" Bottleneck: {bottleneck}"
|
|
|
|
# Per-job delta detail (top 5 by absolute change)
|
|
detail_lines: list[str] = []
|
|
if baseline:
|
|
bl_map = {j["name"]: j for j in baseline.get("jobs", [])}
|
|
deltas: list[tuple[float, str, str]] = []
|
|
for j in timings.get("jobs", []):
|
|
if is_skipped(j):
|
|
continue
|
|
bl = bl_map.get(j["name"])
|
|
if not bl or is_skipped(bl):
|
|
continue
|
|
cur = j.get("duration_s") or 0
|
|
bl_d = bl.get("duration_s") or 0
|
|
diff = cur - bl_d
|
|
if abs(diff) < 1.0:
|
|
continue
|
|
deltas.append((abs(diff), j["name"], f"{diff:+.1f}s"))
|
|
deltas.sort(reverse=True)
|
|
for _, name, delta_str in deltas[:5]:
|
|
detail_lines.append(f"- {name}: {delta_str}")
|
|
|
|
result: dict = {
|
|
"kind": severity,
|
|
"title": "CI timings",
|
|
"summary": summary,
|
|
"detail": "\n".join(detail_lines),
|
|
}
|
|
if report_url:
|
|
result["link"] = report_url
|
|
result["link_label"] = "View report"
|
|
|
|
return [{"source": "ci timing", "results": [result]}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def expect_env(var: str) -> str:
|
|
val = os.environ.get(var)
|
|
if not val:
|
|
raise ValueError(f"missing environment variable {var}")
|
|
return val
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Collect CI timings and generate HTML report")
|
|
parser.add_argument("--from-json", help="Read timings from JSON instead of API")
|
|
parser.add_argument("--baseline", default="ci-timings-baseline.json",
|
|
help="Baseline JSON path (default: ci-timings-baseline.json)")
|
|
parser.add_argument("--output", default="ci-timings-report.html",
|
|
help="HTML output path (default: ci-timings-report.html)")
|
|
parser.add_argument("--json-out", default="ci-timings.json",
|
|
help="JSON output path (default: ci-timings.json)")
|
|
parser.add_argument("--summary-out", default="ci-timings-summary.md",
|
|
help="Markdown summary output path (default: ci-timings-summary.md)")
|
|
parser.add_argument("--review-status-out", default="",
|
|
help="If set, write a review-status JSON for the unified PR comment.")
|
|
parser.add_argument("--review-status-only", action="store_true",
|
|
help="Write review status from existing timings without regenerating the report.")
|
|
parser.add_argument("--profiles-dir", default="",
|
|
help="Directory of downloaded resource-profile-* artifacts.")
|
|
args = parser.parse_args()
|
|
|
|
# Load resource profiles (available in both API and --from-json modes)
|
|
profiles = load_resource_profiles(args.profiles_dir) if args.profiles_dir else {}
|
|
|
|
# Collect or load timings
|
|
if args.from_json:
|
|
with open(args.from_json, encoding="utf-8") as f:
|
|
timings = json.load(f)
|
|
else:
|
|
repo = expect_env("GITHUB_REPOSITORY")
|
|
run_id = expect_env("GITHUB_RUN_ID")
|
|
head_sha = expect_env("GITHUB_SHA")
|
|
try:
|
|
# A missing token (e.g. an empty PAT on a fork PR, where repo
|
|
# secrets are unavailable) is a degraded run, not a hard error:
|
|
# route it through the same soft-fail path so this advisory job
|
|
# never reddens the PR.
|
|
token = os.environ.get("GITHUB_TOKEN")
|
|
if not token:
|
|
raise TimingsUnavailable("GITHUB_TOKEN is empty")
|
|
timings = collect_timings(token, repo, run_id, head_sha)
|
|
except TimingsUnavailable as e:
|
|
# Observability job: a missing report must never redden the PR.
|
|
# Emit a degraded summary + placeholder artifact and exit 0.
|
|
msg = f"CI timing data unavailable this run: {e}"
|
|
print(msg, file=sys.stderr)
|
|
with open(args.summary_out, "a", encoding="utf-8") as f:
|
|
f.write(f"\n> ⚠️ {msg}\n")
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(f"<html><body><p>{escape(msg)}</p></body></html>\n")
|
|
# No JSON on purpose: an empty timings file must never be cached
|
|
# as the main baseline.
|
|
sys.exit(0)
|
|
|
|
# Save JSON
|
|
with open(args.json_out, "w", encoding="utf-8") as f:
|
|
json.dump(timings, f, indent=2)
|
|
print(f"Saved timings to {args.json_out} ({len(timings.get('jobs', []))} jobs)")
|
|
|
|
# Load baseline
|
|
baseline = None
|
|
if os.path.exists(args.baseline):
|
|
with open(args.baseline, encoding="utf-8") as f:
|
|
baseline = json.load(f)
|
|
print(f"Loaded baseline from {args.baseline}")
|
|
else:
|
|
print(f"No baseline file at {args.baseline} — generating current-only report")
|
|
|
|
if args.review_status_only:
|
|
if not args.review_status_out:
|
|
parser.error("--review-status-only requires --review-status-out")
|
|
report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "")
|
|
statuses = generate_review_status(timings, baseline, report_url, profiles)
|
|
with open(args.review_status_out, "w", encoding="utf-8") as f:
|
|
f.write(f"review_status={json.dumps(statuses)}\n")
|
|
print(f"Wrote review status to {args.review_status_out}")
|
|
return
|
|
|
|
# Generate HTML
|
|
html = generate_html(timings, baseline, profiles)
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(html)
|
|
print(f"Generated HTML report: {args.output}")
|
|
|
|
# Write summary
|
|
summary = generate_summary(timings, baseline, profiles)
|
|
with open(args.summary_out, "a", encoding="utf-8") as f:
|
|
f.write(summary)
|
|
print(f"Wrote summary to {args.summary_out}")
|
|
|
|
# Write review status for the unified PR comment.
|
|
# The output goes to GITHUB_OUTPUT (or a file with the same key=value
|
|
# format) so the ci-timings job can expose it as a workflow_call output.
|
|
if args.review_status_out:
|
|
report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "")
|
|
statuses = generate_review_status(timings, baseline, report_url, profiles)
|
|
json_str = json.dumps(statuses)
|
|
with open(args.review_status_out, "a", encoding="utf-8") as f:
|
|
f.write(f"review_status={json_str}\n")
|
|
print(f"Wrote review status to {args.review_status_out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|