@@ -155,9 +155,14 @@ def parse_link_header(header):
155155
156156
157157def parse_program_frontmatter (content ):
158- """Parse optional YAML frontmatter for ``schedule`` and ``target-metric``.
158+ """Parse optional YAML frontmatter for ``schedule``, ``target-metric``, and ``metric_direction ``.
159159
160- Returns ``(schedule_delta, target_metric, target_metric_invalid_value)``.
160+ Returns ``(schedule_delta, target_metric, target_metric_invalid_value,
161+ metric_direction, metric_direction_invalid_value)``.
162+
163+ ``metric_direction`` is one of ``"higher"`` (default) or ``"lower"``.
164+ Invalid values fall back to ``"higher"`` and the raw string is returned in
165+ the fifth element so the caller can warn.
161166 The third element is the raw string of an invalid ``target-metric`` value
162167 (so the caller can warn), or ``None`` when the value parsed cleanly or was
163168 absent.
@@ -167,20 +172,41 @@ def parse_program_frontmatter(content):
167172 schedule_delta = None
168173 target_metric = None
169174 target_metric_invalid = None
175+ metric_direction = "higher"
176+ metric_direction_invalid = None
170177 fm_match = re .match (r"^---\s*\n(.*?)\n---\s*\n" , content_stripped , re .DOTALL )
171178 if not fm_match :
172- return schedule_delta , target_metric , target_metric_invalid
179+ return (
180+ schedule_delta ,
181+ target_metric ,
182+ target_metric_invalid ,
183+ metric_direction ,
184+ metric_direction_invalid ,
185+ )
173186 for line in fm_match .group (1 ).split ("\n " ):
174- if line .strip ().startswith ("schedule:" ):
187+ stripped = line .strip ()
188+ if stripped .startswith ("schedule:" ):
175189 schedule_str = line .split (":" , 1 )[1 ].strip ()
176190 schedule_delta = parse_schedule (schedule_str )
177- if line . strip () .startswith ("target-metric:" ):
191+ if stripped .startswith ("target-metric:" ):
178192 raw = line .split (":" , 1 )[1 ].strip ()
179193 try :
180194 target_metric = float (raw )
181195 except (ValueError , TypeError ):
182196 target_metric_invalid = raw
183- return schedule_delta , target_metric , target_metric_invalid
197+ if stripped .startswith ("metric_direction:" ) or stripped .startswith ("metric-direction:" ):
198+ raw = line .split (":" , 1 )[1 ].strip ().strip ('"' ).strip ("'" ).lower ()
199+ if raw in ("higher" , "lower" ):
200+ metric_direction = raw
201+ else :
202+ metric_direction_invalid = raw
203+ return (
204+ schedule_delta ,
205+ target_metric ,
206+ target_metric_invalid ,
207+ metric_direction ,
208+ metric_direction_invalid ,
209+ )
184210
185211
186212def is_unconfigured (content ):
@@ -363,12 +389,22 @@ def _parse_target_metric_from_file(path):
363389 """Re-parse a program file to extract its ``target-metric``, if any."""
364390 try :
365391 with open (path ) as f :
366- _ , target_metric , _ = parse_program_frontmatter (f .read ())
392+ _ , target_metric , _ , _ , _ = parse_program_frontmatter (f .read ())
367393 return target_metric
368394 except (OSError , ValueError , TypeError ):
369395 return None
370396
371397
398+ def _parse_metric_direction_from_file (path ):
399+ """Re-parse a program file to extract its ``metric_direction`` (default ``"higher"``)."""
400+ try :
401+ with open (path ) as f :
402+ _ , _ , _ , direction , _ = parse_program_frontmatter (f .read ())
403+ return direction or "higher"
404+ except (OSError , ValueError , TypeError ):
405+ return "higher"
406+
407+
372408# ---------------------------------------------------------------------------
373409# Existing PR lookup (single-PR-per-program invariant)
374410# ---------------------------------------------------------------------------
@@ -459,23 +495,25 @@ def select_program(due, forced_program=None, all_programs=None, unconfigured=Non
459495 """Pick the program to run.
460496
461497 Returns ``(selected, selected_file, selected_issue, selected_target_metric,
462- deferred, error)``. ``error`` is a string describing why a forced selection
463- failed (and the caller should ``sys.exit(1)``); otherwise it is ``None``.
498+ selected_metric_direction, deferred, error)``. ``error`` is a string describing
499+ why a forced selection failed (and the caller should ``sys.exit(1)``);
500+ otherwise it is ``None``. ``selected_metric_direction`` is one of
501+ ``"higher"`` (default) or ``"lower"``.
464502 """
465503 all_programs = all_programs or {}
466504 unconfigured = unconfigured or []
467505 issue_programs = issue_programs or {}
468506 if forced_program :
469507 if forced_program not in all_programs :
470508 return (
471- None , None , None , None , [],
509+ None , None , None , None , "higher" , [],
472510 "requested program '{}' not found. Available programs: {}" .format (
473511 forced_program , list (all_programs .keys ())
474512 ),
475513 )
476514 if forced_program in unconfigured :
477515 return (
478- None , None , None , None , [],
516+ None , None , None , None , "higher" , [],
479517 "requested program '{}' is unconfigured (has placeholders)." .format (
480518 forced_program
481519 ),
@@ -487,13 +525,25 @@ def select_program(due, forced_program=None, all_programs=None, unconfigured=Non
487525 issue_programs [selected ]["issue_number" ] if selected in issue_programs else None
488526 )
489527 selected_target_metric = None
528+ selected_metric_direction = None
490529 for p in due :
491530 if p ["name" ] == forced_program :
492531 selected_target_metric = p .get ("target_metric" )
532+ selected_metric_direction = p .get ("metric_direction" )
493533 break
494534 if selected_target_metric is None :
495535 selected_target_metric = _parse_target_metric_from_file (selected_file )
496- return selected , selected_file , selected_issue , selected_target_metric , deferred , None
536+ if selected_metric_direction is None :
537+ selected_metric_direction = _parse_metric_direction_from_file (selected_file )
538+ return (
539+ selected ,
540+ selected_file ,
541+ selected_issue ,
542+ selected_target_metric ,
543+ selected_metric_direction ,
544+ deferred ,
545+ None ,
546+ )
497547
498548 if due :
499549 # Normal scheduling: pick the single most-overdue program.
@@ -502,13 +552,22 @@ def select_program(due, forced_program=None, all_programs=None, unconfigured=Non
502552 selected = due_sorted [0 ]["name" ]
503553 selected_file = due_sorted [0 ]["file" ]
504554 selected_target_metric = due_sorted [0 ].get ("target_metric" )
555+ selected_metric_direction = due_sorted [0 ].get ("metric_direction" ) or "higher"
505556 deferred = [p ["name" ] for p in due_sorted [1 :]]
506557 selected_issue = (
507558 issue_programs [selected ]["issue_number" ] if selected in issue_programs else None
508559 )
509- return selected , selected_file , selected_issue , selected_target_metric , deferred , None
560+ return (
561+ selected ,
562+ selected_file ,
563+ selected_issue ,
564+ selected_target_metric ,
565+ selected_metric_direction ,
566+ deferred ,
567+ None ,
568+ )
510569
511- return None , None , None , None , [], None
570+ return None , None , None , None , "higher" , [], None
512571
513572
514573# ---------------------------------------------------------------------------
@@ -574,9 +633,15 @@ def main():
574633 unconfigured .append (name )
575634 continue
576635
577- schedule_delta , target_metric , invalid_target = parse_program_frontmatter (content )
636+ schedule_delta , target_metric , invalid_target , metric_direction , invalid_direction = parse_program_frontmatter (content )
578637 if invalid_target is not None :
579638 print (" Warning: {} has invalid target-metric value: {}" .format (name , invalid_target ))
639+ if invalid_direction is not None :
640+ print (
641+ " Warning: {} has invalid metric_direction value: {!r} (must be 'higher' or 'lower'); defaulting to 'higher'" .format (
642+ name , invalid_direction
643+ )
644+ )
580645
581646 # Read state from repo-memory
582647 state = read_program_state (name )
@@ -613,9 +678,15 @@ def main():
613678 )
614679 continue
615680
616- due .append ({"name" : name , "last_run" : lr , "file" : pf , "target_metric" : target_metric })
681+ due .append ({
682+ "name" : name ,
683+ "last_run" : lr ,
684+ "file" : pf ,
685+ "target_metric" : target_metric ,
686+ "metric_direction" : metric_direction ,
687+ })
617688
618- selected , selected_file , selected_issue , selected_target_metric , deferred , error = (
689+ selected , selected_file , selected_issue , selected_target_metric , selected_metric_direction , deferred , error = (
619690 select_program (due , forced_program , all_programs , unconfigured , issue_programs )
620691 )
621692
@@ -645,6 +716,7 @@ def main():
645716 "selected_file" : selected_file ,
646717 "selected_issue" : selected_issue ,
647718 "selected_target_metric" : selected_target_metric ,
719+ "selected_metric_direction" : selected_metric_direction ,
648720 "state_file_size_bytes" : get_state_file_size (selected ) if selected else 0 ,
649721 "state_file_max_bytes" : STATE_FILE_MAX_BYTES ,
650722 "issue_programs" : {
0 commit comments