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.
Files changed (46) hide show
  1. jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
  2. jleechanorg_pr_automation/__init__.py +64 -9
  3. jleechanorg_pr_automation/automation_safety_manager.py +306 -95
  4. jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
  5. jleechanorg_pr_automation/automation_utils.py +87 -65
  6. jleechanorg_pr_automation/check_codex_comment.py +7 -1
  7. jleechanorg_pr_automation/codex_branch_updater.py +21 -9
  8. jleechanorg_pr_automation/codex_config.py +70 -3
  9. jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
  10. jleechanorg_pr_automation/logging_utils.py +86 -0
  11. jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
  12. jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
  13. jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
  14. jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
  15. jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
  16. jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
  17. jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
  18. jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
  19. jleechanorg_pr_automation/tests/__init__.py +0 -0
  20. jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
  21. jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
  22. jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
  23. jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
  24. jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
  25. jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
  26. jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
  27. jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
  28. jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
  29. jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
  30. jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
  31. jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
  32. jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
  33. jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
  34. jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
  35. jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
  36. jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
  37. jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
  38. jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
  39. jleechanorg_pr_automation/utils.py +81 -56
  40. jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
  41. jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
  42. jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
  43. jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
  44. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
  45. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
  46. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/top_level.txt +0 -0
@@ -6,11 +6,9 @@ Test Matrix: Actionable PR counting should exclude skipped PRs and only count
6
6
  PRs that actually get processed with comments.
7
7
  """
8
8
 
9
- import os
10
- import unittest
11
9
  import tempfile
12
- from datetime import datetime, timedelta
13
- from unittest.mock import Mock, patch, MagicMock
10
+ import unittest
11
+ from unittest.mock import patch
14
12
 
15
13
  from jleechanorg_pr_automation.jleechanorg_pr_monitor import JleechanorgPRMonitor
16
14
 
@@ -21,7 +19,7 @@ class TestActionableCountingMatrix(unittest.TestCase):
21
19
  def setUp(self):
22
20
  """Set up test environment"""
23
21
  self.temp_dir = tempfile.mkdtemp()
24
- self.monitor = JleechanorgPRMonitor()
22
+ self.monitor = JleechanorgPRMonitor(automation_username="test-automation-user")
25
23
  self.monitor.history_storage_path = self.temp_dir
26
24
 
27
25
  def tearDown(self):
@@ -35,40 +33,40 @@ class TestActionableCountingMatrix(unittest.TestCase):
35
33
  # Create a mix of PRs - some actionable, some should be skipped
36
34
  mock_prs = [
37
35
  # 3 actionable PRs (new commits)
38
- {'number': 1001, 'state': 'open', 'isDraft': False, 'headRefOid': 'new001',
39
- 'repository': 'repo1', 'headRefName': 'feature1', 'repositoryFullName': 'org/repo1',
40
- 'title': 'Actionable PR 1', 'updatedAt': '2025-09-28T21:00:00Z'},
41
- {'number': 1002, 'state': 'open', 'isDraft': False, 'headRefOid': 'new002',
42
- 'repository': 'repo2', 'headRefName': 'feature2', 'repositoryFullName': 'org/repo2',
43
- 'title': 'Actionable PR 2', 'updatedAt': '2025-09-28T20:59:00Z'},
44
- {'number': 1003, 'state': 'open', 'isDraft': False, 'headRefOid': 'new003',
45
- 'repository': 'repo3', 'headRefName': 'feature3', 'repositoryFullName': 'org/repo3',
46
- 'title': 'Actionable PR 3', 'updatedAt': '2025-09-28T20:58:00Z'},
36
+ {"number": 1001, "state": "open", "isDraft": False, "headRefOid": "new001",
37
+ "repository": "repo1", "headRefName": "feature1", "repositoryFullName": "org/repo1",
38
+ "title": "Actionable PR 1", "updatedAt": "2025-09-28T21:00:00Z"},
39
+ {"number": 1002, "state": "open", "isDraft": False, "headRefOid": "new002",
40
+ "repository": "repo2", "headRefName": "feature2", "repositoryFullName": "org/repo2",
41
+ "title": "Actionable PR 2", "updatedAt": "2025-09-28T20:59:00Z"},
42
+ {"number": 1003, "state": "open", "isDraft": False, "headRefOid": "new003",
43
+ "repository": "repo3", "headRefName": "feature3", "repositoryFullName": "org/repo3",
44
+ "title": "Actionable PR 3", "updatedAt": "2025-09-28T20:58:00Z"},
47
45
 
48
46
  # 2 PRs that should be skipped (already processed)
49
- {'number': 2001, 'state': 'open', 'isDraft': False, 'headRefOid': 'old001',
50
- 'repository': 'repo4', 'headRefName': 'processed1', 'repositoryFullName': 'org/repo4',
51
- 'title': 'Already Processed PR 1', 'updatedAt': '2025-09-28T20:57:00Z'},
52
- {'number': 2002, 'state': 'open', 'isDraft': False, 'headRefOid': 'old002',
53
- 'repository': 'repo5', 'headRefName': 'processed2', 'repositoryFullName': 'org/repo5',
54
- 'title': 'Already Processed PR 2', 'updatedAt': '2025-09-28T20:56:00Z'}
47
+ {"number": 2001, "state": "open", "isDraft": False, "headRefOid": "old001",
48
+ "repository": "repo4", "headRefName": "processed1", "repositoryFullName": "org/repo4",
49
+ "title": "Already Processed PR 1", "updatedAt": "2025-09-28T20:57:00Z"},
50
+ {"number": 2002, "state": "open", "isDraft": False, "headRefOid": "old002",
51
+ "repository": "repo5", "headRefName": "processed2", "repositoryFullName": "org/repo5",
52
+ "title": "Already Processed PR 2", "updatedAt": "2025-09-28T20:56:00Z"}
55
53
  ]
56
54
 
57
55
  # Pre-record the "processed" PRs as already handled
58
- self.monitor._record_pr_processing('repo4', 'processed1', 2001, 'old001')
59
- self.monitor._record_pr_processing('repo5', 'processed2', 2002, 'old002')
56
+ self.monitor._record_pr_processing("repo4", "processed1", 2001, "old001")
57
+ self.monitor._record_pr_processing("repo5", "processed2", 2002, "old002")
60
58
 
61
59
  # Mock the PR discovery to return our test data
62
- with patch.object(self.monitor, 'discover_open_prs', return_value=mock_prs):
63
- with patch.object(self.monitor, '_process_pr_comment', return_value=True) as mock_process:
60
+ with patch.object(self.monitor, "discover_open_prs", return_value=mock_prs):
61
+ with patch.object(self.monitor, "_process_pr_comment", return_value=True) as mock_process:
64
62
 
65
63
  # RED: This should fail - current implementation counts all PRs, not just actionable ones
66
64
  result = self.monitor.run_monitoring_cycle_with_actionable_count(target_actionable_count=3)
67
65
 
68
66
  # Should process exactly 3 actionable PRs, not counting the 2 skipped ones
69
- self.assertEqual(result['actionable_processed'], 3)
70
- self.assertEqual(result['total_discovered'], 5)
71
- self.assertEqual(result['skipped_count'], 2)
67
+ self.assertEqual(result["actionable_processed"], 3)
68
+ self.assertEqual(result["total_discovered"], 5)
69
+ self.assertEqual(result["skipped_count"], 2)
72
70
 
73
71
  # Verify that _process_pr_comment was called exactly 3 times (only for actionable PRs)
74
72
  self.assertEqual(mock_process.call_count, 3)
@@ -78,35 +76,35 @@ class TestActionableCountingMatrix(unittest.TestCase):
78
76
 
79
77
  # Create only 2 actionable PRs, but set target to 5
80
78
  mock_prs = [
81
- {'number': 1001, 'state': 'open', 'isDraft': False, 'headRefOid': 'new001',
82
- 'repository': 'repo1', 'headRefName': 'feature1', 'repositoryFullName': 'org/repo1',
83
- 'title': 'Actionable PR 1', 'updatedAt': '2025-09-28T21:00:00Z'},
84
- {'number': 1002, 'state': 'open', 'isDraft': False, 'headRefOid': 'new002',
85
- 'repository': 'repo2', 'headRefName': 'feature2', 'repositoryFullName': 'org/repo2',
86
- 'title': 'Actionable PR 2', 'updatedAt': '2025-09-28T20:59:00Z'},
79
+ {"number": 1001, "state": "open", "isDraft": False, "headRefOid": "new001",
80
+ "repository": "repo1", "headRefName": "feature1", "repositoryFullName": "org/repo1",
81
+ "title": "Actionable PR 1", "updatedAt": "2025-09-28T21:00:00Z"},
82
+ {"number": 1002, "state": "open", "isDraft": False, "headRefOid": "new002",
83
+ "repository": "repo2", "headRefName": "feature2", "repositoryFullName": "org/repo2",
84
+ "title": "Actionable PR 2", "updatedAt": "2025-09-28T20:59:00Z"},
87
85
 
88
86
  # 3 closed/processed PRs (not actionable)
89
- {'number': 2001, 'state': 'closed', 'isDraft': False, 'headRefOid': 'any001',
90
- 'repository': 'repo3', 'headRefName': 'closed1', 'repositoryFullName': 'org/repo3',
91
- 'title': 'Closed PR', 'updatedAt': '2025-09-28T20:58:00Z'},
92
- {'number': 2002, 'state': 'open', 'isDraft': False, 'headRefOid': 'old002',
93
- 'repository': 'repo4', 'headRefName': 'processed2', 'repositoryFullName': 'org/repo4',
94
- 'title': 'Processed PR', 'updatedAt': '2025-09-28T20:57:00Z'}
87
+ {"number": 2001, "state": "closed", "isDraft": False, "headRefOid": "any001",
88
+ "repository": "repo3", "headRefName": "closed1", "repositoryFullName": "org/repo3",
89
+ "title": "Closed PR", "updatedAt": "2025-09-28T20:58:00Z"},
90
+ {"number": 2002, "state": "open", "isDraft": False, "headRefOid": "old002",
91
+ "repository": "repo4", "headRefName": "processed2", "repositoryFullName": "org/repo4",
92
+ "title": "Processed PR", "updatedAt": "2025-09-28T20:57:00Z"}
95
93
  ]
96
94
 
97
95
  # Pre-record one as processed
98
- self.monitor._record_pr_processing('repo4', 'processed2', 2002, 'old002')
96
+ self.monitor._record_pr_processing("repo4", "processed2", 2002, "old002")
99
97
 
100
- with patch.object(self.monitor, 'discover_open_prs', return_value=mock_prs):
101
- with patch.object(self.monitor, '_process_pr_comment', return_value=True) as mock_process:
98
+ with patch.object(self.monitor, "discover_open_prs", return_value=mock_prs):
99
+ with patch.object(self.monitor, "_process_pr_comment", return_value=True) as mock_process:
102
100
 
103
101
  # RED: This should fail - method doesn't exist yet
104
102
  result = self.monitor.run_monitoring_cycle_with_actionable_count(target_actionable_count=5)
105
103
 
106
104
  # Should process only the 2 available actionable PRs
107
- self.assertEqual(result['actionable_processed'], 2)
108
- self.assertEqual(result['total_discovered'], 4)
109
- self.assertEqual(result['skipped_count'], 2) # 1 closed + 1 processed
105
+ self.assertEqual(result["actionable_processed"], 2)
106
+ self.assertEqual(result["total_discovered"], 4)
107
+ self.assertEqual(result["skipped_count"], 2) # 1 closed + 1 processed
110
108
 
111
109
  # Verify processing was called only for actionable PRs
112
110
  self.assertEqual(mock_process.call_count, 2)
@@ -117,30 +115,30 @@ class TestActionableCountingMatrix(unittest.TestCase):
117
115
  # Create only non-actionable PRs
118
116
  mock_prs = [
119
117
  # All closed or already processed
120
- {'number': 2001, 'state': 'closed', 'isDraft': False, 'headRefOid': 'any001',
121
- 'repository': 'repo1', 'headRefName': 'closed1', 'repositoryFullName': 'org/repo1',
122
- 'title': 'Closed PR 1', 'updatedAt': '2025-09-28T21:00:00Z'},
123
- {'number': 2002, 'state': 'closed', 'isDraft': False, 'headRefOid': 'any002',
124
- 'repository': 'repo2', 'headRefName': 'closed2', 'repositoryFullName': 'org/repo2',
125
- 'title': 'Closed PR 2', 'updatedAt': '2025-09-28T20:59:00Z'},
126
- {'number': 2003, 'state': 'open', 'isDraft': False, 'headRefOid': 'old003',
127
- 'repository': 'repo3', 'headRefName': 'processed3', 'repositoryFullName': 'org/repo3',
128
- 'title': 'Processed PR', 'updatedAt': '2025-09-28T20:58:00Z'}
118
+ {"number": 2001, "state": "closed", "isDraft": False, "headRefOid": "any001",
119
+ "repository": "repo1", "headRefName": "closed1", "repositoryFullName": "org/repo1",
120
+ "title": "Closed PR 1", "updatedAt": "2025-09-28T21:00:00Z"},
121
+ {"number": 2002, "state": "closed", "isDraft": False, "headRefOid": "any002",
122
+ "repository": "repo2", "headRefName": "closed2", "repositoryFullName": "org/repo2",
123
+ "title": "Closed PR 2", "updatedAt": "2025-09-28T20:59:00Z"},
124
+ {"number": 2003, "state": "open", "isDraft": False, "headRefOid": "old003",
125
+ "repository": "repo3", "headRefName": "processed3", "repositoryFullName": "org/repo3",
126
+ "title": "Processed PR", "updatedAt": "2025-09-28T20:58:00Z"}
129
127
  ]
130
128
 
131
129
  # Mark the open one as already processed
132
- self.monitor._record_pr_processing('repo3', 'processed3', 2003, 'old003')
130
+ self.monitor._record_pr_processing("repo3", "processed3", 2003, "old003")
133
131
 
134
- with patch.object(self.monitor, 'discover_open_prs', return_value=mock_prs):
135
- with patch.object(self.monitor, '_process_pr_comment', return_value=True) as mock_process:
132
+ with patch.object(self.monitor, "discover_open_prs", return_value=mock_prs):
133
+ with patch.object(self.monitor, "_process_pr_comment", return_value=True) as mock_process:
136
134
 
137
135
  # RED: This should fail - method doesn't exist yet
138
136
  result = self.monitor.run_monitoring_cycle_with_actionable_count(target_actionable_count=10)
139
137
 
140
138
  # Should process 0 PRs
141
- self.assertEqual(result['actionable_processed'], 0)
142
- self.assertEqual(result['total_discovered'], 3)
143
- self.assertEqual(result['skipped_count'], 3) # All skipped
139
+ self.assertEqual(result["actionable_processed"], 0)
140
+ self.assertEqual(result["total_discovered"], 3)
141
+ self.assertEqual(result["skipped_count"], 3) # All skipped
144
142
 
145
143
  # Verify no processing was attempted
146
144
  self.assertEqual(mock_process.call_count, 0)
@@ -149,15 +147,15 @@ class TestActionableCountingMatrix(unittest.TestCase):
149
147
  """RED: Actionable counter should only count PRs that successfully get processed"""
150
148
 
151
149
  mock_prs = [
152
- {'number': 1001, 'state': 'open', 'isDraft': False, 'headRefOid': 'new001',
153
- 'repository': 'repo1', 'headRefName': 'feature1', 'repositoryFullName': 'org/repo1',
154
- 'title': 'Success PR', 'updatedAt': '2025-09-28T21:00:00Z'},
155
- {'number': 1002, 'state': 'open', 'isDraft': False, 'headRefOid': 'new002',
156
- 'repository': 'repo2', 'headRefName': 'feature2', 'repositoryFullName': 'org/repo2',
157
- 'title': 'Failure PR', 'updatedAt': '2025-09-28T20:59:00Z'},
158
- {'number': 1003, 'state': 'open', 'isDraft': False, 'headRefOid': 'new003',
159
- 'repository': 'repo3', 'headRefName': 'feature3', 'repositoryFullName': 'org/repo3',
160
- 'title': 'Success PR 2', 'updatedAt': '2025-09-28T20:58:00Z'}
150
+ {"number": 1001, "state": "open", "isDraft": False, "headRefOid": "new001",
151
+ "repository": "repo1", "headRefName": "feature1", "repositoryFullName": "org/repo1",
152
+ "title": "Success PR", "updatedAt": "2025-09-28T21:00:00Z"},
153
+ {"number": 1002, "state": "open", "isDraft": False, "headRefOid": "new002",
154
+ "repository": "repo2", "headRefName": "feature2", "repositoryFullName": "org/repo2",
155
+ "title": "Failure PR", "updatedAt": "2025-09-28T20:59:00Z"},
156
+ {"number": 1003, "state": "open", "isDraft": False, "headRefOid": "new003",
157
+ "repository": "repo3", "headRefName": "feature3", "repositoryFullName": "org/repo3",
158
+ "title": "Success PR 2", "updatedAt": "2025-09-28T20:58:00Z"}
161
159
  ]
162
160
 
163
161
  def mock_process_side_effect(repo_name, pr_number, pr_data):
@@ -166,16 +164,16 @@ class TestActionableCountingMatrix(unittest.TestCase):
166
164
  return False # Processing failed
167
165
  return True # Processing succeeded
168
166
 
169
- with patch.object(self.monitor, 'discover_open_prs', return_value=mock_prs):
170
- with patch.object(self.monitor, '_process_pr_comment', side_effect=mock_process_side_effect) as mock_process:
167
+ with patch.object(self.monitor, "discover_open_prs", return_value=mock_prs):
168
+ with patch.object(self.monitor, "_process_pr_comment", side_effect=mock_process_side_effect) as mock_process:
171
169
 
172
170
  # RED: This should fail - method doesn't exist yet
173
171
  result = self.monitor.run_monitoring_cycle_with_actionable_count(target_actionable_count=10)
174
172
 
175
173
  # Should count only successful processing (2 out of 3 attempts)
176
- self.assertEqual(result['actionable_processed'], 2)
177
- self.assertEqual(result['total_discovered'], 3)
178
- self.assertEqual(result['processing_failures'], 1)
174
+ self.assertEqual(result["actionable_processed"], 2)
175
+ self.assertEqual(result["total_discovered"], 3)
176
+ self.assertEqual(result["processing_failures"], 1)
179
177
 
180
178
  # Verify all 3 were attempted
181
179
  self.assertEqual(mock_process.call_count, 3)
@@ -186,35 +184,35 @@ class TestActionableCountingMatrix(unittest.TestCase):
186
184
  mock_prs = [
187
185
  # Create 15 total PRs, but only 8 should be actionable
188
186
  *[
189
- {'number': 1000 + i, 'state': 'open', 'isDraft': False, 'headRefOid': f'new{i:03d}',
190
- 'repository': f'repo{i}', 'headRefName': f'feature{i}', 'repositoryFullName': f'org/repo{i}',
191
- 'title': f'Actionable PR {i}', 'updatedAt': f'2025-09-28T{21-i//10}:{59-(i%10)*5}:00Z'}
187
+ {"number": 1000 + i, "state": "open", "isDraft": False, "headRefOid": f"new{i:03d}",
188
+ "repository": f"repo{i}", "headRefName": f"feature{i}", "repositoryFullName": f"org/repo{i}",
189
+ "title": f"Actionable PR {i}", "updatedAt": f"2025-09-28T{21-i//10}:{59-(i%10)*5}:00Z"}
192
190
  for i in range(8) # 8 actionable PRs
193
191
  ],
194
192
  *[
195
- {'number': 2000 + i, 'state': 'closed', 'isDraft': False, 'headRefOid': f'any{i:03d}',
196
- 'repository': f'closed_repo{i}', 'headRefName': f'closed{i}', 'repositoryFullName': f'org/closed_repo{i}',
197
- 'title': f'Closed PR {i}', 'updatedAt': f'2025-09-28T20:{50-i}:00Z'}
193
+ {"number": 2000 + i, "state": "closed", "isDraft": False, "headRefOid": f"any{i:03d}",
194
+ "repository": f"closed_repo{i}", "headRefName": f"closed{i}", "repositoryFullName": f"org/closed_repo{i}",
195
+ "title": f"Closed PR {i}", "updatedAt": f"2025-09-28T20:{50-i}:00Z"}
198
196
  for i in range(7) # 7 closed PRs (not actionable)
199
197
  ]
200
198
  ]
201
199
 
202
- with patch.object(self.monitor, 'discover_open_prs', return_value=mock_prs):
203
- with patch.object(self.monitor, '_process_pr_comment', return_value=True) as mock_process:
200
+ with patch.object(self.monitor, "discover_open_prs", return_value=mock_prs):
201
+ with patch.object(self.monitor, "_process_pr_comment", return_value=True) as mock_process:
204
202
 
205
203
  # RED: This should fail - enhanced method doesn't exist
206
204
  # Should process exactly 5 actionable PRs, ignoring the 7 closed ones
207
205
  result = self.monitor.run_monitoring_cycle_with_actionable_count(target_actionable_count=5)
208
206
 
209
- self.assertEqual(result['actionable_processed'], 5)
210
- self.assertEqual(result['total_discovered'], 15)
211
- self.assertEqual(result['skipped_count'], 7) # Closed PRs
207
+ self.assertEqual(result["actionable_processed"], 5)
208
+ self.assertEqual(result["total_discovered"], 15)
209
+ self.assertEqual(result["skipped_count"], 7) # Closed PRs
212
210
 
213
211
  # Should have attempted processing exactly 5 times
214
212
  self.assertEqual(mock_process.call_count, 5)
215
213
 
216
214
 
217
- if __name__ == '__main__':
215
+ if __name__ == "__main__":
218
216
  # RED Phase: Run tests to confirm they FAIL
219
217
  print("🔴 RED Phase: Running failing tests for actionable PR counting")
220
218
  print("Expected: ALL TESTS SHOULD FAIL (no implementation exists)")
@@ -0,0 +1,124 @@
1
+ """
2
+ Tests for per-PR attempt limit logic
3
+
4
+ NEW BEHAVIOR: Counts ALL attempts (success + failure) against the limit.
5
+ This enables iterative improvements by allowing multiple successful runs.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import shutil
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ from jleechanorg_pr_automation.automation_safety_manager import AutomationSafetyManager
16
+
17
+
18
+ class TestAttemptLimitLogic(unittest.TestCase):
19
+ """Test that attempt limits count ALL attempts (success + failure)"""
20
+
21
+ def setUp(self):
22
+ """Set up test environment with temporary directory"""
23
+ self.test_dir = tempfile.mkdtemp()
24
+ self.safety_manager = AutomationSafetyManager(data_dir=self.test_dir)
25
+ # Note: Default pr_limit is now 50
26
+
27
+ def tearDown(self):
28
+ """Clean up test directory"""
29
+ shutil.rmtree(self.test_dir, ignore_errors=True)
30
+
31
+ def test_all_attempts_count_against_limit(self):
32
+ """All attempts (success + failure) count against pr_limit"""
33
+ # Create 50 successful attempts (exactly at pr_limit=50 default)
34
+ pr_attempts = {
35
+ "r=test/repo||p=123||b=main": [
36
+ {"result": "success", "timestamp": f"2026-01-{i:02d}T12:00:00"}
37
+ for i in range(1, 51)
38
+ ]
39
+ }
40
+
41
+ attempts_file = os.path.join(self.test_dir, "pr_attempts.json")
42
+ with open(attempts_file, "w") as f:
43
+ json.dump(pr_attempts, f)
44
+
45
+ # Should be blocked (50 attempts = limit)
46
+ can_process = self.safety_manager.can_process_pr(123, repo="test/repo", branch="main")
47
+ self.assertFalse(can_process, "Should block processing at 50 total attempts")
48
+
49
+ def test_failure_limit_blocks_processing(self):
50
+ """50 failures should block processing (pr_limit=50)"""
51
+ # Create 50 failed attempts (exactly at limit)
52
+ pr_attempts = {
53
+ "r=test/repo||p=456||b=main": [
54
+ {"result": "failure", "timestamp": f"2026-01-{i:02d}T12:00:00"}
55
+ for i in range(1, 51)
56
+ ]
57
+ }
58
+
59
+ attempts_file = os.path.join(self.test_dir, "pr_attempts.json")
60
+ with open(attempts_file, "w") as f:
61
+ json.dump(pr_attempts, f)
62
+
63
+ # Should be blocked (50 failures = limit)
64
+ can_process = self.safety_manager.can_process_pr(456, repo="test/repo", branch="main")
65
+ self.assertFalse(can_process, "Should block processing with 50 failed attempts")
66
+
67
+ def test_mixed_attempts_count_all(self):
68
+ """Mixed success/failure attempts all count toward limit"""
69
+ # Create 50 total attempts: 30 successes + 20 failures
70
+ pr_attempts = {
71
+ "r=test/repo||p=789||b=main": [
72
+ {"result": "success", "timestamp": f"2026-01-{i:02d}T12:00:00"}
73
+ for i in range(1, 31)
74
+ ] + [
75
+ {"result": "failure", "timestamp": f"2026-01-{i:02d}T13:00:00"}
76
+ for i in range(31, 51)
77
+ ]
78
+ }
79
+
80
+ attempts_file = os.path.join(self.test_dir, "pr_attempts.json")
81
+ with open(attempts_file, "w") as f:
82
+ json.dump(pr_attempts, f)
83
+
84
+ # Should be blocked (50 total attempts = limit)
85
+ can_process = self.safety_manager.can_process_pr(789, repo="test/repo", branch="main")
86
+ self.assertFalse(can_process, "Should block processing with 50 total attempts")
87
+
88
+ def test_under_limit_allows_processing(self):
89
+ """Under the limit (49 attempts) should allow processing"""
90
+ # Create 49 attempts (1 under limit)
91
+ pr_attempts = {
92
+ "r=test/repo||p=999||b=main": [
93
+ {"result": "success", "timestamp": f"2026-01-{i:02d}T12:00:00"}
94
+ for i in range(1, 30)
95
+ ] + [
96
+ {"result": "failure", "timestamp": f"2026-01-{i:02d}T13:00:00"}
97
+ for i in range(30, 50)
98
+ ]
99
+ }
100
+
101
+ attempts_file = os.path.join(self.test_dir, "pr_attempts.json")
102
+ with open(attempts_file, "w") as f:
103
+ json.dump(pr_attempts, f)
104
+
105
+ # Should allow processing (49 < 50 limit)
106
+ can_process = self.safety_manager.can_process_pr(999, repo="test/repo", branch="main")
107
+ self.assertTrue(can_process, "Should allow processing with 49 total attempts")
108
+
109
+ def test_no_attempts_allows_processing(self):
110
+ """No attempts should allow processing"""
111
+ # Empty attempts
112
+ pr_attempts = {}
113
+
114
+ attempts_file = os.path.join(self.test_dir, "pr_attempts.json")
115
+ with open(attempts_file, "w") as f:
116
+ json.dump(pr_attempts, f)
117
+
118
+ # Should allow processing (0 attempts)
119
+ can_process = self.safety_manager.can_process_pr(111, repo="test/repo", branch="main")
120
+ self.assertTrue(can_process, "Should allow processing with 0 attempts")
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unit tests for automation marker generation and parsing functions.
4
+
5
+ Tests build_automation_marker() and parse_automation_marker() helper functions
6
+ added to support enhanced automation markers with workflow:agent:commit format.
7
+ """
8
+
9
+ import unittest
10
+
11
+ from jleechanorg_pr_automation.codex_config import (
12
+ FIX_COMMENT_RUN_MARKER_PREFIX,
13
+ FIXPR_MARKER_PREFIX,
14
+ build_automation_marker,
15
+ parse_automation_marker,
16
+ )
17
+
18
+
19
+ class TestBuildAutomationMarker(unittest.TestCase):
20
+ """Test build_automation_marker() function"""
21
+
22
+ def test_basic_marker_generation(self):
23
+ """Test basic marker generation with all parameters"""
24
+ marker = build_automation_marker("fix-comment-run", "gemini", "abc1234")
25
+ expected = "<!-- fix-comment-run-automation-commit:gemini:abc1234-->"
26
+ self.assertEqual(marker, expected)
27
+
28
+ def test_fixpr_workflow(self):
29
+ """Test marker generation for fixpr workflow"""
30
+ marker = build_automation_marker("fixpr-run", "codex", "def5678")
31
+ expected = "<!-- fixpr-run-automation-commit:codex:def5678-->"
32
+ self.assertEqual(marker, expected)
33
+
34
+ def test_different_agents(self):
35
+ """Test marker generation with different agent names"""
36
+ agents = ["gemini", "codex", "claude"]
37
+ for agent in agents:
38
+ marker = build_automation_marker("fix-comment-run", agent, "sha123")
39
+ self.assertIn(f":{agent}:", marker)
40
+ self.assertTrue(marker.startswith("<!-- fix-comment-run-automation-commit:"))
41
+ self.assertTrue(marker.endswith("-->"))
42
+
43
+ def test_short_commit_sha(self):
44
+ """Test marker generation with short commit SHA"""
45
+ marker = build_automation_marker("fix-comment-run", "gemini", "abc")
46
+ expected = "<!-- fix-comment-run-automation-commit:gemini:abc-->"
47
+ self.assertEqual(marker, expected)
48
+
49
+ def test_long_commit_sha(self):
50
+ """Test marker generation with full 40-character commit SHA"""
51
+ full_sha = "a" * 40
52
+ marker = build_automation_marker("fix-comment-run", "gemini", full_sha)
53
+ expected = f"<!-- fix-comment-run-automation-commit:gemini:{full_sha}-->"
54
+ self.assertEqual(marker, expected)
55
+
56
+ def test_unknown_commit_fallback(self):
57
+ """Test marker generation with 'unknown' commit value"""
58
+ marker = build_automation_marker("fix-comment-run", "gemini", "unknown")
59
+ expected = "<!-- fix-comment-run-automation-commit:gemini:unknown-->"
60
+ self.assertEqual(marker, expected)
61
+
62
+ def test_marker_format_consistency(self):
63
+ """Test that marker format matches prefix constants"""
64
+ # Fix-comment marker should match FIX_COMMENT_RUN_MARKER_PREFIX
65
+ marker = build_automation_marker("fix-comment-run", "gemini", "abc123")
66
+ self.assertTrue(marker.startswith(FIX_COMMENT_RUN_MARKER_PREFIX))
67
+
68
+ # FixPR marker should match FIXPR_MARKER_PREFIX
69
+ marker = build_automation_marker("fixpr-run", "codex", "def456")
70
+ self.assertTrue(marker.startswith(FIXPR_MARKER_PREFIX))
71
+
72
+
73
+ class TestParseAutomationMarker(unittest.TestCase):
74
+ """Test parse_automation_marker() function"""
75
+
76
+ def test_parse_new_format_with_agent(self):
77
+ """Test parsing new format marker with agent"""
78
+ marker = "<!-- fix-comment-run-automation-commit:gemini:abc123-->"
79
+ result = parse_automation_marker(marker)
80
+
81
+ self.assertIsNotNone(result)
82
+ self.assertEqual(result["workflow"], "fix-comment-run")
83
+ self.assertEqual(result["agent"], "gemini")
84
+ self.assertEqual(result["commit"], "abc123")
85
+
86
+ def test_parse_fixpr_marker(self):
87
+ """Test parsing fixpr-run marker"""
88
+ marker = "<!-- fixpr-run-automation-commit:codex:def456-->"
89
+ result = parse_automation_marker(marker)
90
+
91
+ self.assertIsNotNone(result)
92
+ self.assertEqual(result["workflow"], "fixpr-run")
93
+ self.assertEqual(result["agent"], "codex")
94
+ self.assertEqual(result["commit"], "def456")
95
+
96
+ def test_parse_legacy_format_without_agent(self):
97
+ """Test parsing legacy format marker without agent"""
98
+ marker = "<!-- fix-comment-automation-commit:abc123-->"
99
+ result = parse_automation_marker(marker)
100
+
101
+ self.assertIsNotNone(result)
102
+ self.assertEqual(result["workflow"], "fix-comment")
103
+ self.assertEqual(result["agent"], "unknown")
104
+ self.assertEqual(result["commit"], "abc123")
105
+
106
+ def test_parse_legacy_fixpr_marker(self):
107
+ """Test parsing legacy fixpr marker without agent"""
108
+ marker = "<!-- fixpr-automation-commit:ghi789-->"
109
+ result = parse_automation_marker(marker)
110
+
111
+ self.assertIsNotNone(result)
112
+ self.assertEqual(result["workflow"], "fixpr")
113
+ self.assertEqual(result["agent"], "unknown")
114
+ self.assertEqual(result["commit"], "ghi789")
115
+
116
+ def test_parse_invalid_marker_missing_html_comment(self):
117
+ """Test parsing invalid marker missing HTML comment markers"""
118
+ marker = "fix-comment-run-automation-commit:gemini:abc123"
119
+ result = parse_automation_marker(marker)
120
+ self.assertIsNone(result)
121
+
122
+ def test_parse_invalid_marker_wrong_format(self):
123
+ """Test parsing invalid marker with wrong format"""
124
+ marker = "<!-- some random comment -->"
125
+ result = parse_automation_marker(marker)
126
+ self.assertIsNone(result)
127
+
128
+ def test_parse_invalid_marker_too_many_colons(self):
129
+ """Test parsing marker with too many colons"""
130
+ marker = "<!-- fix-comment-run-automation-commit:gemini:abc123:extra-->"
131
+ result = parse_automation_marker(marker)
132
+ # Should return None for invalid format (3 colons instead of 2)
133
+ self.assertIsNone(result)
134
+
135
+ def test_parse_marker_with_whitespace(self):
136
+ """Test parsing marker with extra whitespace"""
137
+ marker = "<!-- fix-comment-run-automation-commit:gemini:abc123 -->"
138
+ result = parse_automation_marker(marker)
139
+
140
+ # Parser should handle trailing whitespace gracefully
141
+ self.assertIsNotNone(result)
142
+ self.assertEqual(result["workflow"], "fix-comment-run")
143
+
144
+ def test_roundtrip_consistency(self):
145
+ """Test that building and parsing a marker gives consistent results"""
146
+ workflow = "fix-comment-run"
147
+ agent = "gemini"
148
+ commit = "abc1234"
149
+
150
+ # Build marker
151
+ marker = build_automation_marker(workflow, agent, commit)
152
+
153
+ # Parse it back
154
+ result = parse_automation_marker(marker)
155
+
156
+ # Verify roundtrip consistency
157
+ self.assertIsNotNone(result)
158
+ self.assertEqual(result["workflow"], workflow)
159
+ self.assertEqual(result["agent"], agent)
160
+ self.assertEqual(result["commit"], commit)
161
+
162
+ def test_parse_with_different_workflows(self):
163
+ """Test parsing markers with different workflow types"""
164
+ workflows = ["fix-comment-run", "fixpr-run", "codex", "pr-automation"]
165
+
166
+ for workflow in workflows:
167
+ marker = build_automation_marker(workflow, "gemini", "abc123")
168
+ result = parse_automation_marker(marker)
169
+
170
+ self.assertIsNotNone(result)
171
+ self.assertEqual(result["workflow"], workflow)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ unittest.main()