Skip to content

Commit 418777d

Browse files
Add PR test failure summary comment
Posts a sticky comment on each PR listing which matrix test jobs failed, with direct links to their logs. Updates on re-run; clears to a pass message if all jobs succeed. Uses only actions/github-script (already in repo) and the built-in GITHUB_TOKEN with pull-requests: write — no S3, no AWS credentials, no new third-party actions.
1 parent 2384454 commit 418777d

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

.github/workflows/pr.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,24 @@ jobs:
620620
with:
621621
build_type: pull-request
622622
script: ci/test_self_hosted_service.sh
623+
pr-test-summary:
624+
needs:
625+
- conda-cpp-tests
626+
- conda-python-tests
627+
- wheel-tests-cuopt
628+
- wheel-tests-cuopt-server
629+
- test-self-hosted-server
630+
if: always()
631+
runs-on: ubuntu-latest
632+
permissions:
633+
actions: read
634+
pull-requests: write
635+
steps:
636+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
637+
with:
638+
persist-credentials: false
639+
sparse-checkout: ci/utils/pr_test_summary.py
640+
sparse-checkout-cone-mode: false
641+
- run: python3 ci/utils/pr_test_summary.py
642+
env:
643+
GH_TOKEN: ${{ github.token }}

ci/utils/pr_test_summary.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Post or update a sticky PR comment summarizing CI test job failures.
5+
6+
Reads GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_REF, and GH_TOKEN from the
7+
environment. Finds every test job in the current workflow run, then posts (or
8+
updates) a single comment on the pull request listing any failed jobs with
9+
direct log links.
10+
11+
Usage (called from a GitHub Actions step):
12+
python3 ci/utils/pr_test_summary.py
13+
"""
14+
15+
import json
16+
import os
17+
import sys
18+
import urllib.error
19+
import urllib.request
20+
21+
# Job name prefixes that are considered test jobs.
22+
_TEST_PREFIXES = (
23+
"conda-cpp-tests",
24+
"conda-python-tests",
25+
"wheel-tests-cuopt",
26+
"wheel-tests-cuopt-server",
27+
"test-self-hosted-server",
28+
)
29+
30+
_MARKER = "<!-- pr-test-summary -->"
31+
32+
33+
def _headers(token):
34+
return {
35+
"Authorization": f"Bearer {token}",
36+
"Accept": "application/vnd.github+json",
37+
"X-GitHub-Api-Version": "2022-11-28",
38+
}
39+
40+
41+
def _paginate(path, token):
42+
"""Yield all items from a paginated GitHub REST API GET endpoint."""
43+
url = f"https://api.github.com{path}?per_page=100"
44+
while url:
45+
req = urllib.request.Request(url, headers=_headers(token))
46+
with urllib.request.urlopen(req) as resp:
47+
data = json.loads(resp.read())
48+
# Jobs endpoint wraps items in {"jobs": [...]}; comments is a bare list.
49+
yield from (data["jobs"] if isinstance(data, dict) else data)
50+
link = resp.headers.get("Link", "")
51+
url = next(
52+
(
53+
p.split(";")[0].strip().strip("<>")
54+
for p in link.split(",")
55+
if 'rel="next"' in p
56+
),
57+
None,
58+
)
59+
60+
61+
def _api(path, token, method, payload):
62+
req = urllib.request.Request(
63+
f"https://api.github.com{path}",
64+
data=json.dumps(payload).encode(),
65+
method=method,
66+
headers={**_headers(token), "Content-Type": "application/json"},
67+
)
68+
with urllib.request.urlopen(req) as resp:
69+
return json.loads(resp.read())
70+
71+
72+
def _is_test_job(name):
73+
return any(name == p or name.startswith(p + " (") for p in _TEST_PREFIXES)
74+
75+
76+
def _build_body(failed, passed, skipped):
77+
lines = [_MARKER, "## CI Test Summary", ""]
78+
if not failed:
79+
lines.append(f"✅ All {len(passed)} test job(s) passed.")
80+
else:
81+
lines.append(
82+
f"**{len(failed)} failed** · {len(passed)} passed · {len(skipped)} skipped"
83+
)
84+
lines += ["", "| Job | Logs |", "|-----|------|"]
85+
for job in failed:
86+
lines.append(
87+
f"| ❌ `{job['name']}` | [View logs]({job['html_url']}) |"
88+
)
89+
lines += [
90+
"",
91+
"_Click a job link above to see which tests failed in that matrix combination._",
92+
]
93+
return "\n".join(lines)
94+
95+
96+
def main():
97+
token = os.environ["GH_TOKEN"]
98+
repo = os.environ["GITHUB_REPOSITORY"]
99+
run_id = os.environ["GITHUB_RUN_ID"]
100+
ref = os.environ["GITHUB_REF"] # refs/heads/pull-request/NNN
101+
102+
branch = ref.removeprefix("refs/heads/")
103+
if not branch.startswith("pull-request/"):
104+
print(f"Not a PR branch ({branch}), skipping.", file=sys.stderr)
105+
return
106+
pr_number = int(branch.removeprefix("pull-request/"))
107+
108+
jobs = list(_paginate(f"/repos/{repo}/actions/runs/{run_id}/jobs", token))
109+
test_jobs = [j for j in jobs if _is_test_job(j["name"])]
110+
if not test_jobs:
111+
print("No test jobs found in this run, skipping.", file=sys.stderr)
112+
return
113+
114+
failed = [j for j in test_jobs if j["conclusion"] == "failure"]
115+
passed = [j for j in test_jobs if j["conclusion"] == "success"]
116+
skipped = [j for j in test_jobs if j["conclusion"] == "skipped"]
117+
118+
body = _build_body(failed, passed, skipped)
119+
120+
comments = list(
121+
_paginate(f"/repos/{repo}/issues/{pr_number}/comments", token)
122+
)
123+
existing = next(
124+
(c for c in comments if c.get("body", "").startswith(_MARKER)), None
125+
)
126+
127+
if existing:
128+
_api(
129+
f"/repos/{repo}/issues/comments/{existing['id']}",
130+
token,
131+
"PATCH",
132+
{"body": body},
133+
)
134+
print(f"Updated comment {existing['id']} on PR #{pr_number}.")
135+
else:
136+
result = _api(
137+
f"/repos/{repo}/issues/{pr_number}/comments",
138+
token,
139+
"POST",
140+
{"body": body},
141+
)
142+
print(f"Posted comment {result['id']} on PR #{pr_number}.")
143+
144+
145+
if __name__ == "__main__":
146+
main()

0 commit comments

Comments
 (0)