-
Notifications
You must be signed in to change notification settings - Fork 194
Post sticky PR comment summarizing CI test job failures #1412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Post or update a sticky PR comment summarizing CI test job failures. | ||
|
|
||
| Reads GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_REF, and GH_TOKEN from the | ||
| environment. Finds every test job in the current workflow run, then posts (or | ||
| updates) a single comment on the pull request listing any failed jobs with | ||
| direct log links and a collapsible list of individual failed test names. | ||
|
|
||
| Usage (called from a GitHub Actions step): | ||
| python3 ci/utils/pr_test_summary.py | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| import sys | ||
| import urllib.error | ||
| import urllib.request | ||
|
|
||
| # Job name prefixes that are considered test jobs. | ||
| _TEST_PREFIXES = ( | ||
| "conda-cpp-tests", | ||
| "conda-python-tests", | ||
| "wheel-tests-cuopt", | ||
| "wheel-tests-cuopt-server", | ||
| "test-self-hosted-server", | ||
| ) | ||
|
|
||
| _MARKER = "<!-- pr-test-summary -->" | ||
| _HTTP_TIMEOUT_SEC = 30 | ||
| # Maximum failed test names shown per job dropdown. | ||
| _MAX_TESTS = 50 | ||
|
|
||
| # Ordered by specificity; first match wins. | ||
| _CRASH_PATTERNS = [ | ||
| ("Segmentation fault", "SIGSEGV (segfault)"), | ||
| ("SIGSEGV", "SIGSEGV (segfault)"), | ||
| ("signal 11", "SIGSEGV (signal 11)"), | ||
| ("Aborted (core dumped)", "SIGABRT"), | ||
| ("SIGABRT", "SIGABRT"), | ||
| ("signal 6", "SIGABRT (signal 6)"), | ||
| ("SIGKILL", "SIGKILL"), | ||
| ("signal 9", "SIGKILL (signal 9)"), | ||
| ("Out of memory", "OOM"), | ||
| ("oom-kill", "OOM"), | ||
| ("core dumped", "core dumped"), | ||
| ] | ||
|
|
||
|
|
||
| def _headers(token): | ||
| return { | ||
| "Authorization": f"Bearer {token}", | ||
| "Accept": "application/vnd.github+json", | ||
| "X-GitHub-Api-Version": "2022-11-28", | ||
| } | ||
|
|
||
|
|
||
| def _paginate(path, token): | ||
| """Yield all items from a paginated GitHub REST API GET endpoint.""" | ||
| url = f"https://api.github.com{path}?per_page=100" | ||
| while url: | ||
| req = urllib.request.Request(url, headers=_headers(token)) | ||
| with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_SEC) as resp: | ||
| data = json.loads(resp.read()) | ||
| # Jobs endpoint wraps items in {"jobs": [...]}; comments is a bare list. | ||
| yield from (data["jobs"] if isinstance(data, dict) else data) | ||
| link = resp.headers.get("Link", "") | ||
| url = next( | ||
| ( | ||
| p.split(";")[0].strip().strip("<>") | ||
| for p in link.split(",") | ||
| if 'rel="next"' in p | ||
| ), | ||
| None, | ||
| ) | ||
|
|
||
|
|
||
| def _api(path, token, method, payload): | ||
| req = urllib.request.Request( | ||
| f"https://api.github.com{path}", | ||
| data=json.dumps(payload).encode(), | ||
| method=method, | ||
| headers={**_headers(token), "Content-Type": "application/json"}, | ||
| ) | ||
| with urllib.request.urlopen(req) as resp: | ||
| return json.loads(resp.read()) | ||
|
|
||
|
|
||
| def _is_test_job(name): | ||
| return any(name == p or name.startswith(p + " (") for p in _TEST_PREFIXES) | ||
|
|
||
|
|
||
| def _analyze_job_log(job_id, repo, token): | ||
| """Return (failed_test_ids, crash_description_or_None) from a job's log.""" | ||
| req = urllib.request.Request( | ||
| f"https://api.github.com/repos/{repo}/actions/jobs/{job_id}/logs", | ||
| headers=_headers(token), | ||
| ) | ||
| try: | ||
| with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_SEC) as resp: | ||
| # Stream the log, retaining only the last 512 KB so the pytest | ||
| # summary section at the end of the output is always captured. | ||
| chunks = [] | ||
| total = 0 | ||
| while chunk := resp.read(65536): | ||
| chunks.append(chunk) | ||
| total += len(chunk) | ||
| if total > 512 * 1024: | ||
| chunks = chunks[-8:] | ||
| total = sum(len(c) for c in chunks) | ||
| except (urllib.error.HTTPError, urllib.error.URLError): | ||
| return [], None | ||
|
|
||
| text = b"".join(chunks).decode("utf-8", errors="replace") | ||
|
|
||
| crash = next( | ||
| (desc for pattern, desc in _CRASH_PATTERNS if pattern in text), None | ||
| ) | ||
|
|
||
| failed = [] | ||
| in_summary = False | ||
| for raw in text.splitlines(): | ||
| # Strip GHA timestamp prefix: "2024-01-15T10:30:45.1234567Z content" | ||
| parts = raw.split("Z ", 1) | ||
| line = parts[1] if len(parts) > 1 and len(parts[0]) < 35 else raw | ||
|
|
||
| if "short test summary info" in line: | ||
| in_summary = True | ||
| elif in_summary: | ||
| if line.startswith(("FAILED ", "ERROR ")): | ||
| test_id = line.split(" ", 1)[1].split(" - ")[0].strip() | ||
| if test_id: | ||
| failed.append(test_id) | ||
| elif line.startswith("=") and failed: | ||
| break | ||
|
|
||
| return failed[:_MAX_TESTS], crash | ||
|
|
||
|
|
||
| def _build_body(failed, passed, skipped, job_analysis): | ||
| lines = [_MARKER, "## CI Test Summary", ""] | ||
| if not failed: | ||
| lines.append(f"✅ All {len(passed)} test job(s) passed.") | ||
| else: | ||
| lines.append( | ||
| f"**{len(failed)} failed** · {len(passed)} passed · {len(skipped)} skipped" | ||
| ) | ||
| lines += ["", "| Job | Logs |", "|-----|------|"] | ||
| for job in failed: | ||
| lines.append( | ||
| f"| ❌ `{job['name']}` | [View logs]({job['html_url']}) |" | ||
| ) | ||
|
|
||
| for job in failed: | ||
| tests, crash = job_analysis.get(job["id"], ([], None)) | ||
| if not tests and not crash: | ||
| continue | ||
| if crash and not tests: | ||
| summary = f"💥 crashed ({crash})" | ||
| detail = "Process was terminated before pytest completed." | ||
| else: | ||
| n = len(tests) | ||
| noun = "test" if n == 1 else "tests" | ||
| summary = f"{n} failed {noun}" + ( | ||
| f" · 💥 {crash}" if crash else "" | ||
| ) | ||
| detail = "\n".join(f"- `{t}`" for t in tests) | ||
| lines += [ | ||
| "", | ||
| "<details>", | ||
| f"<summary><code>{job['name']}</code> — {summary}</summary>", | ||
| "", | ||
| detail, | ||
| "", | ||
| "</details>", | ||
| ] | ||
|
|
||
| return "\n".join(lines) | ||
|
|
||
|
|
||
| def main(): | ||
| token = os.environ["GH_TOKEN"] | ||
| repo = os.environ["GITHUB_REPOSITORY"] | ||
| run_id = os.environ["GITHUB_RUN_ID"] | ||
| ref = os.environ["GITHUB_REF"] # refs/heads/pull-request/NNN | ||
|
|
||
| branch = ref.removeprefix("refs/heads/") | ||
| if not branch.startswith("pull-request/"): | ||
| print(f"Not a PR branch ({branch}), skipping.", file=sys.stderr) | ||
| return | ||
| pr_number = int(branch.removeprefix("pull-request/")) | ||
|
|
||
| jobs = list(_paginate(f"/repos/{repo}/actions/runs/{run_id}/jobs", token)) | ||
| test_jobs = [j for j in jobs if _is_test_job(j["name"])] | ||
| if not test_jobs: | ||
| print("No test jobs found in this run, skipping.", file=sys.stderr) | ||
| return | ||
|
|
||
| passed = [j for j in test_jobs if j["conclusion"] == "success"] | ||
| skipped = [j for j in test_jobs if j["conclusion"] == "skipped"] | ||
| failed = [j for j in test_jobs if j not in passed and j not in skipped] | ||
|
|
||
| job_analysis = { | ||
| job["id"]: _analyze_job_log(job["id"], repo, token) for job in failed | ||
| } | ||
|
|
||
| body = _build_body(failed, passed, skipped, job_analysis) | ||
|
|
||
| comments = list( | ||
| _paginate(f"/repos/{repo}/issues/{pr_number}/comments", token) | ||
| ) | ||
| existing = next( | ||
| (c for c in comments if c.get("body", "").startswith(_MARKER)), None | ||
| ) | ||
|
|
||
| if existing: | ||
| _api( | ||
| f"/repos/{repo}/issues/comments/{existing['id']}", | ||
| token, | ||
| "PATCH", | ||
| {"body": body}, | ||
| ) | ||
| print(f"Updated comment {existing['id']} on PR #{pr_number}.") | ||
| else: | ||
| result = _api( | ||
| f"/repos/{repo}/issues/{pr_number}/comments", | ||
| token, | ||
| "POST", | ||
| {"body": body}, | ||
| ) | ||
| print(f"Posted comment {result['id']} on PR #{pr_number}.") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,17 @@ | ||||||||||||||||||||||||||||||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||||||||||||||||||||||||||||||
| # SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # Intentional failures to exercise the PR test summary comment feature. | ||||||||||||||||||||||||||||||
| # Remove after verifying the summary comment shows correct output. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def test_ci_summary_demo_failure(): | ||||||||||||||||||||||||||||||
| assert False, ( | ||||||||||||||||||||||||||||||
| "Intentional failure: remove after verifying PR summary comment." | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def test_ci_summary_demo_failure_2(): | ||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||
| "Intentional error: remove after verifying PR summary comment." | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
Comment on lines
+4
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drop these demo failures from the checked-in pytest suite. They keep 🧹 Proposed fix-# Intentional failures to exercise the PR test summary comment feature.
-# Remove after verifying the summary comment shows correct output.
-
-
-def test_ci_summary_demo_failure():
- assert False, (
- "Intentional failure: remove after verifying PR summary comment."
- )
-
-
-def test_ci_summary_demo_failure_2():
- raise RuntimeError(
- "Intentional error: remove after verifying PR summary comment."
- )As per coding guidelines, 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This C++ demo does not exercise per-test name extraction.
ci/utils/pr_test_summary.pyonly pulls test IDs out of pytest’sshort test summary info, so this GoogleTest failure will show the job as failed but will not populate the per-job failed-test details. If C++ per-test names are part of the validation goal, the parser needs explicit GTest support first.🤖 Prompt for AI Agents
Remove this unconditional red test before merge.
This keeps the C++ test job failing on every run and does not validate solver behavior or a real regression. If you still need it for one-off validation, keep it on a throwaway branch or behind an explicit opt-in path that never runs in normal PR CI.
🧹 Proposed fix
As per coding guidelines,
cpp/tests/**should validate numerical correctness/regressions rather than ship intentionally failing tests, and the PR objective already describes these as temporary demo failures to remove before merging.📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines