diff --git a/scripts/ci/timings_report.py b/scripts/ci/timings_report.py index 959cd1d9e..e2d5118ab 100644 --- a/scripts/ci/timings_report.py +++ b/scripts/ci/timings_report.py @@ -198,6 +198,38 @@ def _normalize_job(raw: dict) -> dict: } +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. @@ -248,6 +280,7 @@ def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict: 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, @@ -363,6 +396,8 @@ def compute_stats(timings: dict, baseline: dict | None = None) -> dict: 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, @@ -375,6 +410,8 @@ def compute_stats(timings: dict, baseline: dict | None = None) -> dict: "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), } @@ -423,6 +460,12 @@ h2 { font-size: 18px; margin: 32px 0 12px; } } .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; } @@ -533,11 +576,25 @@ def _gantt_bars(timings: dict, baseline: dict | None) -> str: 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'
' + ) + rows.append( f'| Job | Current | Baseline | ' - 'Delta | Status | ' + 'Job | Run | Wait | ' + 'Baseline | Δ Run | ' + 'Δ Wait | Status | ' '
|---|