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.

@@ -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())