2121from typing import Optional
2222
2323import github
24- from github import Github , InputFileContent
24+ from github import Auth , Github , InputFileContent
2525import requests
2626import 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+
323424def 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