55import sys
66import time
77from datetime import timedelta
8+ from typing import Dict , List , Optional
89
910import mpas_tools .io
1011from mpas_tools .logging import LoggingContext , check_call
@@ -89,6 +90,9 @@ def run_tasks(
8990 suite_start = time .time ()
9091 task_times = dict ()
9192 result_strs = dict ()
93+ total_tasks = len (suite ['tasks' ])
94+ exec_fail_tasks : List [str ] = []
95+ diff_fail_tasks : List [str ] = []
9296 for task_name in suite ['tasks' ]:
9397 stdout_logger .info (f'{ task_name } ' )
9498
@@ -102,7 +106,13 @@ def run_tasks(
102106 log_filename = f'{ cwd } /case_outputs/{ task_prefix } .log'
103107 task_logger = None
104108
105- result_str , success , task_time = _log_and_run_task (
109+ (
110+ result_str ,
111+ success ,
112+ task_time ,
113+ exec_failed ,
114+ diff_failed ,
115+ ) = _log_and_run_task (
106116 task ,
107117 stdout_logger ,
108118 task_logger ,
@@ -116,11 +126,27 @@ def run_tasks(
116126 result_strs [task_name ] = result_str
117127 if not success :
118128 failures += 1
129+ if exec_failed :
130+ exec_fail_tasks .append (task_name )
131+ if diff_failed :
132+ diff_fail_tasks .append (task_name )
119133 task_times [task_name ] = task_time
120134
121135 suite_time = time .time () - suite_start
122136
123137 os .chdir (cwd )
138+
139+ # Write a concise, copy/paste-friendly summary for Omega PRs
140+ _write_output_for_pull_request (
141+ suite_name ,
142+ suite ,
143+ results = {
144+ 'total' : total_tasks ,
145+ 'failures' : exec_fail_tasks ,
146+ 'diffs' : diff_fail_tasks ,
147+ },
148+ )
149+
124150 _log_task_runtimes (
125151 stdout_logger , task_times , result_strs , suite_time , failures
126152 )
@@ -366,6 +392,8 @@ def _log_and_run_task(
366392 task_logger .info ('' )
367393 task_list = ', ' .join (task .steps_to_run )
368394 task_logger .info (f'Running steps: { task_list } ' )
395+ # Default in case execution fails before setting this
396+ baselines_passed = None
369397 try :
370398 baselines_passed = _run_task (task , available_resources )
371399 run_status = success_str
@@ -409,7 +437,9 @@ def _log_and_run_task(
409437 f' task runtime: { start_time_color } { task_time_str } { end_color } '
410438 )
411439
412- return result_str , success , task_time
440+ exec_failed = not task_pass
441+ diff_failed = baselines_passed is False
442+ return result_str , success , task_time , exec_failed , diff_failed
413443
414444
415445def _run_task (task , available_resources ):
@@ -604,3 +634,159 @@ def _run_step_as_subprocess(logger, step, new_log_file):
604634 os .chdir (step .work_dir )
605635 step_args = ['polaris' , 'serial' , '--step_is_subprocess' ]
606636 check_call (step_args , step_logger )
637+
638+
639+ def _write_output_for_pull_request (
640+ suite_name , suite , results : Optional [dict ] = None
641+ ):
642+ """
643+ Parse metadata from the provenance file and write a concise summary that
644+ can be copy/pasted into an Omega pull request.
645+
646+ Parameters
647+ ----------
648+ suite_name : str
649+ The name of the suite (or 'task') being run.
650+
651+ suite : dict
652+ The unpickled suite dictionary containing tasks and base work dir.
653+ """
654+ work_dir = suite .get ('work_dir' , os .getcwd ())
655+ provenance_path = os .path .join (work_dir , 'provenance' )
656+ if not os .path .exists (provenance_path ):
657+ return
658+
659+ # keys in provenance are written exactly like these labels
660+ labels = {
661+ 'baseline work directory' : 'baseline' ,
662+ 'build directory' : 'build' ,
663+ 'work directory' : 'work' ,
664+ 'machine' : 'machine' ,
665+ 'partition' : 'partition' ,
666+ 'compiler' : 'compiler' ,
667+ }
668+
669+ values : Dict [str , Optional [str ]] = {v : None for v in labels .values ()}
670+ _parse_provenance_into (provenance_path , labels , values )
671+
672+ # If a baseline workdir exists, parse its provenance to get baseline build
673+ baseline_build : Optional [str ] = None
674+ baseline_build = _parse_baseline_build (values .get ('baseline' ))
675+
676+ # Build the output content. Only include optional lines if present.
677+ lines = [f'Polaris { suite_name } suite' ]
678+
679+ if values ['baseline' ]:
680+ lines .append (f'- Baseline workdir: { values ["baseline" ]} ' )
681+ if baseline_build :
682+ lines .append (f'- Baseline build: { baseline_build } ' )
683+ if values ['build' ]:
684+ lines .append (f'- PR build: { values ["build" ]} ' )
685+ if values ['work' ]:
686+ lines .append (f'- PR workdir: { values ["work" ]} ' )
687+ if values ['machine' ]:
688+ lines .append (f'- Machine: { values ["machine" ]} ' )
689+ if values ['partition' ]:
690+ lines .append (f'- Partition: { values ["partition" ]} ' )
691+ if values ['compiler' ]:
692+ lines .append (f'- Compiler: { values ["compiler" ]} ' )
693+
694+ # Placeholder for developer to fill in
695+ lines .append ('- Build type: <Debug|Release>' )
696+
697+ # Try to include job scheduler log path for Slurm
698+ job_log = _derive_job_log_path (suite_name , suite )
699+ if job_log :
700+ lines .append (f'- Log: { job_log } ' )
701+
702+ # If we have results, summarize them
703+ if results is not None and isinstance (results , dict ):
704+ total = int (results .get ('total' , 0 ) or 0 )
705+ failures : List [str ] = list (results .get ('failures' , []) or [])
706+ diffs : List [str ] = list (results .get ('diffs' , []) or [])
707+
708+ if total > 0 and not failures and not diffs :
709+ lines .append ('- Result: All tests passed' )
710+ else :
711+ lines .append ('- Result:' )
712+ if failures :
713+ lines .append (f' - Failures ({ len (failures )} of { total } ):' )
714+ for name in failures :
715+ lines .append (f' - { name } ' )
716+ if diffs :
717+ lines .append (f' - Diffs ({ len (diffs )} of { total } ):' )
718+ for name in diffs :
719+ lines .append (f' - { name } ' )
720+
721+ out_path = os .path .join (work_dir , f'{ suite_name } _output_for_pr.log' )
722+ print (f'Writing output useful for copy/paste into PRs to:\n { out_path } ' )
723+ with open (out_path , 'w' ) as out :
724+ out .write ('\n ' .join (lines ) + '\n ' )
725+ print ('Done.' )
726+
727+
728+ def _parse_provenance_into (path , labels , target_values ):
729+ if not os .path .exists (path ):
730+ return
731+ try :
732+ with open (path , 'r' ) as f :
733+ for line in f :
734+ if ':' not in line :
735+ continue
736+ parts = line .strip ().split (':' , 1 )
737+ if len (parts ) != 2 :
738+ continue
739+ key = parts [0 ].strip ().lower ()
740+ val = parts [1 ].strip ()
741+ if key in labels :
742+ target_values [labels [key ]] = val
743+ except (OSError , UnicodeDecodeError ):
744+ # silent failure; helper is best-effort
745+ pass
746+
747+
748+ def _parse_baseline_build (baseline_workdir : Optional [str ]) -> Optional [str ]:
749+ if not baseline_workdir :
750+ return None
751+ path = os .path .join (baseline_workdir , 'provenance' )
752+ if not os .path .exists (path ):
753+ return None
754+ try :
755+ with open (path , 'r' ) as f :
756+ for line in f :
757+ if ':' not in line :
758+ continue
759+ parts = line .strip ().split (':' , 1 )
760+ if len (parts ) != 2 :
761+ continue
762+ key = parts [0 ].strip ().lower ()
763+ val = parts [1 ].strip ()
764+ if key == 'build directory' :
765+ return val
766+ except (OSError , UnicodeDecodeError ):
767+ return None
768+ return None
769+
770+
771+ def _derive_job_log_path (suite_name : str , suite : dict ) -> Optional [str ]:
772+ """Best-effort reconstruction of the Slurm job log path."""
773+ job_id = os .environ .get ('SLURM_JOB_ID' )
774+ if not job_id :
775+ return None
776+
777+ # Reconstruct the job_name the same way the job script did
778+ # using the common component config
779+ try :
780+ task = next (iter (suite ['tasks' ].values ()))
781+ component = task .component
782+ common_config = setup_config (
783+ task .base_work_dir , f'{ component .name } .cfg'
784+ )
785+ job_name = common_config .get ('job' , 'job_name' )
786+ if job_name == '<<<default>>>' :
787+ suite_suffix = f'_{ suite_name } ' if suite_name else ''
788+ job_name = f'polaris{ suite_suffix } '
789+ work_dir = suite .get ('work_dir' , os .getcwd ())
790+ return os .path .join (work_dir , f'{ job_name } .o{ job_id } ' )
791+ except (StopIteration , KeyError , OSError , AttributeError ):
792+ return None
0 commit comments