jleechanorg-pr-automation 0.1.1__py3-none-any.whl → 0.2.45__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
  2. jleechanorg_pr_automation/__init__.py +64 -9
  3. jleechanorg_pr_automation/automation_safety_manager.py +306 -95
  4. jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
  5. jleechanorg_pr_automation/automation_utils.py +87 -65
  6. jleechanorg_pr_automation/check_codex_comment.py +7 -1
  7. jleechanorg_pr_automation/codex_branch_updater.py +21 -9
  8. jleechanorg_pr_automation/codex_config.py +70 -3
  9. jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
  10. jleechanorg_pr_automation/logging_utils.py +86 -0
  11. jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
  12. jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
  13. jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
  14. jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
  15. jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
  16. jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
  17. jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
  18. jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
  19. jleechanorg_pr_automation/tests/__init__.py +0 -0
  20. jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
  21. jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
  22. jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
  23. jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
  24. jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
  25. jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
  26. jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
  27. jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
  28. jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
  29. jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
  30. jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
  31. jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
  32. jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
  33. jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
  34. jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
  35. jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
  36. jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
  37. jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
  38. jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
  39. jleechanorg_pr_automation/utils.py +81 -56
  40. jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
  41. jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
  42. jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
  43. jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
  44. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
  45. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
  46. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/top_level.txt +0 -0
@@ -3,7 +3,7 @@
3
3
  Automation Safety Manager - GREEN Phase Implementation
4
4
 
5
5
  Minimal implementation to pass the RED phase tests with:
6
- - PR attempt limits (max 5 per PR)
6
+ - PR attempt limits (max 10 per PR)
7
7
  - Global run limits (max 50 per day with automatic midnight reset)
8
8
  - Manual approval system
9
9
  - Thread-safe operations
@@ -11,21 +11,17 @@ Minimal implementation to pass the RED phase tests with:
11
11
  """
12
12
 
13
13
  import argparse
14
- import fcntl
15
14
  import importlib.util
16
15
  import json
17
- import logging
18
16
  import os
19
17
  import smtplib
20
18
  import sys
21
- import tempfile
22
19
  import threading
23
20
  from datetime import datetime, timedelta
24
- from email.mime.text import MIMEText
25
21
  from email.mime.multipart import MIMEMultipart
22
+ from email.mime.text import MIMEText
26
23
  from typing import Dict, Optional, Union
27
24
 
28
-
29
25
  REAL_DATETIME = datetime
30
26
 
31
27
  # Number of characters in the ISO 8601 date prefix ("YYYY-MM-DD").
@@ -42,28 +38,33 @@ else:
42
38
 
43
39
  # Import shared utilities
44
40
  from .utils import (
41
+ get_automation_limits_with_overrides,
42
+ coerce_positive_int,
45
43
  json_manager,
46
44
  setup_logging,
47
- get_email_config,
48
- validate_email_config,
49
- get_automation_limits,
50
- format_timestamp,
51
- parse_timestamp,
52
45
  )
53
46
 
54
47
 
55
48
  class AutomationSafetyManager:
56
49
  """Thread-safe automation safety manager with configurable limits"""
57
50
 
58
- def __init__(self, data_dir: str):
51
+ def __init__(self, data_dir: str, limits: Optional[Dict[str, int]] = None):
59
52
  self.data_dir = data_dir
60
53
  self.lock = threading.RLock() # Use RLock to prevent deadlock
61
54
  self.logger = setup_logging(__name__)
62
55
 
63
- # Get limits from shared utility
64
- limits = get_automation_limits()
65
- self.pr_limit = limits['pr_limit']
66
- self.global_limit = limits['global_limit']
56
+ # Start from defaults (hardcoded), then apply config file, then explicit overrides.
57
+ merged_limits = get_automation_limits_with_overrides()
58
+ self.pr_limit = merged_limits["pr_limit"]
59
+ self.global_limit = merged_limits["global_limit"]
60
+ self.approval_hours = merged_limits["approval_hours"]
61
+ self.subprocess_timeout = merged_limits["subprocess_timeout"]
62
+
63
+ # Workflow-specific *comment* limits
64
+ self.pr_automation_limit = merged_limits["pr_automation_limit"]
65
+ self.fix_comment_limit = merged_limits["fix_comment_limit"]
66
+ self.codex_update_limit = merged_limits["codex_update_limit"]
67
+ self.fixpr_limit = merged_limits["fixpr_limit"]
67
68
 
68
69
  # File paths
69
70
  self.pr_attempts_file = os.path.join(data_dir, "pr_attempts.json")
@@ -71,11 +72,13 @@ class AutomationSafetyManager:
71
72
  self.approval_file = os.path.join(data_dir, "manual_approval.json")
72
73
  self.config_file = os.path.join(data_dir, "automation_safety_config.json")
73
74
  self.inflight_file = os.path.join(data_dir, "pr_inflight.json") # NEW: Persist inflight cache
75
+ self.pr_overrides_file = os.path.join(data_dir, "pr_limit_overrides.json") # Per-PR limit overrides
74
76
 
75
77
  # In-memory counters for thread safety
76
78
  self._pr_attempts_cache = {}
77
79
  self._global_runs_cache = 0
78
80
  self._pr_inflight_cache: Dict[str, int] = {}
81
+ self._pr_overrides_cache: Dict[str, int] = {} # Per-PR limit overrides (0 = unlimited)
79
82
 
80
83
  # Initialize files if they don't exist
81
84
  self._ensure_files_exist()
@@ -83,6 +86,10 @@ class AutomationSafetyManager:
83
86
  # Load configuration from file if it exists
84
87
  self._load_config_if_exists()
85
88
 
89
+ # Explicit overrides always win (and are clamped to positive ints).
90
+ if limits:
91
+ self._apply_limit_overrides(limits)
92
+
86
93
  # Load initial state from files
87
94
  self._load_state_from_files()
88
95
 
@@ -113,18 +120,17 @@ class AutomationSafetyManager:
113
120
  if not os.path.exists(self.inflight_file):
114
121
  self._write_json_file(self.inflight_file, {})
115
122
 
123
+ if not os.path.exists(self.pr_overrides_file):
124
+ self._write_json_file(self.pr_overrides_file, {})
125
+
116
126
  def _load_config_if_exists(self):
117
127
  """Load configuration from file if it exists, create default if not"""
118
128
  if os.path.exists(self.config_file):
119
129
  # Load existing config
120
130
  try:
121
- with open(self.config_file, 'r') as f:
131
+ with open(self.config_file) as f:
122
132
  config = json.load(f)
123
- # Update limits from config
124
- if 'pr_limit' in config:
125
- self.pr_limit = config['pr_limit']
126
- if 'global_limit' in config:
127
- self.global_limit = config['global_limit']
133
+ self._apply_limit_overrides(config)
128
134
  except (FileNotFoundError, json.JSONDecodeError):
129
135
  pass # Use defaults
130
136
  else:
@@ -132,9 +138,35 @@ class AutomationSafetyManager:
132
138
  default_config = {
133
139
  "global_limit": self.global_limit,
134
140
  "pr_limit": self.pr_limit,
141
+ "approval_hours": self.approval_hours,
142
+ "subprocess_timeout": self.subprocess_timeout,
143
+ "pr_automation_limit": self.pr_automation_limit,
144
+ "fix_comment_limit": self.fix_comment_limit,
145
+ "codex_update_limit": self.codex_update_limit,
146
+ "fixpr_limit": self.fixpr_limit,
135
147
  }
136
148
  self._write_json_file(self.config_file, default_config)
137
149
 
150
+ def _apply_limit_overrides(self, overrides: Dict[str, object]) -> None:
151
+ """Apply override dict, clamping to positive ints and leaving others unchanged."""
152
+ defaults = get_automation_limits_with_overrides()
153
+ if "pr_limit" in overrides:
154
+ self.pr_limit = coerce_positive_int(overrides.get("pr_limit"), default=defaults["pr_limit"])
155
+ if "global_limit" in overrides:
156
+ self.global_limit = coerce_positive_int(overrides.get("global_limit"), default=defaults["global_limit"])
157
+ if "approval_hours" in overrides:
158
+ self.approval_hours = coerce_positive_int(overrides.get("approval_hours"), default=defaults["approval_hours"])
159
+ if "subprocess_timeout" in overrides:
160
+ self.subprocess_timeout = coerce_positive_int(overrides.get("subprocess_timeout"), default=defaults["subprocess_timeout"])
161
+ if "pr_automation_limit" in overrides:
162
+ self.pr_automation_limit = coerce_positive_int(overrides.get("pr_automation_limit"), default=defaults["pr_automation_limit"])
163
+ if "fix_comment_limit" in overrides:
164
+ self.fix_comment_limit = coerce_positive_int(overrides.get("fix_comment_limit"), default=defaults["fix_comment_limit"])
165
+ if "codex_update_limit" in overrides:
166
+ self.codex_update_limit = coerce_positive_int(overrides.get("codex_update_limit"), default=defaults["codex_update_limit"])
167
+ if "fixpr_limit" in overrides:
168
+ self.fixpr_limit = coerce_positive_int(overrides.get("fixpr_limit"), default=defaults["fixpr_limit"])
169
+
138
170
  def _load_state_from_files(self):
139
171
  """Load state from files into memory cache"""
140
172
  with self.lock:
@@ -149,6 +181,10 @@ class AutomationSafetyManager:
149
181
  inflight_data = self._read_json_file(self.inflight_file)
150
182
  self._pr_inflight_cache = {k: int(v) for k, v in inflight_data.items()}
151
183
 
184
+ # Load PR limit overrides
185
+ overrides_data = self._read_json_file(self.pr_overrides_file)
186
+ self._pr_overrides_cache = {k: int(v) for k, v in overrides_data.items()}
187
+
152
188
  def _sync_state_to_files(self):
153
189
  """Sync in-memory state to files"""
154
190
  with self.lock:
@@ -167,7 +203,7 @@ class AutomationSafetyManager:
167
203
  """Create a labeled key for PR attempt tracking."""
168
204
 
169
205
  repo_part = f"r={repo or ''}"
170
- pr_part = f"p={str(pr_number)}"
206
+ pr_part = f"p={pr_number!s}"
171
207
  branch_part = f"b={branch or ''}"
172
208
  return "||".join((repo_part, pr_part, branch_part))
173
209
 
@@ -286,8 +322,7 @@ class AutomationSafetyManager:
286
322
  except (TypeError, ValueError):
287
323
  total_runs = 0
288
324
 
289
- if total_runs < 0:
290
- total_runs = 0
325
+ total_runs = max(total_runs, 0)
291
326
 
292
327
  data["total_runs"] = total_runs
293
328
 
@@ -317,7 +352,14 @@ class AutomationSafetyManager:
317
352
  return data, total_runs, is_stale
318
353
 
319
354
  def can_process_pr(self, pr_number: Union[int, str], repo: str = None, branch: str = None) -> bool:
320
- """Check if PR can be processed (under attempt limit)"""
355
+ """Check if PR can be processed (under attempt limit).
356
+
357
+ NEW BEHAVIOR: Counts ALL attempts (success + failure) against the limit.
358
+ Supports per-PR limit overrides (0 = unlimited).
359
+
360
+ Blocks if:
361
+ 1. Total attempts >= effective_pr_limit (respects overrides)
362
+ """
321
363
  with self.lock:
322
364
  raw_data = self._read_json_file(self.pr_attempts_file)
323
365
  self._pr_attempts_cache = self._normalize_pr_attempt_keys(raw_data)
@@ -325,20 +367,14 @@ class AutomationSafetyManager:
325
367
  pr_key = self._make_pr_key(pr_number, repo, branch)
326
368
  attempts = list(self._pr_attempts_cache.get(pr_key, []))
327
369
 
328
- # Check total attempts limit first
329
- if len(attempts) >= self.pr_limit:
330
- return False
370
+ # Get effective limit (checks for per-PR override)
371
+ effective_limit = self._get_effective_pr_limit(pr_number, repo, branch)
331
372
 
332
- # Count consecutive failures from latest attempts
333
- consecutive_failures = 0
334
- for attempt in reversed(attempts):
335
- if attempt.get("result") == "failure":
336
- consecutive_failures += 1
337
- else:
338
- break
373
+ # Count ALL attempts (not just failures)
374
+ total_attempts = len(attempts)
339
375
 
340
- # Also block if too many consecutive failures (earlier than total limit)
341
- return consecutive_failures < self.pr_limit
376
+ # Check against effective limit
377
+ return total_attempts < effective_limit
342
378
 
343
379
  def try_process_pr(self, pr_number: Union[int, str], repo: str = None, branch: str = None) -> bool:
344
380
  """Atomically reserve a processing slot for PR."""
@@ -373,18 +409,15 @@ class AutomationSafetyManager:
373
409
  self._write_json_file(self.inflight_file, self._pr_inflight_cache)
374
410
 
375
411
  def get_pr_attempts(self, pr_number: Union[int, str], repo: str = None, branch: str = None):
376
- """Get count of consecutive failures for a specific PR."""
412
+ """Get count of ALL attempts (success + failure) for a specific PR.
413
+
414
+ NEW BEHAVIOR: Counts ALL attempts, not just consecutive failures.
415
+ """
377
416
  with self.lock:
378
417
  pr_key = self._make_pr_key(pr_number, repo, branch)
379
418
  attempts = list(self._pr_attempts_cache.get(pr_key, []))
380
-
381
- failure_count = 0
382
- for attempt in reversed(attempts):
383
- if attempt.get("result") == "failure":
384
- failure_count += 1
385
- else:
386
- break
387
- return failure_count
419
+ # Return total count of ALL attempts
420
+ return len(attempts)
388
421
 
389
422
  def get_pr_attempt_list(self, pr_number: Union[int, str], repo: str = None, branch: str = None):
390
423
  """Get list of attempts for a specific PR (for detailed analysis)"""
@@ -517,8 +550,7 @@ class AutomationSafetyManager:
517
550
  approval_date = REAL_DATETIME.fromisoformat(approval_date_str)
518
551
  except (TypeError, ValueError):
519
552
  return False
520
- approval_hours = get_automation_limits()['approval_hours']
521
- expiry = approval_date + timedelta(hours=approval_hours)
553
+ expiry = approval_date + timedelta(hours=self.approval_hours)
522
554
 
523
555
  return datetime.now() < expiry
524
556
 
@@ -527,19 +559,26 @@ class AutomationSafetyManager:
527
559
  notifications_sent = []
528
560
 
529
561
  with self.lock:
530
- # Check for PR limits reached
562
+ # Check for PR limits reached (count ALL attempts)
531
563
  for pr_key, attempts in self._pr_attempts_cache.items():
532
- if len(attempts) >= self.pr_limit:
564
+ total_attempts = len(attempts) # Count ALL attempts (success + failure)
565
+ effective_pr_limit = self.pr_limit
566
+ override = self._pr_overrides_cache.get(pr_key)
567
+ if override is not None:
568
+ # 0 means unlimited
569
+ effective_pr_limit = sys.maxsize if override == 0 else override
570
+
571
+ if total_attempts >= effective_pr_limit:
533
572
  self._send_limit_notification(
534
- f"PR Automation Limit Reached",
535
- f"PR {pr_key} has reached the maximum attempt limit of {self.pr_limit}."
573
+ "PR Automation Attempt Limit Reached",
574
+ f"PR {pr_key} has reached the maximum limit of {effective_pr_limit} total attempts."
536
575
  )
537
576
  notifications_sent.append(f"PR {pr_key}")
538
577
 
539
578
  # Check for global limit reached
540
579
  if self._global_runs_cache >= self.global_limit:
541
580
  self._send_limit_notification(
542
- f"Global Automation Limit Reached",
581
+ "Global Automation Limit Reached",
543
582
  f"Global automation runs have reached the maximum limit of {self.global_limit}."
544
583
  )
545
584
  notifications_sent.append("Global limit")
@@ -585,9 +624,9 @@ class AutomationSafetyManager:
585
624
  password = None
586
625
 
587
626
  if username is None:
588
- username = os.environ.get('SMTP_USERNAME') or os.environ.get('EMAIL_USER')
627
+ username = os.environ.get("SMTP_USERNAME") or os.environ.get("EMAIL_USER")
589
628
  if password is None:
590
- password = os.environ.get('SMTP_PASSWORD') or os.environ.get('EMAIL_PASS')
629
+ password = os.environ.get("SMTP_PASSWORD") or os.environ.get("EMAIL_PASS")
591
630
 
592
631
  return username, password
593
632
 
@@ -595,20 +634,20 @@ class AutomationSafetyManager:
595
634
  """Send email notification with secure credential handling"""
596
635
  try:
597
636
  # Load email configuration
598
- smtp_server = os.environ.get('SMTP_SERVER', 'smtp.gmail.com')
599
- smtp_port = int(os.environ.get('SMTP_PORT', '587'))
637
+ smtp_server = os.environ.get("SMTP_SERVER", "smtp.gmail.com")
638
+ smtp_port = int(os.environ.get("SMTP_PORT", "587"))
600
639
  username, password = self._get_smtp_credentials()
601
- to_email = os.environ.get('EMAIL_TO')
602
- from_email = os.environ.get('EMAIL_FROM') or username
640
+ to_email = os.environ.get("EMAIL_TO")
641
+ from_email = os.environ.get("EMAIL_FROM") or username
603
642
 
604
643
  if not (username and password and to_email and from_email):
605
644
  self.logger.info("Email configuration incomplete - skipping notification")
606
645
  return False
607
646
 
608
647
  msg = MIMEMultipart()
609
- msg['From'] = from_email
610
- msg['To'] = to_email
611
- msg['Subject'] = f"[WorldArchitect Automation] {subject}"
648
+ msg["From"] = from_email
649
+ msg["To"] = to_email
650
+ msg["Subject"] = f"[WorldArchitect Automation] {subject}"
612
651
 
613
652
  body = f"""
614
653
  {message}
@@ -619,10 +658,10 @@ System: PR Automation Safety Manager
619
658
  This is an automated notification from the WorldArchitect.AI automation system.
620
659
  """
621
660
 
622
- msg.attach(MIMEText(body, 'plain'))
661
+ msg.attach(MIMEText(body, "plain"))
623
662
 
624
- # Connect and send email
625
- server = smtplib.SMTP(smtp_server, smtp_port)
663
+ # Connect and send email with 30s timeout (consistent with automation_utils.py)
664
+ server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
626
665
  try:
627
666
  server.ehlo()
628
667
  server.starttls()
@@ -673,13 +712,13 @@ This is an automated notification from the WorldArchitect.AI automation system.
673
712
  def load_config(self, config_file: str) -> dict:
674
713
  """Load configuration from file"""
675
714
  try:
676
- with open(config_file, 'r') as f:
715
+ with open(config_file) as f:
677
716
  config = json.load(f)
678
717
  # Update limits from config
679
- if 'pr_limit' in config:
680
- self.pr_limit = config['pr_limit']
681
- if 'global_limit' in config:
682
- self.global_limit = config['global_limit']
718
+ if "pr_limit" in config:
719
+ self.pr_limit = config["pr_limit"]
720
+ if "global_limit" in config:
721
+ self.global_limit = config["global_limit"]
683
722
  return config
684
723
  except (FileNotFoundError, json.JSONDecodeError):
685
724
  return {}
@@ -691,7 +730,7 @@ This is an automated notification from the WorldArchitect.AI automation system.
691
730
  def has_email_config(self) -> bool:
692
731
  """Check if email configuration is available"""
693
732
  try:
694
- smtp_server = os.environ.get('SMTP_SERVER')
733
+ smtp_server = os.environ.get("SMTP_SERVER")
695
734
  username, password = self._get_smtp_credentials()
696
735
  return bool(smtp_server and username and password)
697
736
  except Exception:
@@ -707,37 +746,172 @@ This is an automated notification from the WorldArchitect.AI automation system.
707
746
  def _is_email_configured(self) -> bool:
708
747
  """Check if email configuration is complete"""
709
748
  try:
710
- smtp_server = os.environ.get('SMTP_SERVER')
711
- smtp_port = os.environ.get('SMTP_PORT')
712
- email_to = os.environ.get('EMAIL_TO')
749
+ smtp_server = os.environ.get("SMTP_SERVER")
750
+ smtp_port = os.environ.get("SMTP_PORT")
751
+ email_to = os.environ.get("EMAIL_TO")
713
752
  username, password = self._get_smtp_credentials()
714
753
  return bool(smtp_server and smtp_port and email_to and username and password)
715
754
  except Exception:
716
755
  return False
717
756
 
757
+ def _get_effective_pr_limit(self, pr_number: Union[int, str], repo: str = None, branch: str = None) -> int:
758
+ """Get effective PR limit for a specific PR (returns override if set, else default).
759
+
760
+ Args:
761
+ pr_number: PR number
762
+ repo: Repository name
763
+ branch: Branch name
764
+
765
+ Returns:
766
+ Effective limit (0 means unlimited, returns sys.maxsize)
767
+ """
768
+ with self.lock:
769
+ # Reload PR override file to stay in sync with CLI updates from other processes.
770
+ overrides_data = self._read_json_file(self.pr_overrides_file)
771
+ self._pr_overrides_cache = {k: int(v) for k, v in overrides_data.items()}
772
+
773
+ pr_key = self._make_pr_key(pr_number, repo, branch)
774
+ override = self._pr_overrides_cache.get(pr_key)
775
+
776
+ if override is not None:
777
+ # 0 means unlimited
778
+ return sys.maxsize if override == 0 else override
779
+ else:
780
+ return self.pr_limit
781
+
782
+ def clear_pr_attempts(self, pr_number: Union[int, str], repo: str = None, branch: str = None) -> bool:
783
+ """Clear all attempts for a specific PR.
784
+
785
+ Args:
786
+ pr_number: PR number
787
+ repo: Repository name
788
+ branch: Branch name
789
+
790
+ Returns:
791
+ True if successful
792
+ """
793
+ with self.lock:
794
+ pr_key = self._make_pr_key(pr_number, repo, branch)
795
+
796
+ # Clear from cache
797
+ if pr_key in self._pr_attempts_cache:
798
+ del self._pr_attempts_cache[pr_key]
799
+
800
+ # Clear from inflight cache
801
+ if pr_key in self._pr_inflight_cache:
802
+ del self._pr_inflight_cache[pr_key]
803
+
804
+ # Persist to disk
805
+ self._write_json_file(self.pr_attempts_file, self._pr_attempts_cache)
806
+ self._write_json_file(self.inflight_file, self._pr_inflight_cache)
807
+
808
+ self.logger.info(f"✅ Cleared all attempts for PR {pr_key}")
809
+ return True
810
+
811
+ def set_pr_limit_override(self, pr_number: Union[int, str], limit: int, repo: str = None, branch: str = None) -> bool:
812
+ """Set custom limit for a specific PR (0 = unlimited).
813
+
814
+ Args:
815
+ pr_number: PR number
816
+ limit: Custom limit (0 = unlimited)
817
+ repo: Repository name
818
+ branch: Branch name
819
+
820
+ Returns:
821
+ True if successful
822
+ """
823
+ with self.lock:
824
+ pr_key = self._make_pr_key(pr_number, repo, branch)
825
+
826
+ # Validate limit
827
+ if limit < 0:
828
+ self.logger.error(f"❌ Invalid limit {limit} - must be >= 0")
829
+ return False
830
+
831
+ # Update cache
832
+ self._pr_overrides_cache[pr_key] = limit
833
+
834
+ # Persist to disk
835
+ self._write_json_file(self.pr_overrides_file, self._pr_overrides_cache)
836
+
837
+ if limit == 0:
838
+ self.logger.info(f"✅ Set unlimited attempts for PR {pr_key}")
839
+ else:
840
+ self.logger.info(f"✅ Set custom limit {limit} for PR {pr_key}")
841
+ return True
842
+
843
+ def clear_pr_limit_override(self, pr_number: Union[int, str], repo: str = None, branch: str = None) -> bool:
844
+ """Remove limit override for a specific PR, revert to default.
845
+
846
+ Args:
847
+ pr_number: PR number
848
+ repo: Repository name
849
+ branch: Branch name
850
+
851
+ Returns:
852
+ True if successful
853
+ """
854
+ with self.lock:
855
+ pr_key = self._make_pr_key(pr_number, repo, branch)
856
+
857
+ # Remove from cache
858
+ if pr_key in self._pr_overrides_cache:
859
+ del self._pr_overrides_cache[pr_key]
860
+
861
+ # Persist to disk
862
+ self._write_json_file(self.pr_overrides_file, self._pr_overrides_cache)
863
+
864
+ self.logger.info(f"✅ Cleared limit override for PR {pr_key}, reverted to default ({self.pr_limit})")
865
+ return True
866
+
867
+ def get_pr_limit_override(self, pr_number: Union[int, str], repo: str = None, branch: str = None) -> Optional[int]:
868
+ """Get current limit override for a specific PR.
869
+
870
+ Args:
871
+ pr_number: PR number
872
+ repo: Repository name
873
+ branch: Branch name
874
+
875
+ Returns:
876
+ Override value if set, None otherwise (0 = unlimited)
877
+ """
878
+ with self.lock:
879
+ pr_key = self._make_pr_key(pr_number, repo, branch)
880
+ return self._pr_overrides_cache.get(pr_key)
881
+
718
882
 
719
883
  def main():
720
884
  """CLI interface for safety manager"""
721
885
 
722
- parser = argparse.ArgumentParser(description='Automation Safety Manager')
723
- parser.add_argument('--data-dir', default='/tmp/automation_safety',
724
- help='Directory for safety data files')
725
- parser.add_argument('--check-pr', type=int, metavar='PR_NUMBER',
726
- help='Check if PR can be processed')
727
- parser.add_argument('--record-pr', nargs=2, metavar=('PR_NUMBER', 'RESULT'),
728
- help='Record PR attempt (result: success|failure)')
729
- parser.add_argument('--repo', type=str,
730
- help='Repository name (owner/repo) for PR attempt operations')
731
- parser.add_argument('--branch', type=str,
732
- help='Branch name for PR attempt tracking')
733
- parser.add_argument('--check-global', action='store_true',
734
- help='Check if global run can start')
735
- parser.add_argument('--record-global', action='store_true',
736
- help='Record global run')
737
- parser.add_argument('--manual_override', type=str, metavar='EMAIL',
738
- help='Grant manual override (emergency use only)')
739
- parser.add_argument('--status', action='store_true',
740
- help='Show current status')
886
+ parser = argparse.ArgumentParser(description="Automation Safety Manager")
887
+ parser.add_argument("--data-dir", default="/tmp/automation_safety",
888
+ help="Directory for safety data files")
889
+ parser.add_argument("--check-pr", type=int, metavar="PR_NUMBER",
890
+ help="Check if PR can be processed")
891
+ parser.add_argument("--record-pr", nargs=2, metavar=("PR_NUMBER", "RESULT"),
892
+ help="Record PR attempt (result: success|failure)")
893
+ parser.add_argument("--repo", type=str,
894
+ help="Repository name (owner/repo) for PR attempt operations")
895
+ parser.add_argument("--branch", type=str,
896
+ help="Branch name for PR attempt tracking")
897
+ parser.add_argument("--check-global", action="store_true",
898
+ help="Check if global run can start")
899
+ parser.add_argument("--record-global", action="store_true",
900
+ help="Record global run")
901
+ parser.add_argument("--manual_override", type=str, metavar="EMAIL",
902
+ help="Grant manual override (emergency use only)")
903
+ parser.add_argument("--status", action="store_true",
904
+ help="Show current status")
905
+
906
+ # PR limit override arguments
907
+ parser.add_argument("--clear-pr", type=int, metavar="PR_NUMBER",
908
+ help="Clear all attempts for a specific PR")
909
+ parser.add_argument("--set-pr-limit", nargs=2, type=int, metavar=("PR_NUMBER", "LIMIT"),
910
+ help="Set custom limit for a specific PR (0 = unlimited)")
911
+ parser.add_argument("--clear-pr-limit", type=int, metavar="PR_NUMBER",
912
+ help="Remove limit override for a specific PR")
913
+ parser.add_argument("--get-pr-limit", type=int, metavar="PR_NUMBER",
914
+ help="Get current limit override for a specific PR")
741
915
 
742
916
  args = parser.parse_args()
743
917
 
@@ -822,9 +996,46 @@ def main():
822
996
  else:
823
997
  print("No PR attempts recorded")
824
998
 
999
+ elif args.clear_pr:
1000
+ manager.clear_pr_attempts(args.clear_pr, repo=args.repo, branch=args.branch)
1001
+ repo_label = f" ({args.repo})" if args.repo else ""
1002
+ branch_label = f" [{args.branch}]" if args.branch else ""
1003
+ print(f"✅ Cleared all attempts for PR #{args.clear_pr}{repo_label}{branch_label}")
1004
+
1005
+ elif args.set_pr_limit:
1006
+ pr_number, limit = args.set_pr_limit
1007
+ repo_label = f" ({args.repo})" if args.repo else ""
1008
+ branch_label = f" [{args.branch}]" if args.branch else ""
1009
+ success = manager.set_pr_limit_override(pr_number, limit, repo=args.repo, branch=args.branch)
1010
+ if not success:
1011
+ print(f"❌ Failed to set PR limit override for PR #{pr_number}{repo_label}{branch_label}")
1012
+ sys.exit(1)
1013
+ if limit == 0:
1014
+ print(f"✅ Set unlimited attempts for PR #{pr_number}{repo_label}{branch_label}")
1015
+ else:
1016
+ print(f"✅ Set custom limit {limit} for PR #{pr_number}{repo_label}{branch_label}")
1017
+
1018
+ elif args.clear_pr_limit:
1019
+ manager.clear_pr_limit_override(args.clear_pr_limit, repo=args.repo, branch=args.branch)
1020
+ repo_label = f" ({args.repo})" if args.repo else ""
1021
+ branch_label = f" [{args.branch}]" if args.branch else ""
1022
+ print(f"✅ Cleared limit override for PR #{args.clear_pr_limit}{repo_label}{branch_label}, reverted to default ({manager.pr_limit})")
1023
+
1024
+ elif args.get_pr_limit:
1025
+ override = manager.get_pr_limit_override(args.get_pr_limit, repo=args.repo, branch=args.branch)
1026
+ repo_label = f" ({args.repo})" if args.repo else ""
1027
+ branch_label = f" [{args.branch}]" if args.branch else ""
1028
+ if override is not None:
1029
+ if override == 0:
1030
+ print(f"PR #{args.get_pr_limit}{repo_label}{branch_label}: unlimited attempts (override)")
1031
+ else:
1032
+ print(f"PR #{args.get_pr_limit}{repo_label}{branch_label}: {override} attempts (override)")
1033
+ else:
1034
+ print(f"PR #{args.get_pr_limit}{repo_label}{branch_label}: {manager.pr_limit} attempts (default)")
1035
+
825
1036
  else:
826
1037
  parser.print_help()
827
1038
 
828
1039
 
829
- if __name__ == '__main__':
1040
+ if __name__ == "__main__":
830
1041
  main()