Skip to content

Commit 12da07f

Browse files
committed
feat: add --test-report flag and fix PyGithub auth
Fix PyGithub >= 2.0 compatibility: the deprecated Github(token) positional argument no longer authenticates; switch to Github(auth=Auth.Token(token)). Add --test-report <ci_launcher_url> to fetch JUnit results from a completed ci_launcher run and post a structured summary comment on the PR. The comment includes: - A summary table with pass/fail counts, build duration per platform, and a direct link to each platform's /testReport/ page - Collapsible per-platform failure tables sorted by package name
1 parent e4ae152 commit 12da07f

1 file changed

Lines changed: 135 additions & 13 deletions

File tree

ros_github_scripts/ci_for_pr.py

Lines changed: 135 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from typing import Optional
2222

2323
import github
24-
from github import Github, InputFileContent
24+
from github import Auth, Github, InputFileContent
2525
import requests
2626
import yaml
2727

@@ -320,6 +320,107 @@ def run_jenkins_build(
320320
return '\n'.join(info_lines)
321321

322322

323+
def parse_child_build_urls(text: str) -> Dict[str, str]:
324+
"""Extract platform -> build_url from badge markdown lines in launcher console/comment."""
325+
badge_re = re.compile(r'\* (\S+)\s+\[!\[.*?\]\(.*?\)\]\((https?://[^)]+)\)')
326+
result = {}
327+
for line in text.split('\n'):
328+
m = badge_re.match(line.strip())
329+
if m:
330+
url = m.group(2).rstrip('/').replace('http://', 'https://')
331+
result[m.group(1)] = url
332+
return result
333+
334+
335+
def fetch_child_build_urls(
336+
launcher_url: str, auth: Optional[tuple] = None
337+
) -> Dict[str, str]:
338+
"""Fetch ci_launcher console output and return child platform build URLs."""
339+
console_url = launcher_url.rstrip('/') + '/consoleText'
340+
logger.info(f'Fetching launcher console from {console_url}')
341+
resp = requests.get(console_url, auth=auth)
342+
resp.raise_for_status()
343+
urls = parse_child_build_urls(resp.text)
344+
if not urls:
345+
panic(f'No child build URLs found in launcher console at {launcher_url}')
346+
return urls
347+
348+
349+
def fetch_test_report(build_url: str, auth: Optional[tuple] = None) -> Optional[dict]:
350+
"""Fetch JUnit test report from a Jenkins build. Returns None if not yet available."""
351+
url = (
352+
build_url.rstrip('/') +
353+
'/testReport/api/json'
354+
'?tree=failCount,passCount,skipCount,suites[name,cases[className,name,status,errorDetails]]'
355+
)
356+
resp = requests.get(url, auth=auth)
357+
if resp.status_code == 404:
358+
return None
359+
resp.raise_for_status()
360+
data = resp.json()
361+
build_resp = requests.get(f'{build_url.rstrip("/")}/api/json?tree=duration', auth=auth)
362+
if build_resp.status_code == 200:
363+
data['_build_duration_ms'] = build_resp.json().get('duration', 0)
364+
return data
365+
366+
367+
def format_test_report_comment(
368+
platform_reports: Dict[str, Optional[dict]],
369+
platform_urls: Dict[str, str],
370+
launcher_url: str,
371+
) -> str:
372+
"""Format per-platform test results as markdown tables."""
373+
lines = ['## CI Test Results']
374+
375+
# Summary table
376+
lines.append('')
377+
lines.append('| Platform | Result | Failed | Passed | Skipped | Duration | Report |')
378+
lines.append('|----------|--------|-------:|-------:|--------:|---------:|--------|')
379+
for platform, report in platform_reports.items():
380+
build_url = platform_urls.get(platform, '')
381+
report_url = f'{build_url}/testReport/' if build_url else ''
382+
report_link = f'[results]({report_url})' if report_url else '—'
383+
if report is None:
384+
lines.append(f'| {platform} | ⚪ N/A | — | — | — | — | {report_link} |')
385+
else:
386+
fail = report.get('failCount', 0)
387+
passed = report.get('passCount', 0)
388+
skipped = report.get('skipCount', 0)
389+
status = '✅ pass' if fail == 0 else '❌ fail'
390+
ms = report.get('_build_duration_ms', 0)
391+
duration = f'{ms // 60000}m {(ms % 60000) // 1000}s' if ms else '—'
392+
lines.append(
393+
f'| {platform} | {status} | {fail} | {passed} | {skipped}'
394+
f' | {duration} | {report_link} |'
395+
)
396+
397+
# Per-platform failure tables (collapsible, sorted by package)
398+
for platform, report in platform_reports.items():
399+
if report is None or report.get('failCount', 0) == 0:
400+
continue
401+
failures = sorted(
402+
(
403+
(c['className'] if c['className'] != 'projectroot' else s['name'], c['name'])
404+
for s in report.get('suites', [])
405+
for c in s.get('cases', [])
406+
if c['status'] not in ('PASSED', 'SKIPPED', 'FIXED')
407+
),
408+
key=lambda x: x[0],
409+
)
410+
fail_count = report.get('failCount', 0)
411+
lines.append(f'\n<details>')
412+
lines.append(f'<summary><b>{platform}</b> — {fail_count} failure(s)</summary>')
413+
lines.append('')
414+
lines.append('| Package | Test |')
415+
lines.append('|---------|------|')
416+
for pkg, test in failures:
417+
lines.append(f'| `{pkg}` | {test} |')
418+
lines.append('')
419+
lines.append('</details>')
420+
421+
return '\n'.join(lines)
422+
423+
323424
def comment_results(
324425
send_to_github: bool, contents: str, pulls: List[github.PullRequest.PullRequest]
325426
) -> None:
@@ -400,6 +501,13 @@ def printable_package_name(value):
400501
'--cmake-args', type=str, default='',
401502
help='Arbitrary CMake arguments to specify to build; Each argument shall be prefixed'
402503
' with -D. CMake arguments shall only be used with -b --build option.')
504+
group.add_argument(
505+
'--test-report', type=str, default=None, metavar='CI_LAUNCHER_URL',
506+
help='Fetch test results from a completed ci_launcher run and post a failure summary. '
507+
'Provide the ci_launcher build URL '
508+
'(e.g. https://ci.ros2.org/job/ci_launcher/19468). '
509+
'Use with --pulls and --comment to post the summary on the PR(s). '
510+
'Cannot be combined with --build.')
403511
return parser.parse_args()
404512

405513

@@ -411,7 +519,7 @@ def main():
411519
github_access_token = os.environ.get('GITHUB_TOKEN')
412520
if not github_access_token:
413521
panic('Neither environment variable GITHUB_ACCESS_TOKEN nor GITHUB_TOKEN are set')
414-
github_instance = Github(github_access_token)
522+
github_instance = Github(auth=Auth.Token(github_access_token))
415523

416524
branch_name = parsed.branch
417525
pull_texts = parsed.pulls
@@ -433,13 +541,15 @@ def main():
433541
'When using --branch and --comment, you must select PRs to comment on either using '
434542
'--interactive or by providing them using --pulls')
435543

436-
# Only create a gist if we specified PRs and not a branch
437-
# We can set both options for ci.ros2.org, but it does not make sense if we select PRs
544+
# Only create a gist if we are triggering a build (not for --test-report only)
438545
gist_url = None
439-
if chosen_pulls and not branch_name:
546+
if chosen_pulls and not branch_name and not parsed.test_report:
440547
gist = create_ci_gist(github_instance, chosen_pulls, parsed.target)
441548
gist_url = gist.files['ros2.repos'].raw_url
442549

550+
if parsed.build and parsed.test_report:
551+
panic('--build and --test-report are mutually exclusive')
552+
443553
if not parsed.build and \
444554
(parsed.colcon_build_args or parsed.colcon_test_args or parsed.cmake_args):
445555
panic('colcon build, cmake or test args can only be specified when doing a build')
@@ -459,14 +569,15 @@ def main():
459569
extra_build_args += f' --cmake-args {parsed.cmake_args}'
460570

461571
comment_texts = []
462-
comment_texts.append(
463-
format_ci_details(
464-
gist_url=gist_url,
465-
extra_build_args=extra_build_args,
466-
extra_test_args=extra_test_args,
467-
target_release=parsed.target,
468-
target_pulls=pull_texts,
469-
branch_name=branch_name))
572+
if not parsed.test_report:
573+
comment_texts.append(
574+
format_ci_details(
575+
gist_url=gist_url,
576+
extra_build_args=extra_build_args,
577+
extra_test_args=extra_test_args,
578+
target_release=parsed.target,
579+
target_pulls=pull_texts,
580+
branch_name=branch_name))
470581
if parsed.build:
471582
user = github_instance.get_user().login
472583
comment_texts.append(
@@ -479,6 +590,17 @@ def main():
479590
github_token=github_access_token,
480591
target_release=parsed.target))
481592

593+
if parsed.test_report:
594+
user = github_instance.get_user().login
595+
jenkins_auth = (user, github_access_token)
596+
child_urls = fetch_child_build_urls(parsed.test_report, auth=jenkins_auth)
597+
platform_reports = {}
598+
for platform, build_url in child_urls.items():
599+
logger.info(f'Fetching test report for {platform} from {build_url}')
600+
platform_reports[platform] = fetch_test_report(build_url, auth=jenkins_auth)
601+
comment_texts.append(
602+
format_test_report_comment(platform_reports, child_urls, parsed.test_report))
603+
482604
comment_results(parsed.comment, '\n'.join(comment_texts), chosen_pulls)
483605

484606

0 commit comments

Comments
 (0)