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,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)
|