Skip to content

fix(ci): cut cache-quota waste, add a self-serve build-health dashboard - #656

Open
balajinvda wants to merge 4 commits into
mainfrom
fix/ci-cache-quota
Open

fix(ci): cut cache-quota waste, add a self-serve build-health dashboard#656
balajinvda wants to merge 4 commits into
mainfrom
fix/ci-cache-quota

Conversation

@balajinvda

@balajinvda balajinvda commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why

Two questions came up that nobody could answer without hand-rolling gh api
calls: are the caches healthy, and why is the build slow.

The caches were not healthy. The repo sat at 9.42 GB of its 10 GB quota (94%,
and 97% by the time this was written), which means GitHub is evicting
least-recently-used entries continuously. That failure mode is invisible in a
green pipeline: jobs still pass, they just stop being fast. Two specific causes:

  • dependency-docs-bazel held 4.19 GB across three copies, 42% of the entire
    quota, because every branch saved its own full-size copy.
  • 1.90 GB sat on gh-readonly-queue/** refs. Those branches are deleted when
    the merge queue drains, so the entries can never be restored, but they still
    count against quota until evicted.

Answering the second question needed a tool, and the first version of that tool
was wrong in a way worth calling out: it sampled actions/runs?per_page=100 and
filtered by workflow name client-side, so it saw 14 of 1275 bazel runs. That led
me to state there was no history to trend against. There are three full weeks.

What changed

Cache:

  • Split both cache families into actions/cache/restore + guarded
    actions/cache/save. Dependency-docs saves only from main; bazel skips
    merge-queue refs. Restores are unchanged everywhere, so no job gets colder.

tools/ci-health (Go, with tools/ci/ci-health as the stable entrypoint):

  • Reads the per-workflow runs endpoint and pages through it.
  • --dashboard writes a self-contained HTML report and opens it. Inline SVG,
    no CDN and no JavaScript dependency, so it works offline and adds nothing to
    the dependency surface.
  • Separates queue wait from execution time. The Actions UI only shows the sum,
    and the distinction turns out to matter: stargate spends roughly 6.1 min
    waiting for a runner against roughly 3.4 min building.
  • Ranks the causes of slowness rather than dumping metrics.
  • Drops the oldest week from the trend when the history window was truncated;
    only the tail of that week is held, so its median plotted as a misleading
    near-zero point.
  • Flags when change detection skipped most matrix rows, so a fast median is not
    misread as a speedup.

Matrix:

  • max-parallel 8 to 12 on the bazel matrix.

Customer Release Notes

Not customer visible.

Plan Summary

Not applicable.

Usage

tools/ci/ci-health --dashboard     # visual report, opens in a browser
tools/ci/ci-health --why           # same findings, as text
tools/ci/ci-health                 # cache and quota only
tools/ci/ci-health --durations     # duration percentiles by week
tools/ci/ci-health --merge-times   # PR open to merge latency

Testing

go test ./tools/ci-health/...: 37 tests, no third-party dependencies. Each
case corresponds to a defect this tool actually shipped with, including the two
found in review: bare-array pagination and single-page cache listing. Coverage
includes percentile interpolation, the partial-week drop, skipped-matrix
classification, the queue/execution split, long-pole attribution excluding gate
jobs, tolerance of failed job fetches, and that the dashboard stays
self-contained.

Every subcommand was run against live data, including error paths. The dashboard
was rendered headless and inspected, not just parsed.

The full 25-row matrix passed on this branch with no HTTP 429s at the new cap.

Notes

On the max-parallel change, the measured result was weaker than predicted.
Comparing two 25-row pull_request runs, same branch and same runner class:

wall clock slowest row sum of row time
cap 8 10.2 min 6.3 min 77 min
cap 12 9.3 min 8.5 min 94 min

Wall clock improved 9%, but per-row time rose 22%. Both runs used GitHub-hosted
runners (separate VMs, so not CPU contention between rows); the shared remote
cache endpoint is the likeliest explanation. This is n=1 on each side, so it may
be noise. Worth a week of samples via tools/ci/ci-health --durations before
concluding either way, and easy to revert if not.

The cap also exists to keep simultaneous actions/checkout downloads under
GitHub's rate limit, which previously failed runs at "Set up job" with HTTP 429.
That is why this is 12 and not 16: combined with the bazel-docker matrix the
checkout burst goes from 12 to 16. If 429s reappear, walk this back first.

This fix stops the bleed but does not reclaim space. Deleting the two stranded
merge-queue entries frees 1.90 GB immediately.

GitHub Pages is not enabled on this repo, and enabling it would publish the
dashboard publicly. A scheduled workflow uploading the HTML as an artifact is
the better route if we want it without running the tool locally.

References

None

Related Merge Requests/Pull Requests

None

Dependencies

None. The dashboard is inline SVG specifically to avoid adding a charting
library, and the Go module has no requires.

Github commit:
fix(ci): cut cache-quota waste, add a self-serve build-health dashboard

Co-authored-by: Balaji Ganesan [email protected]

Summary by CodeRabbit

  • New Features

    • Added a CI health diagnostic tool that analyzes GitHub Actions workflow performance, including cache usage, job duration trends, slowness causes, and PR merge latency with an optional HTML dashboard.
  • Chores

    • Optimized CI caching strategy with separate restore and save operations to improve workflow efficiency.
    • Replaced Python-based CI health script with a Go-based implementation.

…tool

The repository was at 9.42 GB of its 10 GB Actions cache quota across 16
entries, so every new entry was evicting one another job needed. Nothing
reports this: jobs still pass, they are just colder.

Two causes.

dependency-docs-bazel was 4.19 GB, 42% of the whole budget, in three copies.
It caches a Bazel build of five Java runtime inventories, and every branch
wrote its own full-size copy. It now restores everywhere and saves only from
main, so one authoritative entry serves every branch.

Merge-queue refs were duplicating the largest keys. gh-readonly-queue/**
branches are deleted when the queue drains, so an entry saved there can never
be restored, but it still counts against quota until evicted. 1.90 GB was sat
in exactly that state, from the pr-593 and pr-595 merges. The bazel cache is
now split into restore plus a save that skips those refs.

Adds tools/ci/ci-health so this is answerable without hand-written gh api
calls: cache totals against quota, largest families with their share,
duplicate keys across refs with the merge-queue waste called out, workflow
duration percentiles by day, and PR open-to-merge latency.

It also records the trap in reading those numbers: a low median duration
usually means change detection skipped rows, not that builds got faster, so
the report prints run counts and p90 alongside.

Co-authored-by: Balaji Ganesan <[email protected]>
@balajinvda
balajinvda requested a review from a team as a code owner August 4, 2026 15:40
@balajinvda
balajinvda requested a review from apartha-nv August 4, 2026 15:40
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR separates CI cache restore and save operations, increases Bazel concurrency, and replaces the CI-health script with a Go CLI. The CLI collects GitHub Actions data, analyzes timing and cache metrics, and produces text or self-contained HTML reports with comprehensive tests.

Changes

CI health and cache management

Layer / File(s) Summary
Bazel cache restore and save controls
.github/workflows/bazel.yml, .github/workflows/license-dependencies.yml
The workflows use separate cache restore and save actions. Cache saves require cache misses and permitted refs. Bazel matrix concurrency increases from 8 to 12.
GitHub Actions data collection and analysis
tools/ci-health/go.mod, tools/ci-health/github.go, tools/ci-health/analysis.go
The Go module retrieves paginated workflow, job, cache, and pull-request data through gh. Analysis calculates job timing, weekly latency, critical paths, skipped matrix work, diagnostics, and cache state.
Health report rendering and command dispatch
tools/ci/ci-health, tools/ci-health/main.go, tools/ci-health/render.go, tools/ci-health/.gitignore
The Bash entrypoint delegates to the Go CLI. The CLI supports multiple report modes, dashboard generation, browser opening, and text output. The renderer creates escaped, self-contained HTML with charts, timing bars, cache data, and diagnostic causes.
Health analysis and API validation
tools/ci-health/analysis_test.go, tools/ci-health/github_test.go
Tests cover statistical calculations, job and cache analysis, diagnostics, pagination, filtering, malformed data, fetch failures, nullable timestamps, escaping, empty charts, and dashboard self-containment.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant CIHealth as tools/ci-health
  participant GitHub as GitHub API
  participant Dashboard as HTML dashboard
  Operator->>CIHealth: Select report mode and options
  CIHealth->>GitHub: Fetch runs, jobs, caches, and pull requests
  GitHub-->>CIHealth: Return API data
  CIHealth->>CIHealth: Analyze timing, diagnostics, and cache state
  CIHealth->>Dashboard: Render self-contained report
  Dashboard-->>Operator: Display CI health results
Loading

Possibly related PRs

  • NVIDIA/nvcf#435: Both PRs modify Bazel cache restoration and upload policies in .github/workflows/bazel.yml.

Suggested reviewers: apartha-nv, kristinapathak

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits and accurately describes the primary CI cache optimization and build-health dashboard changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ci-cache-quota

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci/ci-health`:
- Around line 174-191: Add focused tests in tools/ci/test-ci-health covering
mocked gh responses, pagination behavior, percentile edge cases, and main()
command dispatch for workflow, merge-time, cache, and --all options. Keep the
tests targeted to the existing cache_report, workflow_report, merge_time_report,
and main symbols without changing tool behavior.
- Line 81: Update the GitHub API retrieval used by the cache, workflow-run, and
pull-request metrics to follow pagination and aggregate every page needed for
the report window. In particular, replace the single-page call around gh with a
pagination-aware fetch, then apply args.prs only as a client-side limit after
all relevant pull requests are collected, preserving existing metric
calculations.
- Around line 118-123: Update percentile to use a documented percentile
calculation method that interpolates between adjacent sorted values, ensuring
percentile([1, 2], 0.5) returns 1.5 and p90 for ten ascending values reflects
the 90th percentile rather than automatically selecting the maximum. Preserve
the existing empty-input return of 0.0.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ee52de18-52b9-4966-9a97-e99e95c715b7

📥 Commits

Reviewing files that changed from the base of the PR and between 4d2a710 and b2ee20c.

📒 Files selected for processing (3)
  • .github/workflows/bazel.yml
  • .github/workflows/license-dependencies.yml
  • tools/ci/ci-health

Comment thread tools/ci/ci-health Outdated
Comment thread tools/ci/ci-health Outdated
Comment thread tools/ci/ci-health Outdated
balaji-g and others added 2 commits August 4, 2026 09:02
The duration report pulled `actions/runs?per_page=100` and filtered by
workflow name in Python, so it sampled the last 100 runs across all
workflows and kept whichever happened to be bazel. In practice that was 14
runs out of 1275, and it under-reported worse as the repo got busier. It
now reads the per-workflow endpoint and pages through it.

That truncation also produced a false conclusion: with only two days of
runs visible there appeared to be no history to trend against. There are
three full weeks.

Adds `--dashboard`, which writes a self-contained HTML report and opens it.
No CDN and no JavaScript dependency; the charts are inline SVG, so the file
works offline and adds nothing to the dependency surface.

The report separates queue wait from execution time, because the Actions UI
shows only their sum. On current data that distinction matters: several
matrix rows spend more wall clock waiting for a runner than building.

Also drops the oldest week from the trend when the history window was
truncated. Only the tail of that week is held, so its median came from an
arbitrary slice and plotted as a misleading near-zero point.

Adds focused tests for the pure analysis functions, one per defect the tool
has actually shipped with.

Co-authored-by: Balaji Ganesan <[email protected]>
A 25-row merge-queue run needs three waves at max-parallel 8 and two at 12,
and full-matrix runs are what set the p90 (20.1 min against a 6.8 min
median).

The cap exists to keep simultaneous actions/checkout downloads under
GitHub's rate limit, which previously failed runs at "Set up job" with HTTP
429. That constraint is unchanged, so this is a measured step rather than a
jump to 16: combined with the bazel-docker matrix the checkout burst goes
from 12 to 16. Walk this back first if 429s reappear.

Co-authored-by: Balaji Ganesan <[email protected]>
@balajinvda balajinvda changed the title fix(ci): stop merge-queue refs burning the cache quota, add a health tool fix(ci): cut cache-quota waste, add a self-serve build-health dashboard Aug 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/bazel.yml (1)

741-754: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Save the caches after the test step, not between build and test.

The previous combined actions/cache@v4 step saved in the post-job phase, so the entry contained everything the build and the test step fetched. actions/cache/save@v4 saves at the point where the step runs. bazel test //... can fetch further external repositories into ~/.cache/bazel/_bazel_*/cache (test-only dependencies and toolchains reached only by the test configuration). Those fetches now land after the save, so the next run restores an incomplete repository cache and re-downloads them.

Move this step below bazel test //... (after line 876). The conditions themselves are correct.

♻️ Proposed move
-      - name: Save Bazel repository + disk caches
-        # Never from a merge-queue ref: gh-readonly-queue/** is deleted when the
-        # queue drains, so the entry is unrestorable but still occupies quota.
-        if: >-
-          steps.precheck.outputs.skip == 'false'
-          && steps.bazel_cache.outputs.cache-hit != 'true'
-          && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/')
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install
-            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache
-          key: bazel-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.workdir), format('{0}/.bazelversion', matrix.subtree.workdir)) }}
-
       - name: bazel test //...

Then add the same step immediately after the bazel test //... step:

      - name: Save Bazel repository + disk caches
        # Runs after build and test so the entry holds every fetched external
        # repository. Never from a merge-queue ref: gh-readonly-queue/** is
        # deleted when the queue drains, so the entry is unrestorable but still
        # occupies quota.
        if: >-
          always()
          && steps.precheck.outputs.skip == 'false'
          && steps.bazel_cache.outputs.cache-hit != 'true'
          && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/')
        uses: actions/cache/save@v4
        with:
          path: |
            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install
            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache
          key: bazel-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.workdir), format('{0}/.bazelversion', matrix.subtree.workdir)) }}

Drop always() if you want a failed test to skip the save.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/bazel.yml around lines 741 - 754, Move the “Save Bazel
repository + disk caches” step to immediately after the `bazel test //...` step
so test-only downloads are included. Preserve its existing conditions, or add
`always()` if failed tests should still save the cache; do not leave the
original pre-test save step in place.
🧹 Nitpick comments (2)
tools/ci/test-ci-health (1)

59-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for the API-shaped helpers.

The pure analysis functions are well covered. gh_paged and merge_time_report are not, and that is where the key="items" defect on tools/ci/ci-health line 568 sits. Add a case that stubs ci.gh_json with a bare list and a 100-item page, then asserts that gh_paged and merge_time_report aggregate correctly.

As per coding guidelines, "For changed tool behavior, add or update focused tests."

Also applies to: 219-235

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci/test-ci-health` around lines 59 - 70, Add focused tests for the
API-shaped helpers `gh_paged` and `merge_time_report` in the existing test
suite. Stub `ci.gh_json` to return a bare list containing a full 100-item page,
then assert both helpers aggregate the results correctly and preserve the
expected behavior without assuming an `items` wrapper.

Source: Coding guidelines

tools/ci/ci-health (1)

65-70: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout to the gh call.

subprocess.run has no timeout. fetch_jobs issues these calls from eight threads, so one stalled request blocks the tool with no output. Add a timeout and convert the expiry into the existing RuntimeError path, which main already reports through sys.exit.

♻️ Proposed change
 def gh_json(path, repo):
     cmd = ["gh", "api", f"repos/{repo}/{path}"]
-    out = subprocess.run(cmd, capture_output=True, text=True)
+    try:
+        out = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+    except subprocess.TimeoutExpired:
+        raise RuntimeError(f"gh api {path} timed out")
     if out.returncode != 0:
         raise RuntimeError(f"gh api {path} failed: {out.stderr.strip()}")
     return json.loads(out.stdout)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci/ci-health` around lines 65 - 70, Update gh_json to pass a finite
timeout to subprocess.run and catch subprocess.TimeoutExpired, converting it
into the existing RuntimeError path with a clear failure message. Preserve the
current nonzero-return handling and JSON parsing behavior for completed
requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci/ci-health`:
- Around line 567-575: Update merge_time_report so every --prs value uses
gh_paged with key=None for the pulls endpoint, including limits at or below 100,
and rely on its client-side limit. Remove the conditional gh_json path while
preserving the existing pull request filtering and latency calculation.

In `@tools/ci/test-ci-health`:
- Around line 16-23: Add tools/ci/test-ci-health to the appropriate CI job in
.github/workflows/bazel.yml, following the existing invocation pattern used for
tools/ci/test-bazel-cache-upload-mode. Preserve the executable entrypoint and
ensure the test runs as part of the workflow.
- Around line 25-36: Add an explicit import for importlib.machinery alongside
the existing imports in the test module so the SourceFileLoader reference used
during ci module loading is available reliably; leave the existing _spec
initialization and execution flow unchanged.

---

Outside diff comments:
In @.github/workflows/bazel.yml:
- Around line 741-754: Move the “Save Bazel repository + disk caches” step to
immediately after the `bazel test //...` step so test-only downloads are
included. Preserve its existing conditions, or add `always()` if failed tests
should still save the cache; do not leave the original pre-test save step in
place.

---

Nitpick comments:
In `@tools/ci/ci-health`:
- Around line 65-70: Update gh_json to pass a finite timeout to subprocess.run
and catch subprocess.TimeoutExpired, converting it into the existing
RuntimeError path with a clear failure message. Preserve the current
nonzero-return handling and JSON parsing behavior for completed requests.

In `@tools/ci/test-ci-health`:
- Around line 59-70: Add focused tests for the API-shaped helpers `gh_paged` and
`merge_time_report` in the existing test suite. Stub `ci.gh_json` to return a
bare list containing a full 100-item page, then assert both helpers aggregate
the results correctly and preserve the expected behavior without assuming an
`items` wrapper.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6f3bcbbc-02cf-45bc-840a-967d7306d398

📥 Commits

Reviewing files that changed from the base of the PR and between b2ee20c and dbbc674.

📒 Files selected for processing (3)
  • .github/workflows/bazel.yml
  • tools/ci/ci-health
  • tools/ci/test-ci-health

Comment thread tools/ci/ci-health Outdated
Comment thread tools/ci/test-ci-health Outdated
Comment on lines +16 to +23
"""Tests for tools/ci/ci-health.

Every case here corresponds to a defect the tool actually shipped with at
some point. The analysis functions are pure, so none of this touches the
network.

Run: tools/ci/test-ci-health
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check the executable bit and find the CI job that runs the tools tests.
set -euo pipefail

echo "== mode bits =="
fd -t f 'test-ci-health|test-bazel-cache-upload-mode' tools --exec-batch ls -l

echo "== workflow references to tools/ci tests =="
rg -n 'test-ci-health|test-bazel-cache-upload-mode|tools/ci' .github/workflows

Repository: NVIDIA/nvcf

Length of output: 3523


Add tools/ci/test-ci-health to CI workflow.

The file has the executable bit set. However, it is not invoked in any CI workflow. Follow the pattern of tools/ci/test-bazel-cache-upload-mode in .github/workflows/bazel.yml (line 89) to add an invocation. Per coding guidelines, update CI jobs when adding or changing a tool entrypoint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci/test-ci-health` around lines 16 - 23, Add tools/ci/test-ci-health to
the appropriate CI job in .github/workflows/bazel.yml, following the existing
invocation pattern used for tools/ci/test-bazel-cache-upload-mode. Preserve the
executable entrypoint and ensure the test runs as part of the workflow.

Source: Coding guidelines

Comment thread tools/ci/test-ci-health Outdated
Comment on lines +25 to +36
import datetime as dt
import importlib.util
import os
import unittest

_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_loader(
"ci_health",
importlib.machinery.SourceFileLoader("ci_health", os.path.join(_HERE, "ci-health")),
)
ci = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ci)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does importlib.util import importlib.machinery as a side effect in Python 3.13

💡 Result:

No, importlib.util does not import importlib.machinery as a mandatory side effect in Python 3.13 [1][2]. While importing modules in Python can sometimes trigger side effects if other packages or standard library modules (such as pkgutil or inspect) are involved [3][4], the source code for importlib.util in Python 3.13 does not contain an import statement for importlib.machinery [1]. Users should not rely on any automatic availability of importlib.machinery via importlib.util [3][5]. If your code requires functionality from importlib.machinery, you should explicitly import it [6].

Citations:


🌐 Web query:

Python 3.13 importlib.util SourceFileLoader AttributeError

💡 Result:

The AttributeError related to importlib.util.SourceFileLoader in Python 3.13 typically arises in two distinct contexts: the deprecation and removal of older methods, or issues with outdated packaging dependencies. 1. Deprecation of load_module: The most common cause of AttributeError when working with SourceFileLoader is attempting to call.load_module. This method has been deprecated since Python 3.6 and is slated for removal. In later Python versions, objects created via SourceFileLoader do not possess a.load_module attribute [1][2]. To fix this, you should transition to the modern importlib API using exec_module [1]. The recommended pattern for loading a module from a file path is as follows [3][4][5]: import importlib.util import importlib.machinery import sys def load_source(module_name, file_path): loader = importlib.machinery.SourceFileLoader(module_name, file_path) spec = importlib.util.spec_from_file_location(module_name, file_path, loader=loader) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) return module 2. Packaging and Environment Issues (importlib._bootstrap): If you encounter an error stating: AttributeError: module 'importlib._bootstrap' has no attribute 'SourceFileLoader', this is almost always caused by an outdated or broken setuptools or distribute installation [6][7]. This often happens when installing old, unmaintained packages that force a downgrade of setuptools [8]. To resolve this, upgrade your packaging tools using: python -m pip install --upgrade setuptools pip If the issue persists in a virtual environment, ensure you are not using a broken environment by recreating it and avoiding the installation of legacy, unmaintained packages [8].

Citations:


🏁 Script executed:

# Check if the file snippet shows actual code that would fail without the import
cat -n <<'EOF'
import datetime as dt
import importlib.util
import os
import unittest

_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_loader(
    "ci_health",
    importlib.machinery.SourceFileLoader("ci_health", os.path.join(_HERE, "ci-health")),
)
ci = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ci)
EOF

Repository: NVIDIA/nvcf

Length of output: 577


Add explicit import for importlib.machinery.

The code on line 9 uses importlib.machinery.SourceFileLoader, but only importlib.util is imported. importlib.machinery must be imported explicitly; it is not guaranteed to be available as a side effect of importing importlib.util. In Python 3.13 and later, the code will raise AttributeError on access without the explicit import.

🛡️ Proposed fix
 import datetime as dt
+import importlib.machinery
 import importlib.util
 import os
 import unittest
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import datetime as dt
import importlib.util
import os
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_loader(
"ci_health",
importlib.machinery.SourceFileLoader("ci_health", os.path.join(_HERE, "ci-health")),
)
ci = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ci)
import datetime as dt
import importlib.machinery
import importlib.util
import os
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_loader(
"ci_health",
importlib.machinery.SourceFileLoader("ci_health", os.path.join(_HERE, "ci-health")),
)
ci = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ci)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci/test-ci-health` around lines 25 - 36, Add an explicit import for
importlib.machinery alongside the existing imports in the test module so the
SourceFileLoader reference used during ci module loading is available reliably;
leave the existing _spec initialization and execution flow unchanged.

Comment thread tools/ci/ci-health Outdated
@@ -0,0 +1,653 @@
#!/usr/bin/env python3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rewrite this in bash or go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, rewritten in Go: tools/ci-health/ as its own module, with tools/ci/ci-health kept as the stable entrypoint per the tools/AGENTS.md layout guidance.

You were right that it shouldn't have been Python. tools/AGENTS.md calls for Go on non-trivial tooling and warns that some CI environments here don't guarantee a Python interpreter, and this is structured API parsing plus concurrent fetches, which is squarely that.

Behaviour is unchanged, verified against live data. No third-party dependencies; the dashboard is inline SVG rather than a charting library, so it stays self-contained and offline.

The port also picked up two real bugs from the Python version, both now covered by tests:

  • The pulls endpoint returns a bare array, so paging it with a wrapper key crashed once --prs went past one page.
  • The cache listing read a single page, silently capping the report at 100 entries.

go test ./tools/ci-health/... is 37 tests.

Review feedback: tools/AGENTS.md prefers Go for non-trivial repo tooling and
says to avoid new Python, partly because some CI environments here do not
guarantee a Python interpreter. This tool is squarely in that category:
structured API parsing, concurrent fetches, and logic that benefits from unit
tests. It should not have been Python to begin with.

Behaviour is unchanged. The Go build produces the same ranked causes, the same
weekly trend, and a visually identical dashboard against live data.

Layout follows the existing convention: the tool lives in its own module at
tools/ci-health/, with tools/ci/ci-health kept as the stable entrypoint.

Two fixes carried over from review of the Python version:

- The pulls endpoint returns a bare array, so paging it with a wrapper key
  crashed once --prs exceeded one page. The Go paging helper takes an empty key
  for bare-array endpoints, and a test covers both response shapes.
- The cache listing read a single page, silently capping the report at 100
  entries. It now pages.

37 tests, no third-party dependencies.

Co-authored-by: Balaji Ganesan <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci-health/github.go`:
- Around line 170-195: Update fetchJobs to paginate each run’s jobs endpoint
until all pages are retrieved, preserving the worker limit and aggregating every
page’s Jobs into that run’s result. Use the API’s pagination metadata or
next-page indicator and stop when no further page exists. Add a focused test
covering a run whose jobs span multiple pages and verifying all jobs are
returned.

In `@tools/ci-health/main.go`:
- Around line 156-162: Maintain a separate run slice matching the --runs
job-timing window, and pass that slice to diagnose instead of runsList. Update
printWhy and the dashboard diagnostic summary to use the same job-sample slice,
while retaining runsList exclusively for trend-chart data.
- Around line 143-154: Update the command flow around the usage fetch,
fetchCaches, and summariseCaches calls to compute a needCache condition and
perform those requests only for the default, --why, --all, and dashboard modes.
Ensure --durations and --merge-times bypass all cache access while preserving
the existing cache summary behavior for cache-dependent modes.
- Around line 89-93: Validate the parsed count options before dispatch so
--runs, --history, --weeks, and --prs reject negative values instead of reaching
slicing logic. Update the flag-handling flow around the IntVar bindings and
return a clear validation error or usage failure for any negative value, while
preserving valid zero and positive inputs.

In `@tools/ci-health/render.go`:
- Around line 183-196: The quotaBar function currently caps the calculated used
percentage before displaying it. Preserve the raw percentage for threshold
selection and the quotatxt output, while introducing a separately capped value
only for the CSS width attribute. Add a regression test covering utilization
above quota, such as 12 GB, verifying the text reports 120% while the bar width
remains 100%.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 79e3a8e1-7fba-4df1-a6ff-3ec263ba77c8

📥 Commits

Reviewing files that changed from the base of the PR and between dbbc674 and 7f05f19.

📒 Files selected for processing (9)
  • tools/ci-health/.gitignore
  • tools/ci-health/analysis.go
  • tools/ci-health/analysis_test.go
  • tools/ci-health/github.go
  • tools/ci-health/github_test.go
  • tools/ci-health/go.mod
  • tools/ci-health/main.go
  • tools/ci-health/render.go
  • tools/ci/ci-health

Comment thread tools/ci-health/github.go
Comment on lines +170 to +195
func fetchJobs(repo string, runs []Run, workers int) []Job {
type result struct{ jobs []Job }
sem := make(chan struct{}, workers)
out := make([]result, len(runs))
var wg sync.WaitGroup
for i, run := range runs {
wg.Add(1)
go func(i int, id int64) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
var body struct {
Jobs []Job `json:"jobs"`
}
if err := getJSON(fmt.Sprintf("actions/runs/%d/jobs?per_page=100", id), repo, &body); err != nil {
return
}
out[i] = result{jobs: body.Jobs}
}(i, run.ID)
}
wg.Wait()
var jobs []Job
for _, r := range out {
jobs = append(jobs, r.jobs...)
}
return jobs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate file map ---'
ast-grep outline tools/ci-health/github.go --lang go
printf '%s\n' '--- relevant source ---'
sed -n '1,260p' tools/ci-health/github.go
printf '%s\n' '--- paged usages and definitions ---'
rg -n -C 4 '\bpaged\b|fetchJobs|Jobs' tools/ci-health
printf '%s\n' '--- tests ---'
find tools/ci-health -maxdepth 2 -type f -name '*_test.go' -print
rg -n -C 5 'fetchJobs|workflow-jobs|actions/runs/.*/jobs|per_page' tools/ci-health

Repository: NVIDIA/nvcf

Length of output: 2002


🌐 Web query:

GitHub REST API list jobs for a workflow run pagination per_page 100 endpoint documentation

💡 Result:

The GitHub REST API endpoint to list jobs for a workflow run is: GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs [1][2] This endpoint supports pagination using the per_page and page query parameters [1][2]. Key pagination details: - per_page: The number of results to return per page. The maximum value allowed is 100 [1][2]. If you do not specify a value, the default is 30 [1][2]. - page: The page number of the results to fetch. The default is 1 [1][2]. To retrieve a specific number of results per page (e.g., 100), you can append the query parameter to your request: GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs?per_page=100 [1][2] For more extensive data retrieval, you can iterate through pages using the page parameter or by following the link header provided in the API response [3].

Citations:


🌐 Web query:

site:github.com/NVIDIA/nvcf tools/ci-health/github.go fetchJobs paged

💡 Result:

Based on the available repository information for NVIDIA/nvcf, there is no public file located at tools/ci-health/github.go [1][2]. While the repository contains a tools/ directory with various build, documentation, and validation utilities, the specific path and function name provided do not appear in the publicly indexed source code [1][2]. The repository does use CI-related tools (often under tools/ci/) and GitHub API interactions for workflows [3][4], but public visibility into a file named tools/ci-health/github.go is not supported by the current search results [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
base='https://raw.githubusercontent.com/NVIDIA/nvcf'
for ref in main master; do
  url="$base/$ref/tools/ci-health/github.go"
  printf '%s\n' "--- $url ---"
  if curl -fsSL --max-time 15 "$url" | sed -n '1,260p'; then
    break
  fi
done

Repository: NVIDIA/nvcf

Length of output: 2002


Fetch every job page for each run.

The request uses per_page=100 but does not request subsequent pages. Runs with more than 100 jobs produce incomplete metrics. Iterate through all pages and add a focused multi-page job-response test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/github.go` around lines 170 - 195, Update fetchJobs to
paginate each run’s jobs endpoint until all pages are retrieved, preserving the
worker limit and aggregating every page’s Jobs into that run’s result. Use the
API’s pagination metadata or next-page indicator and stop when no further page
exists. Add a focused test covering a run whose jobs span multiple pages and
verifying all jobs are returned.

Comment thread tools/ci-health/main.go
Comment on lines +89 to +93
flag.IntVar(&o.runs, "runs", 60, "runs to pull per-job detail for")
flag.IntVar(&o.history, "history", 1000, "runs to trend over")
flag.IntVar(&o.weeks, "weeks", 12, "weeks of trend to print")
flag.IntVar(&o.prs, "prs", 100, "merged PRs to sample")
flag.BoolVar(&o.noOpen, "no-open", false, "do not launch a browser")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject negative count flags before slicing.

flag.IntVar accepts negative values. --runs=-1 panics at Line 138. --weeks=-1 panics at Line 281. Reject negative values for --runs, --history, --weeks, and --prs before dispatch.

Proposed fix
 func run() error {
 	o := parseFlags()
+	if o.runs < 0 || o.history < 0 || o.weeks < 0 || o.prs < 0 {
+		return fmt.Errorf("count flags must be non-negative")
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/main.go` around lines 89 - 93, Validate the parsed count
options before dispatch so --runs, --history, --weeks, and --prs reject negative
values instead of reaching slicing logic. Update the flag-handling flow around
the IntVar bindings and return a clear validation error or usage failure for any
negative value, while preserving valid zero and positive inputs.

Comment thread tools/ci-health/main.go
Comment on lines +143 to +154
usage := struct {
Size int64 `json:"active_caches_size_in_bytes"`
Count int `json:"active_caches_count"`
}{}
if err := getJSON("actions/cache/usage", o.repo, &usage); err != nil {
return err
}
caches, err := fetchCaches(o.repo)
if err != nil {
return err
}
cache := summariseCaches(caches, usage.Size, usage.Count)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fetch cache data only for cache-dependent modes.

These calls run before command dispatch. --durations and --merge-times fail when cache access fails, even though neither report uses cache data. Guard the cache requests with a needCache condition for the default, --why, --all, and dashboard modes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/main.go` around lines 143 - 154, Update the command flow
around the usage fetch, fetchCaches, and summariseCaches calls to compute a
needCache condition and perform those requests only for the default, --why,
--all, and dashboard modes. Ensure --durations and --merge-times bypass all
cache access while preserving the existing cache summary behavior for
cache-dependent modes.

Comment thread tools/ci-health/main.go
Comment on lines +156 to +162
stats := analyseJobs(jobs)
poles, poleRuns := longPoles(jobs)
var causes []Cause
var wall float64
if needJobs {
causes, wall = diagnose(runsList, stats, poles, poleRuns, cache)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Use one run window for each diagnosis.

runsList contains up to --history runs. Lines 136-140 fetch job timing for only --runs runs. diagnose divides recent job medians by a historical wall-clock median. This can misstate contribution percentages and reorder causes.

Keep a job-sample run slice. Use it for diagnose, printWhy, and the dashboard diagnostic summary. Keep runsList for trend charts only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/main.go` around lines 156 - 162, Maintain a separate run
slice matching the --runs job-timing window, and pass that slice to diagnose
instead of runsList. Update printWhy and the dashboard diagnostic summary to use
the same job-sample slice, while retaining runsList exclusively for trend-chart
data.

Comment thread tools/ci-health/render.go
Comment on lines +183 to +196
func quotaBar(c CacheState) string {
used := c.TotalGB / quotaGB * 100
if used > 100 {
used = 100
}
cls := "ok"
if used >= 90 {
cls = "over"
} else if used >= 75 {
cls = "warn"
}
return fmt.Sprintf("<div class='quota'><div class='quotafill %s' style='width:%.1f%%'></div>"+
"<span class='quotatxt'>%.2f GB / %.0f GB (%.0f%%) across %d entries</span></div>",
cls, used, c.TotalGB, quotaGB, used, c.Count)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show the actual cache utilization.

Line 185 caps used before formatting the text. A 12 GB cache is shown as 100%, not 120%. Keep the raw percentage for text and threshold selection. Cap only the CSS bar width. Add a regression test for usage above quota.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/render.go` around lines 183 - 196, The quotaBar function
currently caps the calculated used percentage before displaying it. Preserve the
raw percentage for threshold selection and the quotatxt output, while
introducing a separately capped value only for the CSS width attribute. Add a
regression test covering utilization above quota, such as 12 GB, verifying the
text reports 120% while the bar width remains 100%.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants