jleechanorg-pr-automation 0.1.0__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.
Potentially problematic release.
This version of jleechanorg-pr-automation might be problematic. Click here for more details.
- jleechanorg_pr_automation/__init__.py +32 -0
- jleechanorg_pr_automation/automation_safety_manager.py +700 -0
- jleechanorg_pr_automation/automation_safety_wrapper.py +116 -0
- jleechanorg_pr_automation/automation_utils.py +314 -0
- jleechanorg_pr_automation/check_codex_comment.py +76 -0
- jleechanorg_pr_automation/codex_branch_updater.py +272 -0
- jleechanorg_pr_automation/codex_config.py +57 -0
- jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1202 -0
- jleechanorg_pr_automation/tests/conftest.py +12 -0
- jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +221 -0
- jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +147 -0
- jleechanorg_pr_automation/tests/test_automation_safety_limits.py +340 -0
- jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +615 -0
- jleechanorg_pr_automation/tests/test_codex_actor_matching.py +137 -0
- jleechanorg_pr_automation/tests/test_graphql_error_handling.py +155 -0
- jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +473 -0
- jleechanorg_pr_automation/tests/test_pr_targeting.py +95 -0
- jleechanorg_pr_automation/utils.py +232 -0
- jleechanorg_pr_automation-0.1.0.dist-info/METADATA +217 -0
- jleechanorg_pr_automation-0.1.0.dist-info/RECORD +23 -0
- jleechanorg_pr_automation-0.1.0.dist-info/WHEEL +5 -0
- jleechanorg_pr_automation-0.1.0.dist-info/entry_points.txt +3 -0
- jleechanorg_pr_automation-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Automation Safety Wrapper for launchd
|
|
4
|
+
|
|
5
|
+
This wrapper enforces safety limits before running PR automation:
|
|
6
|
+
- Max 5 attempts per PR
|
|
7
|
+
- Max 50 total automation runs before requiring manual approval
|
|
8
|
+
- Email notifications when limits are reached
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from .automation_safety_manager import AutomationSafetyManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup_logging() -> logging.Logger:
|
|
20
|
+
"""Set up logging for automation wrapper"""
|
|
21
|
+
log_dir = Path.home() / "Library" / "Logs" / "worldarchitect-automation"
|
|
22
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=logging.INFO,
|
|
26
|
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
27
|
+
handlers=[
|
|
28
|
+
logging.FileHandler(log_dir / "automation_safety.log"),
|
|
29
|
+
logging.StreamHandler()
|
|
30
|
+
]
|
|
31
|
+
)
|
|
32
|
+
return logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> int:
|
|
36
|
+
"""Main wrapper function with safety checks"""
|
|
37
|
+
logger = setup_logging()
|
|
38
|
+
logger.info("🛡️ Starting automation safety wrapper")
|
|
39
|
+
|
|
40
|
+
# Data directory for safety tracking
|
|
41
|
+
data_dir = Path.home() / "Library" / "Application Support" / "worldarchitect-automation"
|
|
42
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# Initialize safety manager
|
|
45
|
+
manager = AutomationSafetyManager(str(data_dir))
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Check global run limits
|
|
49
|
+
if not manager.can_start_global_run():
|
|
50
|
+
logger.warning(f"🚫 Global automation limit reached ({manager.get_global_runs()}/{manager.global_limit} runs)")
|
|
51
|
+
|
|
52
|
+
if manager.requires_manual_approval() and not manager.has_manual_approval():
|
|
53
|
+
logger.error("❌ Manual approval required to continue automation")
|
|
54
|
+
logger.info("💡 To grant approval: automation-safety-cli --manual_override user@example.com")
|
|
55
|
+
|
|
56
|
+
# Send notification
|
|
57
|
+
manager.check_and_notify_limits()
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
logger.info(
|
|
61
|
+
f"📊 Global runs before execution: {manager.get_global_runs()}"
|
|
62
|
+
f"/{manager.global_limit}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info("🚀 Executing PR automation monitor: jleechanorg_pr_automation.jleechanorg_pr_monitor")
|
|
66
|
+
|
|
67
|
+
# Execute with environment variables for safety integration
|
|
68
|
+
env = os.environ.copy()
|
|
69
|
+
env['AUTOMATION_SAFETY_DATA_DIR'] = str(data_dir)
|
|
70
|
+
env['AUTOMATION_SAFETY_WRAPPER'] = '1'
|
|
71
|
+
|
|
72
|
+
# Record the global run *before* launching the monitor so the attempt
|
|
73
|
+
# is counted even if the subprocess fails to start or exits early.
|
|
74
|
+
manager.record_global_run()
|
|
75
|
+
logger.info(
|
|
76
|
+
f"📊 Recorded global run {manager.get_global_runs()}/"
|
|
77
|
+
f"{manager.global_limit} prior to monitor execution"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
[sys.executable, '-m', 'jleechanorg_pr_automation.jleechanorg_pr_monitor'],
|
|
82
|
+
env=env,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=3600,
|
|
86
|
+
shell=False,
|
|
87
|
+
) # 1 hour timeout
|
|
88
|
+
|
|
89
|
+
global_runs_after = manager.get_global_runs()
|
|
90
|
+
logger.info(f"📊 Global runs after execution: {global_runs_after}/{manager.global_limit}")
|
|
91
|
+
|
|
92
|
+
# Log results
|
|
93
|
+
if result.returncode == 0:
|
|
94
|
+
logger.info("✅ Automation completed successfully")
|
|
95
|
+
if result.stdout:
|
|
96
|
+
logger.info(f"Output: {result.stdout.strip()}")
|
|
97
|
+
else:
|
|
98
|
+
logger.error(f"❌ Automation failed with exit code {result.returncode}")
|
|
99
|
+
if result.stderr:
|
|
100
|
+
logger.error(f"Error: {result.stderr.strip()}")
|
|
101
|
+
|
|
102
|
+
return result.returncode
|
|
103
|
+
|
|
104
|
+
except subprocess.TimeoutExpired:
|
|
105
|
+
logger.error("⏰ Automation timed out after 1 hour")
|
|
106
|
+
return 124
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"💥 Unexpected error in safety wrapper: {e}")
|
|
109
|
+
return 1
|
|
110
|
+
finally:
|
|
111
|
+
# Check and notify about any limit violations
|
|
112
|
+
manager.check_and_notify_limits()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == '__main__':
|
|
116
|
+
sys.exit(main())
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared Automation Utilities Module
|
|
4
|
+
|
|
5
|
+
Consolidates common functionality used across automation components:
|
|
6
|
+
- Logging setup and configuration
|
|
7
|
+
- Configuration management (SMTP, paths, environment)
|
|
8
|
+
- Email notification system
|
|
9
|
+
- Subprocess execution with timeout and error handling
|
|
10
|
+
- File and directory management utilities
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import smtplib
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import fcntl
|
|
21
|
+
import subprocess
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Dict, Optional, Tuple, Any
|
|
25
|
+
from email.mime.text import MIMEText
|
|
26
|
+
from email.mime.multipart import MIMEMultipart
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import keyring
|
|
30
|
+
KEYRING_AVAILABLE = True
|
|
31
|
+
except ImportError:
|
|
32
|
+
KEYRING_AVAILABLE = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AutomationUtils:
|
|
36
|
+
"""Shared utilities for automation components"""
|
|
37
|
+
|
|
38
|
+
# Default configuration
|
|
39
|
+
DEFAULT_CONFIG = {
|
|
40
|
+
'SMTP_SERVER': 'smtp.gmail.com',
|
|
41
|
+
'SMTP_PORT': 587,
|
|
42
|
+
'LOG_DIR': '~/Library/Logs/worldarchitect-automation',
|
|
43
|
+
'DATA_DIR': '~/Library/Application Support/worldarchitect-automation',
|
|
44
|
+
'MAX_SUBPROCESS_TIMEOUT': int(os.getenv('AUTOMATION_SUBPROCESS_TIMEOUT', '300')), # 5 minutes (configurable)
|
|
45
|
+
'EMAIL_SUBJECT_PREFIX': '[WorldArchitect Automation]'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def setup_logging(cls, name: str, log_filename: str = None) -> logging.Logger:
|
|
50
|
+
"""Unified logging setup for all automation components
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
name: Logger name (typically __name__)
|
|
54
|
+
log_filename: Optional specific log filename, defaults to component name
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Configured logger instance
|
|
58
|
+
"""
|
|
59
|
+
if log_filename is None:
|
|
60
|
+
log_filename = f"{name.split('.')[-1]}.log"
|
|
61
|
+
|
|
62
|
+
# Create log directory
|
|
63
|
+
log_dir = Path(cls.DEFAULT_CONFIG['LOG_DIR']).expanduser()
|
|
64
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# Set up logging with consistent format
|
|
67
|
+
logging.basicConfig(
|
|
68
|
+
level=logging.INFO,
|
|
69
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
70
|
+
handlers=[
|
|
71
|
+
logging.FileHandler(log_dir / log_filename),
|
|
72
|
+
logging.StreamHandler()
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger(name)
|
|
77
|
+
logger.info(f"🛠️ Logging initialized - logs: {log_dir / log_filename}")
|
|
78
|
+
return logger
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def get_config_value(cls, key: str, default: Any = None) -> Any:
|
|
82
|
+
"""Get configuration value from environment or defaults"""
|
|
83
|
+
env_value = os.environ.get(key)
|
|
84
|
+
if env_value is not None:
|
|
85
|
+
# Try to convert to appropriate type
|
|
86
|
+
if isinstance(default, bool):
|
|
87
|
+
return env_value.lower() in ('true', '1', 'yes', 'on')
|
|
88
|
+
elif isinstance(default, int):
|
|
89
|
+
try:
|
|
90
|
+
return int(env_value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
pass
|
|
93
|
+
return env_value
|
|
94
|
+
return cls.DEFAULT_CONFIG.get(key, default)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def get_data_directory(cls, subdir: str = None) -> Path:
|
|
98
|
+
"""Get standardized data directory path"""
|
|
99
|
+
data_dir = Path(cls.DEFAULT_CONFIG['DATA_DIR']).expanduser()
|
|
100
|
+
if subdir:
|
|
101
|
+
data_dir = data_dir / subdir
|
|
102
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
return data_dir
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def get_smtp_credentials(cls) -> Tuple[Optional[str], Optional[str]]:
|
|
107
|
+
"""Securely get SMTP credentials from keyring or environment"""
|
|
108
|
+
username = None
|
|
109
|
+
password = None
|
|
110
|
+
|
|
111
|
+
if KEYRING_AVAILABLE:
|
|
112
|
+
try:
|
|
113
|
+
username = keyring.get_password("worldarchitect-automation", "smtp_username")
|
|
114
|
+
password = keyring.get_password("worldarchitect-automation", "smtp_password")
|
|
115
|
+
except Exception:
|
|
116
|
+
pass # Fall back to environment variables
|
|
117
|
+
|
|
118
|
+
# Fallback to environment variables if keyring fails or unavailable
|
|
119
|
+
if not username:
|
|
120
|
+
username = os.environ.get('SMTP_USERNAME')
|
|
121
|
+
if not password:
|
|
122
|
+
password = os.environ.get('SMTP_PASSWORD')
|
|
123
|
+
|
|
124
|
+
return username, password
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def send_email_notification(cls, subject: str, message: str,
|
|
128
|
+
to_email: str = None, from_email: str = None) -> bool:
|
|
129
|
+
"""Send email notification with unified error handling
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
subject: Email subject line
|
|
133
|
+
message: Email body content
|
|
134
|
+
to_email: Override recipient email
|
|
135
|
+
from_email: Override sender email
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if email sent successfully, False otherwise
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
# Get SMTP configuration
|
|
142
|
+
smtp_server = cls.get_config_value('SMTP_SERVER')
|
|
143
|
+
smtp_port = cls.get_config_value('SMTP_PORT')
|
|
144
|
+
username, password = cls.get_smtp_credentials()
|
|
145
|
+
|
|
146
|
+
# Get email addresses
|
|
147
|
+
from_email = from_email or os.environ.get('MEMORY_EMAIL_FROM')
|
|
148
|
+
to_email = to_email or os.environ.get('MEMORY_EMAIL_TO')
|
|
149
|
+
|
|
150
|
+
if not all([username, password, from_email, to_email]):
|
|
151
|
+
print("Email configuration incomplete - skipping notification")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
# Build email message
|
|
155
|
+
msg = MIMEMultipart()
|
|
156
|
+
msg['From'] = from_email
|
|
157
|
+
msg['To'] = to_email
|
|
158
|
+
msg['Subject'] = f"{cls.DEFAULT_CONFIG['EMAIL_SUBJECT_PREFIX']} {subject}"
|
|
159
|
+
|
|
160
|
+
# Add timestamp to message
|
|
161
|
+
full_message = f"""{message}
|
|
162
|
+
|
|
163
|
+
Time: {datetime.now().isoformat()}
|
|
164
|
+
System: WorldArchitect Automation
|
|
165
|
+
|
|
166
|
+
This is an automated notification from the WorldArchitect.AI automation system."""
|
|
167
|
+
|
|
168
|
+
msg.attach(MIMEText(full_message, 'plain'))
|
|
169
|
+
|
|
170
|
+
# Send email with timeout and proper error handling
|
|
171
|
+
with smtplib.SMTP(smtp_server, smtp_port, timeout=30) as server:
|
|
172
|
+
server.starttls()
|
|
173
|
+
server.login(username, password)
|
|
174
|
+
server.send_message(msg)
|
|
175
|
+
|
|
176
|
+
print(f"✅ Email notification sent: {subject}")
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
except smtplib.SMTPAuthenticationError as e:
|
|
180
|
+
print(f"❌ SMTP authentication failed: {e}")
|
|
181
|
+
except smtplib.SMTPRecipientsRefused as e:
|
|
182
|
+
print(f"❌ Email recipients refused: {e}")
|
|
183
|
+
except smtplib.SMTPException as e:
|
|
184
|
+
print(f"❌ SMTP error: {e}")
|
|
185
|
+
except OSError as e:
|
|
186
|
+
print(f"❌ Network error: {e}")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f"❌ Unexpected email error: {e}")
|
|
189
|
+
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def execute_subprocess_with_timeout(cls, command: list, timeout: int = None,
|
|
194
|
+
cwd: str = None, capture_output: bool = True,
|
|
195
|
+
check: bool = True) -> subprocess.CompletedProcess:
|
|
196
|
+
"""Execute subprocess with standardized timeout and error handling
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
command: Command to execute as list
|
|
200
|
+
timeout: Timeout in seconds (uses default if None)
|
|
201
|
+
cwd: Working directory
|
|
202
|
+
capture_output: Whether to capture stdout/stderr
|
|
203
|
+
check: Whether to raise CalledProcessError on non-zero exit (default True)
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
CompletedProcess instance
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
subprocess.TimeoutExpired: If command times out
|
|
210
|
+
subprocess.CalledProcessError: If command fails and check=True
|
|
211
|
+
"""
|
|
212
|
+
if timeout is None:
|
|
213
|
+
timeout = cls.get_config_value('MAX_SUBPROCESS_TIMEOUT')
|
|
214
|
+
|
|
215
|
+
# Ensure shell=False for security, check parameter controls error handling
|
|
216
|
+
result = subprocess.run(
|
|
217
|
+
command,
|
|
218
|
+
timeout=timeout,
|
|
219
|
+
cwd=cwd,
|
|
220
|
+
capture_output=capture_output,
|
|
221
|
+
text=True,
|
|
222
|
+
shell=False,
|
|
223
|
+
check=check
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def safe_read_json(cls, file_path: Path) -> dict:
|
|
230
|
+
"""Safely read JSON file with file locking"""
|
|
231
|
+
try:
|
|
232
|
+
with open(file_path, 'r') as f:
|
|
233
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_SH) # Shared lock for reading
|
|
234
|
+
data = json.load(f)
|
|
235
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN) # Unlock
|
|
236
|
+
return data
|
|
237
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
238
|
+
return {}
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def safe_write_json(cls, file_path: Path, data: dict):
|
|
242
|
+
"""Atomically write JSON file with file locking"""
|
|
243
|
+
# Use system temp directory for better security
|
|
244
|
+
temp_path = None
|
|
245
|
+
try:
|
|
246
|
+
# Create temporary file securely
|
|
247
|
+
with tempfile.NamedTemporaryFile(
|
|
248
|
+
mode='w',
|
|
249
|
+
suffix=".tmp",
|
|
250
|
+
delete=False
|
|
251
|
+
) as temp_file:
|
|
252
|
+
fcntl.flock(temp_file.fileno(), fcntl.LOCK_EX) # Exclusive lock for writing
|
|
253
|
+
json.dump(data, temp_file, indent=2)
|
|
254
|
+
temp_file.flush()
|
|
255
|
+
os.fsync(temp_file.fileno()) # Force write to disk
|
|
256
|
+
fcntl.flock(temp_file.fileno(), fcntl.LOCK_UN) # Unlock
|
|
257
|
+
temp_path = temp_file.name
|
|
258
|
+
|
|
259
|
+
# Set restrictive permissions before moving
|
|
260
|
+
os.chmod(temp_path, 0o600) # Owner read/write only
|
|
261
|
+
|
|
262
|
+
# Atomic rename - this operation is atomic on POSIX systems
|
|
263
|
+
os.rename(temp_path, file_path)
|
|
264
|
+
temp_path = None # Successful, don't clean up
|
|
265
|
+
|
|
266
|
+
except (OSError, IOError, json.JSONEncodeError) as e:
|
|
267
|
+
# Clean up temp file on error
|
|
268
|
+
if temp_path and os.path.exists(temp_path):
|
|
269
|
+
try:
|
|
270
|
+
os.unlink(temp_path)
|
|
271
|
+
except OSError:
|
|
272
|
+
pass # Best effort cleanup
|
|
273
|
+
raise RuntimeError(f"Failed to write JSON file {file_path}: {e}") from e
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def get_memory_config(cls) -> Dict[str, str]:
|
|
277
|
+
"""Load memory email configuration (for backward compatibility)"""
|
|
278
|
+
config = {}
|
|
279
|
+
config_file = Path.home() / ".memory_email_config"
|
|
280
|
+
|
|
281
|
+
if config_file.exists():
|
|
282
|
+
try:
|
|
283
|
+
# Source the bash config file by running it and capturing environment
|
|
284
|
+
result = cls.execute_subprocess_with_timeout(
|
|
285
|
+
['bash', '-c', f'source {config_file} && env'],
|
|
286
|
+
timeout=10
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
for line in result.stdout.splitlines():
|
|
290
|
+
if '=' in line:
|
|
291
|
+
key, value = line.split('=', 1)
|
|
292
|
+
config[key] = value
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print(f"Warning: Could not load memory config: {e}")
|
|
296
|
+
|
|
297
|
+
return config
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# Convenience functions for backward compatibility
|
|
301
|
+
def setup_logging(name: str, log_filename: str = None) -> logging.Logger:
|
|
302
|
+
"""Convenience function for setup_logging"""
|
|
303
|
+
return AutomationUtils.setup_logging(name, log_filename)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def send_email_notification(subject: str, message: str, to_email: str = None, from_email: str = None) -> bool:
|
|
307
|
+
"""Convenience function for send_email_notification"""
|
|
308
|
+
return AutomationUtils.send_email_notification(subject, message, to_email, from_email)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def execute_subprocess_with_timeout(command: list, timeout: int = None,
|
|
312
|
+
cwd: str = None, capture_output: bool = True) -> subprocess.CompletedProcess:
|
|
313
|
+
"""Convenience function for execute_subprocess_with_timeout"""
|
|
314
|
+
return AutomationUtils.execute_subprocess_with_timeout(command, timeout, cwd, capture_output)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Determine whether a Codex instruction comment should be posted."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
|
|
10
|
+
# Safety limits for JSON parsing to prevent memory exhaustion
|
|
11
|
+
MAX_JSON_SIZE = 10 * 1024 * 1024 # 10MB limit for PR data
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def decide(marker_prefix: str, marker_suffix: str) -> Tuple[str, str]:
|
|
15
|
+
try:
|
|
16
|
+
# Read stdin with size limit to prevent memory exhaustion
|
|
17
|
+
stdin_data = sys.stdin.read(MAX_JSON_SIZE)
|
|
18
|
+
if len(stdin_data) >= MAX_JSON_SIZE:
|
|
19
|
+
sys.stderr.write("ERROR: PR data exceeds maximum size limit\n")
|
|
20
|
+
return "post", ""
|
|
21
|
+
|
|
22
|
+
pr_data = json.loads(stdin_data)
|
|
23
|
+
except json.JSONDecodeError:
|
|
24
|
+
return "post", ""
|
|
25
|
+
|
|
26
|
+
head_sha = pr_data.get("headRefOid") or ""
|
|
27
|
+
|
|
28
|
+
# Handle GitHub API comments structure variations
|
|
29
|
+
comments_data = pr_data.get("comments", [])
|
|
30
|
+
if isinstance(comments_data, dict):
|
|
31
|
+
# GraphQL format: {"nodes": [...]}
|
|
32
|
+
comments = comments_data.get("nodes", [])
|
|
33
|
+
elif isinstance(comments_data, list):
|
|
34
|
+
# REST API format: [...]
|
|
35
|
+
comments = comments_data
|
|
36
|
+
else:
|
|
37
|
+
comments = []
|
|
38
|
+
|
|
39
|
+
if not head_sha:
|
|
40
|
+
return "post", ""
|
|
41
|
+
|
|
42
|
+
for comment in comments:
|
|
43
|
+
# Safely handle comment data that may not be a dictionary
|
|
44
|
+
if isinstance(comment, dict):
|
|
45
|
+
body = comment.get("body", "")
|
|
46
|
+
else:
|
|
47
|
+
# Skip malformed comment data
|
|
48
|
+
continue
|
|
49
|
+
prefix_index = body.find(marker_prefix)
|
|
50
|
+
if prefix_index == -1:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
start_index = prefix_index + len(marker_prefix)
|
|
54
|
+
end_index = body.find(marker_suffix, start_index)
|
|
55
|
+
if end_index == -1:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
marker_sha = body[start_index:end_index].strip()
|
|
59
|
+
if marker_sha == head_sha:
|
|
60
|
+
return "skip", head_sha
|
|
61
|
+
|
|
62
|
+
return "post", head_sha
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> int:
|
|
66
|
+
if len(sys.argv) != 3:
|
|
67
|
+
sys.stderr.write("Usage: check_codex_comment.py <marker_prefix> <marker_suffix>\n")
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
action, sha = decide(sys.argv[1], sys.argv[2])
|
|
71
|
+
sys.stdout.write(f"{action}:{sha}\n")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
raise SystemExit(main())
|