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,340 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test-Driven Development for PR Automation Safety Limits
4
+
5
+ RED Phase: All tests should FAIL initially
6
+ - PR attempt limits (max 5 per PR)
7
+ - Global run limits (max 50 total)
8
+ - Manual approval requirement
9
+ """
10
+
11
+ import os
12
+ import unittest
13
+ import tempfile
14
+ import json
15
+ import shutil
16
+ from datetime import datetime, timedelta
17
+ from pathlib import Path
18
+ from unittest.mock import patch, MagicMock
19
+
20
+ from jleechanorg_pr_automation.automation_safety_manager import AutomationSafetyManager
21
+
22
+
23
+ class TestAutomationSafetyLimits(unittest.TestCase):
24
+ """Matrix testing for automation safety limits"""
25
+
26
+ def setUp(self):
27
+ """Set up test environment with temporary files"""
28
+ self.test_dir = tempfile.mkdtemp()
29
+ self.pr_attempts_file = os.path.join(self.test_dir, "pr_attempts.json")
30
+ self.global_runs_file = os.path.join(self.test_dir, "global_runs.json")
31
+ self.approval_file = os.path.join(self.test_dir, "manual_approval.json")
32
+
33
+ if hasattr(self, '_automation_manager'):
34
+ del self._automation_manager
35
+
36
+ # Initialize empty tracking files
37
+ with open(self.pr_attempts_file, 'w') as f:
38
+ json.dump({}, f)
39
+ with open(self.global_runs_file, 'w') as f:
40
+ json.dump({"total_runs": 0, "start_date": datetime.now().isoformat()}, f)
41
+ with open(self.approval_file, 'w') as f:
42
+ json.dump({"approved": False, "approval_date": None}, f)
43
+
44
+ def tearDown(self):
45
+ """Clean up test files"""
46
+ shutil.rmtree(self.test_dir)
47
+
48
+ # Matrix 1: PR Attempt Limits (5 attempts per PR)
49
+ def test_pr_attempt_limit_1_should_allow(self):
50
+ """RED: First attempt on PR #1001 should be allowed"""
51
+ # This test will FAIL initially - no implementation exists
52
+ result = self.automation_manager.can_process_pr(1001)
53
+ self.assertTrue(result)
54
+ self.assertEqual(self.automation_manager.get_pr_attempts(1001), 0)
55
+
56
+ def test_pr_attempt_limit_5_should_allow(self):
57
+ """RED: 5th attempt on PR #1001 should be allowed"""
58
+ # Set up 4 previous attempts
59
+ self.automation_manager.record_pr_attempt(1001, "failure")
60
+ self.automation_manager.record_pr_attempt(1001, "failure")
61
+ self.automation_manager.record_pr_attempt(1001, "failure")
62
+ self.automation_manager.record_pr_attempt(1001, "failure")
63
+
64
+ result = self.automation_manager.can_process_pr(1001)
65
+ self.assertTrue(result)
66
+ self.assertEqual(self.automation_manager.get_pr_attempts(1001), 4)
67
+
68
+ def test_pr_attempt_limit_6_should_block(self):
69
+ """RED: 6th attempt on PR #1001 should be blocked"""
70
+ # Set up 5 previous attempts (max limit reached)
71
+ for _ in range(5):
72
+ self.automation_manager.record_pr_attempt(1001, "failure")
73
+
74
+ result = self.automation_manager.can_process_pr(1001)
75
+ self.assertFalse(result)
76
+ self.assertEqual(self.automation_manager.get_pr_attempts(1001), 5)
77
+
78
+ def test_pr_attempt_success_resets_counter(self):
79
+ """RED: Successful PR attempt should reset counter"""
80
+ # Set up 3 failures then 1 success
81
+ self.automation_manager.record_pr_attempt(1001, "failure")
82
+ self.automation_manager.record_pr_attempt(1001, "failure")
83
+ self.automation_manager.record_pr_attempt(1001, "failure")
84
+ self.automation_manager.record_pr_attempt(1001, "success")
85
+
86
+ # Counter should reset, allowing new attempts
87
+ result = self.automation_manager.can_process_pr(1001)
88
+ self.assertTrue(result)
89
+ self.assertEqual(self.automation_manager.get_pr_attempts(1001), 0)
90
+
91
+ # Matrix 2: Global Run Limits (50 total runs)
92
+ def test_global_run_limit_1_should_allow(self):
93
+ """RED: First global run should be allowed"""
94
+ result = self.automation_manager.can_start_global_run()
95
+ self.assertTrue(result)
96
+ self.assertEqual(self.automation_manager.get_global_runs(), 0)
97
+
98
+ def test_global_run_limit_50_should_allow(self):
99
+ """RED: 50th global run should be allowed"""
100
+ # Set up 49 previous runs
101
+ for i in range(49):
102
+ self.automation_manager.record_global_run()
103
+
104
+ result = self.automation_manager.can_start_global_run()
105
+ self.assertTrue(result)
106
+ self.assertEqual(self.automation_manager.get_global_runs(), 49)
107
+
108
+ def test_global_run_limit_51_should_block(self):
109
+ """RED: 51st global run should be blocked without approval"""
110
+ # Set up 50 previous runs (max limit reached)
111
+ for i in range(50):
112
+ self.automation_manager.record_global_run()
113
+
114
+ result = self.automation_manager.can_start_global_run()
115
+ self.assertFalse(result)
116
+ self.assertEqual(self.automation_manager.get_global_runs(), 50)
117
+
118
+ # Matrix 3: Manual Approval System
119
+ def test_manual_approval_required_after_50_runs(self):
120
+ """RED: Manual approval should be required after 50 runs"""
121
+ # Set up 50 runs to trigger approval requirement
122
+ for i in range(50):
123
+ self.automation_manager.record_global_run()
124
+
125
+ # Should require approval
126
+ self.assertTrue(self.automation_manager.requires_manual_approval())
127
+ self.assertFalse(self.automation_manager.has_manual_approval())
128
+
129
+ def test_manual_approval_grants_additional_runs(self):
130
+ """RED: Manual approval should allow continuation beyond 50 runs"""
131
+ # Set up 50 runs
132
+ for i in range(50):
133
+ self.automation_manager.record_global_run()
134
+
135
+ # Grant manual approval
136
+ self.automation_manager.grant_manual_approval("user@example.com")
137
+
138
+ # Should now allow additional runs
139
+ self.assertTrue(self.automation_manager.can_start_global_run())
140
+ self.assertTrue(self.automation_manager.has_manual_approval())
141
+
142
+ def test_approval_expires_after_24_hours(self):
143
+ """RED: Manual approval should expire after 24 hours"""
144
+ # Set up approval 25 hours ago
145
+ old_time = datetime.now() - timedelta(hours=25)
146
+ self.automation_manager.grant_manual_approval("user@example.com", old_time)
147
+
148
+ # Approval should be expired
149
+ self.assertFalse(self.automation_manager.has_manual_approval())
150
+
151
+ # Matrix 4: Email Notification System
152
+ @patch.dict(os.environ, {
153
+ 'SMTP_SERVER': 'smtp.example.com',
154
+ 'SMTP_PORT': '587',
155
+ 'EMAIL_USER': 'test@example.com',
156
+ 'EMAIL_PASS': 'testpass',
157
+ 'EMAIL_TO': 'admin@example.com'
158
+ })
159
+ @patch('smtplib.SMTP')
160
+ def test_email_sent_when_pr_limit_reached(self, mock_smtp):
161
+ """RED: Email should be sent when PR reaches 5 attempts"""
162
+ # Set up 5 attempts to trigger notification
163
+ for _ in range(5):
164
+ self.automation_manager.record_pr_attempt(1001, "failure")
165
+
166
+ # Should trigger email
167
+ self.automation_manager.check_and_notify_limits()
168
+
169
+ # Verify email was sent
170
+ mock_smtp.assert_called_once()
171
+
172
+ @patch.dict(os.environ, {
173
+ 'SMTP_SERVER': 'smtp.example.com',
174
+ 'SMTP_PORT': '587',
175
+ 'EMAIL_USER': 'test@example.com',
176
+ 'EMAIL_PASS': 'testpass',
177
+ 'EMAIL_TO': 'admin@example.com'
178
+ })
179
+ @patch('smtplib.SMTP')
180
+ def test_email_sent_when_global_limit_reached(self, mock_smtp):
181
+ """RED: Email should be sent when global limit of 50 is reached"""
182
+ # Set up 50 runs to trigger notification
183
+ for i in range(50):
184
+ self.automation_manager.record_global_run()
185
+
186
+ # Should trigger email
187
+ self.automation_manager.check_and_notify_limits()
188
+
189
+ # Verify email was sent
190
+ mock_smtp.assert_called_once()
191
+
192
+ # Matrix 5: State Persistence
193
+ def test_pr_attempts_persist_across_restarts(self):
194
+ """RED: PR attempt counts should persist across automation restarts"""
195
+ # Record attempts
196
+ self.automation_manager.record_pr_attempt(1001, "failure")
197
+ self.automation_manager.record_pr_attempt(1001, "failure")
198
+
199
+ # Simulate restart by creating new manager instance
200
+ new_manager = AutomationSafetyManager(self.test_dir)
201
+
202
+ # Should maintain attempt count
203
+ self.assertEqual(new_manager.get_pr_attempts(1001), 2)
204
+
205
+ def test_global_runs_persist_across_restarts(self):
206
+ """RED: Global run count should persist across automation restarts"""
207
+ # Record runs
208
+ for i in range(10):
209
+ self.automation_manager.record_global_run()
210
+
211
+ # Simulate restart
212
+ new_manager = AutomationSafetyManager(self.test_dir)
213
+
214
+ # Should maintain run count
215
+ self.assertEqual(new_manager.get_global_runs(), 10)
216
+
217
+ # Matrix 6: Concurrent Access Safety
218
+ def test_concurrent_pr_attempts_thread_safe(self):
219
+ """RED: Concurrent PR attempts should be thread-safe"""
220
+ import threading
221
+ import time
222
+
223
+ # Create a single manager instance explicitly for this test
224
+ manager = AutomationSafetyManager(self.test_dir)
225
+ results = []
226
+
227
+ def attempt_pr():
228
+ result = manager.try_process_pr(1001)
229
+ results.append(result)
230
+
231
+ # Start 10 concurrent threads
232
+ threads = []
233
+ for _ in range(10):
234
+ t = threading.Thread(target=attempt_pr)
235
+ threads.append(t)
236
+ t.start()
237
+
238
+ # Wait for all threads
239
+ for t in threads:
240
+ t.join()
241
+
242
+ # Should have exactly 5 successful attempts (limit)
243
+ successful_attempts = sum(results)
244
+ self.assertEqual(successful_attempts, 5)
245
+
246
+ # Matrix 7: Configuration Management
247
+ def test_limits_configurable_via_environment(self):
248
+ """RED: Safety limits should be configurable via environment variables"""
249
+ with patch.dict(os.environ, {
250
+ 'AUTOMATION_PR_LIMIT': '3',
251
+ 'AUTOMATION_GLOBAL_LIMIT': '25'
252
+ }):
253
+ manager = AutomationSafetyManager(self.test_dir)
254
+
255
+ # Should use custom limits
256
+ self.assertEqual(manager.pr_limit, 3)
257
+ self.assertEqual(manager.global_limit, 25)
258
+
259
+ def test_default_limits_when_no_config(self):
260
+ """RED: Should use default limits when no configuration provided"""
261
+ manager = AutomationSafetyManager(self.test_dir)
262
+
263
+ # Should use defaults
264
+ self.assertEqual(manager.pr_limit, 5)
265
+ self.assertEqual(manager.global_limit, 50)
266
+
267
+ @property
268
+ def automation_manager(self):
269
+ """RED: This property will fail - no AutomationSafetyManager exists yet"""
270
+ # This will fail until we implement the class in GREEN phase
271
+ if not hasattr(self, '_automation_manager'):
272
+ self._automation_manager = AutomationSafetyManager(self.test_dir)
273
+ return self._automation_manager
274
+
275
+
276
+ # Matrix 8: Integration with Existing Automation
277
+ class TestAutomationIntegration(unittest.TestCase):
278
+ """Integration tests with existing simple_pr_batch.sh script"""
279
+
280
+ def setUp(self):
281
+ self.launchd_root = Path(tempfile.mkdtemp(prefix="launchd-plist-"))
282
+ self.plist_path = self.launchd_root / "com.worldarchitect.pr-automation.plist"
283
+ plist_dir = self.plist_path.parent
284
+ plist_dir.mkdir(parents=True, exist_ok=True)
285
+ plist_dir.chmod(0o755)
286
+ plist_content = """<?xml version="1.0" encoding="UTF-8"?>
287
+ <plist version="1.0">
288
+ <dict>
289
+ <key>ProgramArguments</key>
290
+ <array>
291
+ <string>/usr/bin/python3</string>
292
+ <string>/Users/jleechan/projects/worldarchitect.ai/automation/automation_safety_wrapper.py</string>
293
+ </array>
294
+ </dict>
295
+ </plist>
296
+ """
297
+ with open(self.plist_path, "w", encoding="utf-8") as plist_file:
298
+ plist_file.write(plist_content)
299
+
300
+ def tearDown(self):
301
+ shutil.rmtree(self.launchd_root, ignore_errors=True)
302
+
303
+ def test_shell_script_respects_safety_limits(self):
304
+ """RED: Shell script should check safety limits before processing"""
305
+ # This test will fail - existing script doesn't have safety checks
306
+ with patch('subprocess.run') as mock_run:
307
+ mock_run.return_value.returncode = 1 # Safety limit hit
308
+
309
+ result = self.run_automation_script()
310
+
311
+ # Should exit early due to safety limits
312
+ self.assertEqual(result.returncode, 1)
313
+
314
+ def test_launchd_plist_includes_safety_wrapper(self):
315
+ """RED: launchd plist should call safety wrapper, not direct script"""
316
+ plist_content = self.read_launchd_plist()
317
+
318
+ # Should call safety wrapper, not direct automation
319
+ self.assertIn("automation_safety_wrapper.py", plist_content)
320
+ self.assertNotIn("simple_pr_batch.sh", plist_content)
321
+
322
+ def run_automation_script(self):
323
+ """Helper to run automation script"""
324
+ import subprocess
325
+ return subprocess.run([
326
+ "/Users/jleechan/projects/worktree_worker2/automation/simple_pr_batch.sh"
327
+ ], capture_output=True, text=True)
328
+
329
+ def read_launchd_plist(self):
330
+ """Helper to read launchd plist file"""
331
+ # This will fail - plist doesn't exist yet
332
+ with open(self.plist_path, encoding="utf-8") as f:
333
+ return f.read()
334
+
335
+
336
+ if __name__ == '__main__':
337
+ # RED Phase: Run tests to confirm they FAIL
338
+ print("🔴 RED Phase: Running failing tests for automation safety limits")
339
+ print("Expected: ALL TESTS SHOULD FAIL (no implementation exists)")
340
+ unittest.main(verbosity=2)