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.
- jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
- jleechanorg_pr_automation/__init__.py +64 -9
- jleechanorg_pr_automation/automation_safety_manager.py +306 -95
- jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
- jleechanorg_pr_automation/automation_utils.py +87 -65
- jleechanorg_pr_automation/check_codex_comment.py +7 -1
- jleechanorg_pr_automation/codex_branch_updater.py +21 -9
- jleechanorg_pr_automation/codex_config.py +70 -3
- jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
- jleechanorg_pr_automation/logging_utils.py +86 -0
- jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
- jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
- jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
- jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
- jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
- jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
- jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
- jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
- jleechanorg_pr_automation/tests/__init__.py +0 -0
- jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
- jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
- jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
- jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
- jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
- jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
- jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
- jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
- jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
- jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
- jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
- jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
- jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
- jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
- jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
- jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
- jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
- jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
- jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
- jleechanorg_pr_automation/utils.py +81 -56
- jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
- jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
- jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
- jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
- {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
- {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
#
|
|
64
|
-
|
|
65
|
-
self.pr_limit =
|
|
66
|
-
self.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
|
|
131
|
+
with open(self.config_file) as f:
|
|
122
132
|
config = json.load(f)
|
|
123
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
#
|
|
329
|
-
|
|
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
|
|
333
|
-
|
|
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
|
-
#
|
|
341
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
f"PR {pr_key} has reached the maximum
|
|
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
|
-
|
|
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(
|
|
627
|
+
username = os.environ.get("SMTP_USERNAME") or os.environ.get("EMAIL_USER")
|
|
589
628
|
if password is None:
|
|
590
|
-
password = os.environ.get(
|
|
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(
|
|
599
|
-
smtp_port = int(os.environ.get(
|
|
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(
|
|
602
|
-
from_email = os.environ.get(
|
|
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[
|
|
610
|
-
msg[
|
|
611
|
-
msg[
|
|
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,
|
|
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
|
|
715
|
+
with open(config_file) as f:
|
|
677
716
|
config = json.load(f)
|
|
678
717
|
# Update limits from config
|
|
679
|
-
if
|
|
680
|
-
self.pr_limit = config[
|
|
681
|
-
if
|
|
682
|
-
self.global_limit = config[
|
|
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(
|
|
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(
|
|
711
|
-
smtp_port = os.environ.get(
|
|
712
|
-
email_to = os.environ.get(
|
|
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=
|
|
723
|
-
parser.add_argument(
|
|
724
|
-
help=
|
|
725
|
-
parser.add_argument(
|
|
726
|
-
help=
|
|
727
|
-
parser.add_argument(
|
|
728
|
-
help=
|
|
729
|
-
parser.add_argument(
|
|
730
|
-
help=
|
|
731
|
-
parser.add_argument(
|
|
732
|
-
help=
|
|
733
|
-
parser.add_argument(
|
|
734
|
-
help=
|
|
735
|
-
parser.add_argument(
|
|
736
|
-
help=
|
|
737
|
-
parser.add_argument(
|
|
738
|
-
help=
|
|
739
|
-
parser.add_argument(
|
|
740
|
-
help=
|
|
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__ ==
|
|
1040
|
+
if __name__ == "__main__":
|
|
830
1041
|
main()
|