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
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for fixpr prompt formatting."""
3
+
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from automation.jleechanorg_pr_automation import orchestrated_pr_runner as runner
10
+
11
+
12
+ class _FakeDispatcher:
13
+ def __init__(self) -> None:
14
+ self.task_description = None
15
+
16
+ def analyze_task_and_create_agents(self, task_description: str, forced_cli: str = "claude"):
17
+ self.task_description = task_description
18
+ return [{"name": "test-agent"}]
19
+
20
+ def create_dynamic_agent(self, agent_spec): # pragma: no cover - simple stub
21
+ return True
22
+
23
+
24
+ class TestFixprPrompt(unittest.TestCase):
25
+ def test_fixpr_commit_message_includes_mode_and_model(self):
26
+ pr_payload = {
27
+ "repo_full": "jleechanorg/worldarchitect.ai",
28
+ "repo": "worldarchitect.ai",
29
+ "number": 123,
30
+ "title": "Test PR",
31
+ "branch": "feature/test-fixpr",
32
+ }
33
+
34
+ dispatcher = _FakeDispatcher()
35
+ with tempfile.TemporaryDirectory() as tmpdir:
36
+ with patch.object(runner, "WORKSPACE_ROOT_BASE", Path(tmpdir)):
37
+ with patch.object(runner, "kill_tmux_session_if_exists", lambda _: None):
38
+ ok = runner.dispatch_agent_for_pr(dispatcher, pr_payload, agent_cli="codex")
39
+
40
+ self.assertTrue(ok)
41
+ self.assertIsNotNone(dispatcher.task_description)
42
+ self.assertIn(
43
+ "[fixpr codex-automation-commit] fix PR #123",
44
+ dispatcher.task_description,
45
+ )
46
+ # Verify the prompt instructs fetching ALL feedback sources (inline review comments included).
47
+ self.assertIn("/pulls/{pr}/comments", dispatcher.task_description)
48
+ self.assertIn("/pulls/{pr}/reviews", dispatcher.task_description)
49
+ self.assertIn("/issues/{pr}/comments", dispatcher.task_description)
50
+ self.assertIn("--paginate", dispatcher.task_description)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ unittest.main()
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test for fixpr workflow return value handling.
4
+
5
+ Regression test for cursor[bot] bug report (Comment ID 2674134633):
6
+ "FixPR workflow ignores queued comment posting failure"
7
+
8
+ Tests that _process_pr_fixpr correctly captures and handles the return value
9
+ from _post_fixpr_queued, matching the behavior of _process_pr_fix_comment.
10
+ """
11
+
12
+ import unittest
13
+ from unittest.mock import Mock, patch, MagicMock, call
14
+
15
+ from jleechanorg_pr_automation.jleechanorg_pr_monitor import JleechanorgPRMonitor
16
+
17
+
18
+ class TestFixprReturnValue(unittest.TestCase):
19
+ """Test fixpr workflow return value handling"""
20
+
21
+ def setUp(self):
22
+ """Set up test environment with comprehensive mocking"""
23
+ # Patch AutomationSafetyManager during JleechanorgPRMonitor initialization
24
+ with patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.AutomationSafetyManager'):
25
+ self.monitor = JleechanorgPRMonitor(automation_username="test-automation-user")
26
+ self.monitor.safety_manager.fixpr_limit = 10
27
+
28
+ # Mock logger to avoid logging issues
29
+ self.monitor.logger = MagicMock()
30
+
31
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.dispatch_agent_for_pr')
32
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.ensure_base_clone')
33
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.chdir')
34
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.TaskDispatcher')
35
+ def test_fixpr_returns_partial_when_queued_comment_fails(
36
+ self,
37
+ mock_dispatcher,
38
+ mock_chdir,
39
+ mock_clone,
40
+ mock_dispatch_agent,
41
+ ):
42
+ """
43
+ Test that _process_pr_fixpr returns 'partial' when _post_fixpr_queued fails.
44
+
45
+ Regression test for cursor[bot] bug: Method was ignoring return value and
46
+ always returning 'posted' even when comment posting failed.
47
+ """
48
+ # Setup mocks
49
+ mock_clone.return_value = "/tmp/fake/repo"
50
+ mock_dispatch_agent.return_value = True # Agent dispatch succeeds
51
+
52
+ pr_data = {
53
+ "number": 1234,
54
+ "title": "Test PR",
55
+ "headRefName": "test-branch",
56
+ "baseRefName": "main",
57
+ "url": "https://github.com/test/repo/pull/1234",
58
+ "headRepository": {"owner": {"login": "test"}},
59
+ "headRefOid": "abc123",
60
+ "statusCheckRollup": [],
61
+ "mergeable": "MERGEABLE",
62
+ }
63
+
64
+ # Comprehensive mocking to avoid all side effects
65
+ with patch.object(self.monitor, '_normalize_repository_name', return_value="test/repo"):
66
+ with patch.object(self.monitor, '_get_pr_comment_state', return_value=("abc123", [])):
67
+ with patch.object(self.monitor, '_should_skip_pr', return_value=False):
68
+ with patch.object(self.monitor, '_count_workflow_comments', return_value=5): # Under limit
69
+ with patch.object(self.monitor, '_post_fixpr_queued', return_value=False) as mock_post_queued: # FAILS
70
+ with patch.object(self.monitor, '_record_processed_pr'):
71
+ result = self.monitor._process_pr_fixpr(
72
+ repository="test/repo",
73
+ pr_number=1234,
74
+ pr_data=pr_data,
75
+ )
76
+
77
+ # CRITICAL: Should return "partial" when queued comment fails
78
+ self.assertEqual(
79
+ result,
80
+ "partial",
81
+ "REGRESSION BUG: _process_pr_fixpr should return 'partial' when _post_fixpr_queued fails, "
82
+ "not ignore the return value. This causes failed marker posts to not count against fixpr_limit."
83
+ )
84
+ # Verify _post_fixpr_queued was called
85
+ mock_post_queued.assert_called_once()
86
+
87
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.dispatch_agent_for_pr')
88
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.ensure_base_clone')
89
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.chdir')
90
+ @patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.TaskDispatcher')
91
+ def test_fixpr_returns_posted_when_queued_comment_succeeds(
92
+ self,
93
+ mock_dispatcher,
94
+ mock_chdir,
95
+ mock_clone,
96
+ mock_dispatch_agent,
97
+ ):
98
+ """
99
+ Test that _process_pr_fixpr returns 'posted' when _post_fixpr_queued succeeds.
100
+
101
+ This is the happy path - verifies correct behavior when comment posting works.
102
+ """
103
+ # Setup mocks
104
+ mock_clone.return_value = "/tmp/fake/repo"
105
+ mock_dispatch_agent.return_value = True # Agent dispatch succeeds
106
+
107
+ pr_data = {
108
+ "number": 1234,
109
+ "title": "Test PR",
110
+ "headRefName": "test-branch",
111
+ "baseRefName": "main",
112
+ "url": "https://github.com/test/repo/pull/1234",
113
+ "headRepository": {"owner": {"login": "test"}},
114
+ "headRefOid": "abc123",
115
+ "statusCheckRollup": [],
116
+ "mergeable": "MERGEABLE",
117
+ }
118
+
119
+ # Comprehensive mocking to avoid all side effects
120
+ with patch.object(self.monitor, '_normalize_repository_name', return_value="test/repo"):
121
+ with patch.object(self.monitor, '_get_pr_comment_state', return_value=("abc123", [])):
122
+ with patch.object(self.monitor, '_should_skip_pr', return_value=False):
123
+ with patch.object(self.monitor, '_count_workflow_comments', return_value=5): # Under limit
124
+ with patch.object(self.monitor, '_post_fixpr_queued', return_value=True) as mock_post_queued: # SUCCEEDS
125
+ with patch.object(self.monitor, '_record_processed_pr'):
126
+ result = self.monitor._process_pr_fixpr(
127
+ repository="test/repo",
128
+ pr_number=1234,
129
+ pr_data=pr_data,
130
+ )
131
+
132
+ # Should return "posted" when everything succeeds
133
+ self.assertEqual(result, "posted")
134
+ # Verify _post_fixpr_queued was called
135
+ mock_post_queued.assert_called_once()
136
+
137
+
138
+
139
+ if __name__ == "__main__":
140
+ unittest.main()
@@ -13,51 +13,51 @@ class TestGraphQLErrorHandling(unittest.TestCase):
13
13
  """Validate robust error handling for GraphQL API failures."""
14
14
 
15
15
  def setUp(self) -> None:
16
- self.monitor = JleechanorgPRMonitor()
16
+ self.monitor = JleechanorgPRMonitor(automation_username="test-automation-user")
17
17
 
18
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
18
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
19
19
  def test_handles_api_timeout(self, mock_exec) -> None:
20
20
  """Should return None when GraphQL API times out"""
21
- mock_exec.side_effect = subprocess.TimeoutExpired(['gh'], 30)
21
+ mock_exec.side_effect = subprocess.TimeoutExpired(["gh"], 30)
22
22
 
23
- result = self.monitor._get_head_commit_details('org/repo', 123)
23
+ result = self.monitor._get_head_commit_details("org/repo", 123)
24
24
 
25
25
  self.assertIsNone(result, "Should return None on API timeout")
26
26
 
27
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
27
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
28
28
  def test_handles_malformed_json(self, mock_exec) -> None:
29
29
  """Should return None when GraphQL returns invalid JSON"""
30
30
  mock_exec.return_value = MagicMock(
31
31
  stdout='{"invalid": json, missing quotes}'
32
32
  )
33
33
 
34
- result = self.monitor._get_head_commit_details('org/repo', 123)
34
+ result = self.monitor._get_head_commit_details("org/repo", 123)
35
35
 
36
36
  self.assertIsNone(result, "Should return None on malformed JSON")
37
37
 
38
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
38
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
39
39
  def test_handles_missing_data_field(self, mock_exec) -> None:
40
40
  """Should handle missing 'data' field in GraphQL response"""
41
41
  mock_exec.return_value = MagicMock(
42
42
  stdout='{"errors": [{"message": "Field error"}]}'
43
43
  )
44
44
 
45
- result = self.monitor._get_head_commit_details('org/repo', 123)
45
+ result = self.monitor._get_head_commit_details("org/repo", 123)
46
46
 
47
47
  self.assertIsNone(result, "Should return None when data field missing")
48
48
 
49
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
49
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
50
50
  def test_handles_missing_repository_field(self, mock_exec) -> None:
51
51
  """Should handle missing 'repository' field gracefully"""
52
52
  mock_exec.return_value = MagicMock(
53
53
  stdout='{"data": {}}'
54
54
  )
55
55
 
56
- result = self.monitor._get_head_commit_details('org/repo', 123)
56
+ result = self.monitor._get_head_commit_details("org/repo", 123)
57
57
 
58
58
  self.assertIsNone(result, "Should return None when repository field missing")
59
59
 
60
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
60
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
61
61
  def test_handles_missing_commits(self, mock_exec) -> None:
62
62
  """Should handle missing commits array gracefully"""
63
63
  response = {
@@ -69,11 +69,11 @@ class TestGraphQLErrorHandling(unittest.TestCase):
69
69
  }
70
70
  mock_exec.return_value = MagicMock(stdout=json.dumps(response))
71
71
 
72
- result = self.monitor._get_head_commit_details('org/repo', 123)
72
+ result = self.monitor._get_head_commit_details("org/repo", 123)
73
73
 
74
74
  self.assertIsNone(result, "Should return None when commits missing")
75
75
 
76
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
76
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
77
77
  def test_handles_empty_commits_array(self, mock_exec) -> None:
78
78
  """Should handle empty commits array gracefully"""
79
79
  response = {
@@ -87,66 +87,66 @@ class TestGraphQLErrorHandling(unittest.TestCase):
87
87
  }
88
88
  mock_exec.return_value = MagicMock(stdout=json.dumps(response))
89
89
 
90
- result = self.monitor._get_head_commit_details('org/repo', 123)
90
+ result = self.monitor._get_head_commit_details("org/repo", 123)
91
91
 
92
92
  self.assertIsNone(result, "Should return None when commits array empty")
93
93
 
94
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
94
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
95
95
  def test_handles_called_process_error(self, mock_exec) -> None:
96
96
  """Should handle subprocess CalledProcessError gracefully"""
97
97
  mock_exec.side_effect = subprocess.CalledProcessError(
98
98
  returncode=1,
99
- cmd=['gh', 'api'],
100
- stderr='API rate limit exceeded'
99
+ cmd=["gh", "api"],
100
+ stderr="API rate limit exceeded"
101
101
  )
102
102
 
103
- result = self.monitor._get_head_commit_details('org/repo', 123)
103
+ result = self.monitor._get_head_commit_details("org/repo", 123)
104
104
 
105
105
  self.assertIsNone(result, "Should return None on CalledProcessError")
106
106
 
107
- @patch('jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout')
107
+ @patch("jleechanorg_pr_automation.automation_utils.AutomationUtils.execute_subprocess_with_timeout")
108
108
  def test_handles_generic_exception(self, mock_exec) -> None:
109
109
  """Should handle unexpected exceptions gracefully"""
110
110
  mock_exec.side_effect = RuntimeError("Unexpected error")
111
111
 
112
- result = self.monitor._get_head_commit_details('org/repo', 123)
112
+ result = self.monitor._get_head_commit_details("org/repo", 123)
113
113
 
114
114
  self.assertIsNone(result, "Should return None on unexpected exception")
115
115
 
116
116
  def test_validates_invalid_repo_format(self) -> None:
117
117
  """Should return None for invalid repository format"""
118
- result = self.monitor._get_head_commit_details('invalid-no-slash', 123)
118
+ result = self.monitor._get_head_commit_details("invalid-no-slash", 123)
119
119
 
120
120
  self.assertIsNone(result, "Should reject repo without slash separator")
121
121
 
122
122
  def test_validates_empty_repo_name(self) -> None:
123
123
  """Should return None for empty repository parts"""
124
- result = self.monitor._get_head_commit_details('/repo', 123)
124
+ result = self.monitor._get_head_commit_details("/repo", 123)
125
125
 
126
126
  self.assertIsNone(result, "Should reject empty owner")
127
127
 
128
128
  def test_validates_invalid_github_owner_name(self) -> None:
129
129
  """Should return None for invalid GitHub owner/repo names"""
130
130
  # GitHub names cannot start with hyphen
131
- result = self.monitor._get_head_commit_details('-invalid/repo', 123)
131
+ result = self.monitor._get_head_commit_details("-invalid/repo", 123)
132
132
 
133
133
  self.assertIsNone(result, "Should reject owner starting with hyphen")
134
134
 
135
135
  def test_validates_invalid_pr_number_string(self) -> None:
136
136
  """Should return None for non-integer PR number"""
137
- result = self.monitor._get_head_commit_details('org/repo', "not-a-number")
137
+ result = self.monitor._get_head_commit_details("org/repo", "not-a-number")
138
138
 
139
139
  self.assertIsNone(result, "Should reject string PR number")
140
140
 
141
141
  def test_validates_negative_pr_number(self) -> None:
142
142
  """Should return None for negative PR number"""
143
- result = self.monitor._get_head_commit_details('org/repo', -1)
143
+ result = self.monitor._get_head_commit_details("org/repo", -1)
144
144
 
145
145
  self.assertIsNone(result, "Should reject negative PR number")
146
146
 
147
147
  def test_validates_zero_pr_number(self) -> None:
148
148
  """Should return None for zero PR number"""
149
- result = self.monitor._get_head_commit_details('org/repo', 0)
149
+ result = self.monitor._get_head_commit_details("org/repo", 0)
150
150
 
151
151
  self.assertIsNone(result, "Should reject zero PR number")
152
152
 
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for --model parameter support in orchestration and automation.
4
+
5
+ Validates that model parameter is correctly passed through the automation pipeline.
6
+ """
7
+
8
+ import argparse
9
+ import unittest
10
+ from types import SimpleNamespace
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ from jleechanorg_pr_automation.jleechanorg_pr_monitor import (
14
+ JleechanorgPRMonitor,
15
+ _normalize_model,
16
+ )
17
+
18
+
19
+ class TestModelParameter(unittest.TestCase):
20
+ """Test suite for model parameter functionality."""
21
+
22
+ def setUp(self):
23
+ """Set up test fixtures."""
24
+ self.monitor = JleechanorgPRMonitor(automation_username="test-automation-user")
25
+
26
+ def test_process_single_pr_accepts_model_parameter(self):
27
+ """Test that process_single_pr_by_number accepts model parameter."""
28
+ with patch.object(self.monitor, 'safety_manager') as mock_safety:
29
+ with patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.AutomationUtils'):
30
+ mock_safety.can_start_global_run.return_value = True
31
+ mock_safety.try_process_pr.return_value = True
32
+
33
+ # Should not raise TypeError for model parameter
34
+ try:
35
+ self.monitor.process_single_pr_by_number(
36
+ pr_number=1234,
37
+ repository="test/repo",
38
+ fix_comment=True,
39
+ agent_cli="claude",
40
+ model="sonnet" # Test model parameter
41
+ )
42
+ except TypeError as e:
43
+ if "model" in str(e):
44
+ self.fail(f"process_single_pr_by_number does not accept model parameter: {e}")
45
+ except Exception:
46
+ # Other exceptions are okay for this test - we just want to verify signature
47
+ pass
48
+
49
+ def test_dispatch_fix_comment_agent_accepts_model_parameter(self):
50
+ """Test that dispatch_fix_comment_agent accepts model parameter."""
51
+ with patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.ensure_base_clone') as mock_clone, patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.chdir'), patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.TaskDispatcher'), patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.dispatch_agent_for_pr_with_task') as mock_dispatch:
52
+
53
+ mock_clone.return_value = "/tmp/fake/repo"
54
+ mock_dispatch.return_value = True
55
+
56
+ pr_data = {
57
+ "number": 1234,
58
+ "title": "Test PR",
59
+ "headRefName": "test-branch",
60
+ "url": "https://github.com/test/repo/pull/1234"
61
+ }
62
+
63
+ # Should not raise TypeError for model parameter
64
+ try:
65
+ self.monitor.dispatch_fix_comment_agent(
66
+ repository="test/repo",
67
+ pr_number=1234,
68
+ pr_data=pr_data,
69
+ agent_cli="claude",
70
+ model="opus" # Test model parameter
71
+ )
72
+ # Verify it was passed through
73
+ mock_dispatch.assert_called_once()
74
+ call_kwargs = mock_dispatch.call_args[1]
75
+ self.assertEqual(call_kwargs.get("model"), "opus")
76
+ except TypeError as e:
77
+ if "model" in str(e):
78
+ self.fail(f"dispatch_fix_comment_agent does not accept model parameter: {e}")
79
+
80
+ def test_model_parameter_passed_to_dispatcher(self):
81
+ """Test that model parameter is passed through to dispatcher."""
82
+ with patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.TaskDispatcher'), patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.ensure_base_clone'), patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.chdir'), patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.dispatch_agent_for_pr_with_task') as mock_dispatch, patch.object(self.monitor, '_post_fix_comment_queued') as mock_queued, patch.object(self.monitor, '_start_fix_comment_review_watcher') as mock_watcher, patch.object(self.monitor, '_get_pr_comment_state', return_value=(None, [])):
83
+
84
+ mock_dispatch.return_value = True
85
+ mock_queued.return_value = True
86
+ mock_watcher.return_value = True
87
+
88
+ pr_data = {
89
+ "number": 1234,
90
+ "title": "Test PR",
91
+ "headRefName": "test-branch",
92
+ "baseRefName": "main",
93
+ "url": "https://github.com/test/repo/pull/1234",
94
+ "headRepository": {"owner": {"login": "test"}},
95
+ "headRefOid": "abc123"
96
+ }
97
+
98
+ # Process fix-comment with model parameter
99
+ self.monitor._process_pr_fix_comment(
100
+ repository="test/repo",
101
+ pr_number=1234,
102
+ pr_data=pr_data,
103
+ agent_cli="claude",
104
+ model="haiku" # Test model parameter
105
+ )
106
+
107
+ # Verify dispatcher was called with model parameter
108
+ mock_dispatch.assert_called_once()
109
+ call_kwargs = mock_dispatch.call_args[1]
110
+ self.assertEqual(call_kwargs.get("model"), "haiku")
111
+
112
+ def test_cli_argument_parser_has_model_flag(self):
113
+ """Test that CLI argument parser includes --model flag."""
114
+ # Get the argument parser from the module
115
+ parser = argparse.ArgumentParser()
116
+
117
+ # Add the expected arguments (mimicking what main() does)
118
+ parser.add_argument("--model", type=str, default=None,
119
+ help="Model to use for Claude CLI")
120
+
121
+ # Should not raise error
122
+ args = parser.parse_args(["--model", "sonnet"])
123
+ self.assertEqual(args.model, "sonnet")
124
+
125
+ def test_model_defaults_to_none(self):
126
+ """Test that model parameter defaults to None when not provided."""
127
+ parser = argparse.ArgumentParser()
128
+ parser.add_argument("--model", type=str, default=None)
129
+
130
+ # Parse without --model flag
131
+ args = parser.parse_args([])
132
+ self.assertIsNone(args.model)
133
+
134
+ def test_multiple_model_values(self):
135
+ """Test different valid model values."""
136
+ valid_models = ["sonnet", "opus", "haiku", "gemini-3-pro-preview", "composer-1"]
137
+
138
+ for model in valid_models:
139
+ with self.subTest(model=model):
140
+ with patch.object(self.monitor, 'safety_manager') as mock_safety:
141
+ with patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.AutomationUtils'):
142
+ mock_safety.can_start_global_run.return_value = True
143
+ mock_safety.try_process_pr.return_value = True
144
+
145
+ try:
146
+ self.monitor.process_single_pr_by_number(
147
+ pr_number=1234,
148
+ repository="test/repo",
149
+ fix_comment=True,
150
+ agent_cli="claude",
151
+ model=model
152
+ )
153
+ except TypeError as e:
154
+ if "model" in str(e):
155
+ self.fail(f"Model parameter not accepted for value '{model}': {e}")
156
+ except Exception:
157
+ # Other exceptions are okay
158
+ pass
159
+
160
+ def test_fixpr_run_monitoring_cycle_threads_model(self):
161
+ """FixPR mode should pass --model through to _process_pr_fixpr."""
162
+ monitor = JleechanorgPRMonitor()
163
+ pr = {
164
+ "repository": "test/repo",
165
+ "repositoryFullName": "test/repo",
166
+ "number": 123,
167
+ "title": "Test PR",
168
+ "headRefName": "feature/test",
169
+ }
170
+
171
+ with patch.object(monitor, "discover_open_prs", return_value=[pr]), \
172
+ patch.object(monitor, "is_pr_actionable", return_value=True), \
173
+ patch.object(monitor, "_get_pr_comment_state", return_value=(None, [])), \
174
+ patch.object(monitor, "_process_pr_fixpr", return_value="skipped") as mock_fixpr, \
175
+ patch("jleechanorg_pr_automation.jleechanorg_pr_monitor.has_failing_checks", return_value=True), \
176
+ patch("jleechanorg_pr_automation.jleechanorg_pr_monitor.AutomationUtils.execute_subprocess_with_timeout",
177
+ return_value=SimpleNamespace(returncode=0, stdout='{\"mergeable\":\"MERGEABLE\"}')):
178
+
179
+ with patch.object(monitor, "safety_manager") as mock_safety:
180
+ mock_safety.can_start_global_run.return_value = True
181
+ mock_safety.try_process_pr.return_value = True
182
+ mock_safety.get_global_runs.return_value = 1
183
+ mock_safety.global_limit = 50
184
+ mock_safety.fixpr_limit = 10
185
+ mock_safety.pr_limit = 10
186
+ mock_safety.pr_automation_limit = 10
187
+ mock_safety.fix_comment_limit = 10
188
+
189
+ monitor.run_monitoring_cycle(
190
+ max_prs=1,
191
+ cutoff_hours=24,
192
+ fixpr=True,
193
+ agent_cli="claude",
194
+ model="sonnet",
195
+ )
196
+
197
+ self.assertTrue(mock_fixpr.called)
198
+ self.assertEqual(mock_fixpr.call_args[1].get("model"), "sonnet")
199
+
200
+ def test_fixpr_process_pr_threads_model_to_dispatch(self):
201
+ """_process_pr_fixpr should forward model through to dispatch_agent_for_pr."""
202
+ monitor = JleechanorgPRMonitor()
203
+ pr_data = {
204
+ "number": 123,
205
+ "title": "Test PR",
206
+ "headRefName": "feature/test",
207
+ "url": "https://github.com/test/repo/pull/123",
208
+ "headRefOid": "abc123",
209
+ }
210
+
211
+ with patch.object(monitor, "_get_pr_comment_state", return_value=(None, [])), \
212
+ patch.object(monitor, "_should_skip_pr", return_value=False), \
213
+ patch.object(monitor, "_post_fixpr_queued", return_value=True), \
214
+ patch("jleechanorg_pr_automation.jleechanorg_pr_monitor.ensure_base_clone", return_value="/tmp/fake/repo"), \
215
+ patch("jleechanorg_pr_automation.jleechanorg_pr_monitor.chdir"), \
216
+ patch("jleechanorg_pr_automation.jleechanorg_pr_monitor.TaskDispatcher"), \
217
+ patch("jleechanorg_pr_automation.jleechanorg_pr_monitor.dispatch_agent_for_pr", return_value=True) as mock_dispatch:
218
+
219
+ monitor.safety_manager.fixpr_limit = 10
220
+ result = monitor._process_pr_fixpr(
221
+ repository="test/repo",
222
+ pr_number=123,
223
+ pr_data=pr_data,
224
+ agent_cli="claude",
225
+ model="sonnet",
226
+ )
227
+
228
+ self.assertEqual(result, "posted")
229
+ self.assertEqual(mock_dispatch.call_args[1].get("model"), "sonnet")
230
+
231
+
232
+ def test_normalize_model_none_returns_none(self):
233
+ """Test that _normalize_model returns None for None input."""
234
+ result = _normalize_model(None)
235
+ self.assertIsNone(result)
236
+
237
+ def test_normalize_model_empty_string_returns_none(self):
238
+ """Test that _normalize_model returns None for empty string."""
239
+ result = _normalize_model("")
240
+ self.assertIsNone(result)
241
+
242
+ result = _normalize_model(" ")
243
+ self.assertIsNone(result)
244
+
245
+ def test_normalize_model_valid_names(self):
246
+ """Test that _normalize_model accepts valid model names."""
247
+ valid_models = [
248
+ "sonnet",
249
+ "opus",
250
+ "haiku",
251
+ "gemini-3-pro-preview",
252
+ "gemini-3-auto",
253
+ "composer-1",
254
+ "model_name",
255
+ "model.name",
256
+ "model_name_123",
257
+ "a",
258
+ "123",
259
+ ]
260
+
261
+ for model in valid_models:
262
+ with self.subTest(model=model):
263
+ result = _normalize_model(model)
264
+ self.assertEqual(result, model.strip())
265
+
266
+ # Test with whitespace
267
+ result = _normalize_model(f" {model} ")
268
+ self.assertEqual(result, model)
269
+
270
+ def test_normalize_model_invalid_names_raises_error(self):
271
+ """Test that _normalize_model rejects invalid model names."""
272
+ invalid_models = [
273
+ "model with spaces",
274
+ "model@invalid",
275
+ "model#invalid",
276
+ "model$invalid",
277
+ "model%invalid",
278
+ "model&invalid",
279
+ "model*invalid",
280
+ "model+invalid",
281
+ "model=invalid",
282
+ "model[invalid",
283
+ "model]invalid",
284
+ "model{invalid",
285
+ "model}invalid",
286
+ "model|invalid",
287
+ "model\\invalid",
288
+ "model/invalid",
289
+ "model<invalid",
290
+ "model>invalid",
291
+ "model,invalid",
292
+ "model;invalid",
293
+ "model:invalid",
294
+ "model'invalid",
295
+ 'model"invalid',
296
+ "model`invalid",
297
+ "model~invalid",
298
+ "model!invalid",
299
+ "model?invalid",
300
+ ]
301
+
302
+ for model in invalid_models:
303
+ with self.subTest(model=model):
304
+ with self.assertRaises(argparse.ArgumentTypeError):
305
+ _normalize_model(model)
306
+
307
+ def test_normalize_model_strips_whitespace(self):
308
+ """Test that _normalize_model strips whitespace from valid names."""
309
+ result = _normalize_model(" sonnet ")
310
+ self.assertEqual(result, "sonnet")
311
+
312
+ result = _normalize_model("\tgemini-3-auto\n")
313
+ self.assertEqual(result, "gemini-3-auto")
314
+
315
+
316
+ if __name__ == '__main__':
317
+ unittest.main()