Skip to content

Commit f317b16

Browse files
committed
Print uncovered lines from PR
1 parent e306a1a commit f317b16

1 file changed

Lines changed: 296 additions & 4 deletions

File tree

tools/print_uncovered_lines.py

Lines changed: 296 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,18 @@
2222
from __future__ import annotations
2323

2424
import argparse
25+
import json
26+
import os
27+
import sys
28+
import tempfile
29+
import urllib.error
30+
import urllib.request
31+
import zipfile
2532
from pathlib import Path
2633

2734

35+
DEFAULT_REPO = "kunitoki/yup"
36+
2837
DEFAULT_TARGETS = [
2938
"yup_GpuPipeline.cpp",
3039
"yup_GpuRenderPass.cpp",
@@ -39,28 +48,305 @@
3948
]
4049

4150

51+
def get_github_token() -> str:
52+
"""Return the GitHub token from environment variables."""
53+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
54+
if not token:
55+
print(
56+
"Error: GITHUB_TOKEN or GH_TOKEN environment variable is not set.",
57+
file=sys.stderr,
58+
)
59+
sys.exit(1)
60+
return token
61+
62+
63+
def _report_github_http_error(exc: urllib.error.HTTPError, url: str) -> None:
64+
"""Print a user-friendly error for a failed GitHub API call."""
65+
code = exc.code
66+
body = exc.read().decode(errors="replace")
67+
detail = ""
68+
69+
# Try to extract the "message" field from the JSON body.
70+
try:
71+
body_data = json.loads(body)
72+
if isinstance(body_data, dict):
73+
detail = body_data.get("message", "")
74+
except (json.JSONDecodeError, TypeError):
75+
pass
76+
77+
if code == 401:
78+
print(
79+
"Error: authentication failed (HTTP 401).\n"
80+
" → Your GITHUB_TOKEN is invalid or expired.\n"
81+
" → Generate a new token at https://github.com/settings/tokens",
82+
file=sys.stderr,
83+
)
84+
if detail:
85+
print(f" API message: {detail}", file=sys.stderr)
86+
elif code == 403:
87+
print(
88+
"Error: access denied (HTTP 403).\n"
89+
" → Your token may lack permission to read this repository or its actions.\n"
90+
" → For a public repo, the token needs at least 'actions:read' scope.\n"
91+
" → For a private repo, the token also needs 'repo' scope.",
92+
file=sys.stderr,
93+
)
94+
if detail:
95+
print(f" API message: {detail}", file=sys.stderr)
96+
elif code == 404:
97+
print(
98+
f"Error: resource not found (HTTP 404).\n"
99+
f" → Double-check the PR number and repository name.\n"
100+
f" → URL: {url}",
101+
file=sys.stderr,
102+
)
103+
if detail:
104+
print(f" API message: {detail}", file=sys.stderr)
105+
else:
106+
print(
107+
f"Error: GitHub API request failed ({code}).",
108+
file=sys.stderr,
109+
)
110+
if detail:
111+
print(f" {detail}", file=sys.stderr)
112+
else:
113+
print(f" {body}", file=sys.stderr)
114+
115+
116+
def github_api_request(url: str, token: str) -> object:
117+
"""Make an authenticated GET request to the GitHub API and return the parsed JSON."""
118+
req = urllib.request.Request(url)
119+
req.add_header("Authorization", f"Bearer {token}")
120+
req.add_header("Accept", "application/vnd.github+json")
121+
req.add_header("X-GitHub-Api-Version", "2022-11-28")
122+
123+
try:
124+
with urllib.request.urlopen(req) as resp:
125+
return json.loads(resp.read())
126+
except urllib.error.HTTPError as exc:
127+
_report_github_http_error(exc, url)
128+
sys.exit(1)
129+
except urllib.error.URLError as exc:
130+
print(f"Error: could not reach GitHub API: {exc.reason}", file=sys.stderr)
131+
sys.exit(1)
132+
133+
134+
def download_file(url: str, dest: Path, token: str) -> None:
135+
"""Download a file from *url* and save it to *dest*.
136+
137+
GitHub artifact downloads return a 302 redirect to Azure blob storage.
138+
We handle the redirect manually so the Authorization header is never
139+
forwarded to the external host.
140+
"""
141+
142+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
143+
def redirect_request(self, req, fp, code, msg, headers, newurl):
144+
return None # suppress automatic redirect following
145+
146+
opener = urllib.request.build_opener(_NoRedirectHandler)
147+
148+
req = urllib.request.Request(url)
149+
req.add_header("Authorization", f"Bearer {token}")
150+
req.add_header("Accept", "application/vnd.github+json")
151+
req.add_header("X-GitHub-Api-Version", "2022-11-28")
152+
153+
try:
154+
resp = opener.open(req)
155+
dest.write_bytes(resp.read())
156+
except urllib.error.HTTPError as exc:
157+
if exc.code in (301, 302, 303, 307):
158+
redirect_url = exc.headers.get("Location")
159+
if not redirect_url:
160+
print(
161+
"Error: artifact download returned a redirect without a Location header.",
162+
file=sys.stderr,
163+
)
164+
sys.exit(1)
165+
# Second request — no Authorization header, the URL is pre-signed.
166+
try:
167+
with urllib.request.urlopen(redirect_url) as resp2:
168+
dest.write_bytes(resp2.read())
169+
except urllib.error.HTTPError as exc2:
170+
_report_github_http_error(exc2, redirect_url)
171+
sys.exit(1)
172+
except urllib.error.URLError as exc2:
173+
print(
174+
f"Error: could not download from redirect URL: {exc2.reason}",
175+
file=sys.stderr,
176+
)
177+
sys.exit(1)
178+
else:
179+
_report_github_http_error(exc, url)
180+
sys.exit(1)
181+
except urllib.error.URLError as exc:
182+
print(f"Error: could not download artifact: {exc.reason}", file=sys.stderr)
183+
sys.exit(1)
184+
185+
186+
def resolve_coverage_from_pr(
187+
pr_number: int,
188+
repo: str,
189+
workflow_name: str,
190+
) -> Path:
191+
"""Fetch the coverage artifact from a GitHub Actions PR run.
192+
193+
Returns the path to the extracted ``coverage_final.info`` file.
194+
"""
195+
token = get_github_token()
196+
197+
# ------------------------------------------------------------------
198+
# 1. Get the PR head SHA
199+
# ------------------------------------------------------------------
200+
pr_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}"
201+
pr_data = github_api_request(pr_url, token)
202+
203+
if not isinstance(pr_data, dict):
204+
print(f"Error: unexpected API response for PR #{pr_number}", file=sys.stderr)
205+
sys.exit(1)
206+
207+
head_sha = pr_data.get("head", {}).get("sha")
208+
if not head_sha:
209+
print(f"Error: could not determine head SHA for PR #{pr_number}", file=sys.stderr)
210+
sys.exit(1)
211+
212+
print(f"PR #{pr_number} head SHA: {head_sha}")
213+
214+
# ------------------------------------------------------------------
215+
# 2. Find completed / successful workflow runs for that commit
216+
# ------------------------------------------------------------------
217+
runs_url = (
218+
f"https://api.github.com/repos/{repo}/actions/runs"
219+
f"?head_sha={head_sha}&status=completed&conclusion=success&per_page=100"
220+
)
221+
runs_data = github_api_request(runs_url, token)
222+
223+
if not isinstance(runs_data, dict) or not runs_data.get("workflow_runs"):
224+
print(
225+
f"Error: no completed/successful workflow runs found for PR #{pr_number}",
226+
file=sys.stderr,
227+
)
228+
sys.exit(1)
229+
230+
# Prefer runs whose workflow name contains the *workflow_name* substring.
231+
matching = [
232+
r
233+
for r in runs_data["workflow_runs"]
234+
if workflow_name.lower() in r.get("name", "").lower()
235+
]
236+
runs = matching if matching else runs_data["workflow_runs"]
237+
238+
run = runs[0] # API returns newest first
239+
print(f"Using workflow run: {run['name']} (id={run['id']})")
240+
241+
# ------------------------------------------------------------------
242+
# 3. Find the "coverage-reports" artifact
243+
# ------------------------------------------------------------------
244+
artifacts_url = run["artifacts_url"]
245+
artifacts_data = github_api_request(artifacts_url, token)
246+
247+
if not isinstance(artifacts_data, dict) or not artifacts_data.get("artifacts"):
248+
print(f"Error: no artifacts found in workflow run {run['id']}", file=sys.stderr)
249+
sys.exit(1)
250+
251+
coverage_artifact = None
252+
for artifact in artifacts_data["artifacts"]:
253+
if artifact.get("name") == "coverage-reports":
254+
coverage_artifact = artifact
255+
break
256+
257+
if not coverage_artifact:
258+
available = [a.get("name") for a in artifacts_data["artifacts"]]
259+
print(
260+
f"Error: 'coverage-reports' artifact not found. "
261+
f"Available: {available}",
262+
file=sys.stderr,
263+
)
264+
sys.exit(1)
265+
266+
print(
267+
f"Downloading artifact: {coverage_artifact['name']} "
268+
f"(id={coverage_artifact['id']}, size={coverage_artifact['size_in_bytes']} bytes)"
269+
)
270+
271+
# ------------------------------------------------------------------
272+
# 4. Download and extract the artifact zip
273+
# ------------------------------------------------------------------
274+
tmp_dir = Path(tempfile.mkdtemp(prefix="yup_coverage_"))
275+
zip_path = tmp_dir / "coverage-reports.zip"
276+
277+
download_file(coverage_artifact["archive_download_url"], zip_path, token)
278+
279+
with zipfile.ZipFile(zip_path, "r") as zf:
280+
zf.extractall(tmp_dir)
281+
282+
# ------------------------------------------------------------------
283+
# 5. Locate coverage_final.info inside the extracted tree
284+
# ------------------------------------------------------------------
285+
info_files = list(tmp_dir.rglob("coverage_final.info"))
286+
if not info_files:
287+
print(
288+
"Error: coverage_final.info not found in the extracted artifact.",
289+
file=sys.stderr,
290+
)
291+
sys.exit(1)
292+
293+
coverage_path = info_files[0]
294+
print(f"Using coverage file: {coverage_path}")
295+
return coverage_path
296+
297+
42298
def parse_arguments() -> argparse.Namespace:
43299
parser = argparse.ArgumentParser(
44300
description="Print uncovered line numbers from an LCOV .info coverage file."
45301
)
46-
parser.add_argument(
302+
303+
# ------------------------------------------------------------------
304+
# Source selection (one must be provided)
305+
# ------------------------------------------------------------------
306+
source_group = parser.add_mutually_exclusive_group()
307+
source_group.add_argument(
47308
"coverage_file",
309+
nargs="?",
48310
type=Path,
49311
help="Path to the LCOV .info coverage file.",
50312
)
313+
source_group.add_argument(
314+
"--pr",
315+
type=int,
316+
metavar="NUMBER",
317+
help="PR number to fetch the coverage artifact from GitHub Actions.",
318+
)
319+
51320
parser.add_argument(
52321
"targets",
53322
nargs="*",
54-
help="Source file names or path fragments to report. Uses the default graphics targets when omitted.",
323+
help="Source file names or path fragments to report. "
324+
"Uses the default graphics targets when omitted.",
55325
)
56326
parser.add_argument(
57327
"--all",
58328
action="store_true",
59329
help="Report every source file found in the coverage file.",
60330
)
331+
parser.add_argument(
332+
"--repo",
333+
default=DEFAULT_REPO,
334+
metavar="OWNER/REPO",
335+
help=f"GitHub repository to query when using --pr (default: {DEFAULT_REPO}).",
336+
)
337+
parser.add_argument(
338+
"--workflow",
339+
default="coverage",
340+
metavar="NAME",
341+
help="Substring to match against workflow run names (default: 'coverage').",
342+
)
343+
61344
args = parser.parse_args()
62345

63-
if not args.coverage_file.is_file():
346+
if args.pr is None and args.coverage_file is None:
347+
parser.error("either a coverage_file path or --pr must be provided")
348+
349+
if args.coverage_file is not None and not args.coverage_file.is_file():
64350
parser.error(f"coverage file does not exist: {args.coverage_file}")
65351

66352
return args
@@ -119,7 +405,13 @@ def print_uncovered_lines(coverage_file: Path, targets: list[str], report_all: b
119405
def main() -> None:
120406
args = parse_arguments()
121407
targets = args.targets if args.targets else DEFAULT_TARGETS
122-
print_uncovered_lines(args.coverage_file, targets, args.all)
408+
409+
if args.pr is not None:
410+
coverage_file = resolve_coverage_from_pr(args.pr, args.repo, args.workflow)
411+
else:
412+
coverage_file = args.coverage_file
413+
414+
print_uncovered_lines(coverage_file, targets, args.all)
123415

124416

125417
if __name__ == "__main__":

0 commit comments

Comments
 (0)