mcp-github-agent 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.
@@ -0,0 +1,162 @@
1
+ """Tests for review_engine.py."""
2
+ from unittest.mock import Mock, patch
3
+
4
+ import pytest
5
+
6
+ from src.analyzers.base import Finding
7
+ from src.diff_parser import ChangedFile
8
+ from src.review_engine import (
9
+ _DEFAULT_MAX_DIFF_BYTES,
10
+ _MAX_DIFF_BYTES_HARD_CAP,
11
+ _get_max_diff_bytes,
12
+ ReviewService,
13
+ )
14
+
15
+
16
+ def test_review_filters_analyzer_findings_to_changed_python_lines():
17
+ analyzer = Mock()
18
+ analyzer.analyze.return_value = [
19
+ Finding("warning", "src/app.py", 2, "F401", "unused", source="ruff"),
20
+ Finding("warning", "src/app.py", 9, "E501", "long", source="ruff"),
21
+ ]
22
+ service = ReviewService()
23
+ service.analyzers = [analyzer]
24
+
25
+ with patch("src.review_engine.parse_diff", return_value=[
26
+ ChangedFile("src/app.py", added_lines={2}),
27
+ ChangedFile("README.md", added_lines={1}),
28
+ ]), patch("src.review_engine.legacy_review", return_value=[]):
29
+ findings = service.review("diff")
30
+
31
+ assert [f.rule for f in findings] == ["F401"]
32
+ analyzer.analyze.assert_called_once_with("src/app.py")
33
+
34
+
35
+ def test_review_ignores_analyzer_errors_and_adds_legacy_findings():
36
+ analyzer = Mock()
37
+ analyzer.analyze.side_effect = RuntimeError("ruff failed")
38
+ service = ReviewService()
39
+ service.analyzers = [analyzer]
40
+
41
+ with patch("src.review_engine.parse_diff", return_value=[
42
+ ChangedFile("src/app.py", added_lines={2}),
43
+ ]), patch("src.review_engine.legacy_review", return_value=[
44
+ {
45
+ "severity": "error",
46
+ "line": 2,
47
+ "rule": "no-bare-except",
48
+ "message": "Bare except clause",
49
+ "file": "src/app.py",
50
+ }
51
+ ]):
52
+ findings = service.review("diff")
53
+
54
+ assert len(findings) == 1
55
+ assert findings[0].rule == "no-bare-except"
56
+ assert findings[0].source == "regex"
57
+
58
+
59
+ def test_review_service_handles_missing_ruff_analyzer():
60
+ with patch("src.review_engine.RuffAnalyzer", side_effect=RuntimeError("missing")):
61
+ service = ReviewService()
62
+
63
+ assert service.analyzers == []
64
+
65
+
66
+ def test_review_rejects_diff_larger_than_500kb(monkeypatch):
67
+ monkeypatch.setenv("GITHUB_REVIEW_MAX_DIFF_BYTES", "1024") # 1KB limit
68
+ assert _get_max_diff_bytes() == 1024
69
+
70
+ service = ReviewService()
71
+ huge_diff = "x" * 2048 # 2KB
72
+ findings = service.review(huge_diff)
73
+ assert len(findings) == 1
74
+ assert findings[0].rule == "diff-too-large"
75
+ assert findings[0].severity == "error"
76
+
77
+
78
+ def test_review_processes_diff_exactly_at_size_limit(monkeypatch):
79
+ monkeypatch.setenv("GITHUB_REVIEW_MAX_DIFF_BYTES", str(_DEFAULT_MAX_DIFF_BYTES))
80
+ service = ReviewService()
81
+ boundary_diff = "x" * _DEFAULT_MAX_DIFF_BYTES
82
+
83
+ with patch("src.review_engine.parse_diff", return_value=[]) as parse, \
84
+ patch("src.review_engine.legacy_review", return_value=[]) as legacy:
85
+ findings = service.review(boundary_diff)
86
+
87
+ assert findings == []
88
+ parse.assert_called_once_with(boundary_diff)
89
+ legacy.assert_called_once_with(boundary_diff)
90
+
91
+
92
+ def test_review_rejects_diff_one_byte_over_default_limit(monkeypatch):
93
+ monkeypatch.delenv("GITHUB_REVIEW_MAX_DIFF_BYTES", raising=False)
94
+ service = ReviewService()
95
+ oversized_diff = "x" * (_DEFAULT_MAX_DIFF_BYTES + 1)
96
+
97
+ with patch("src.review_engine.parse_diff") as parse, \
98
+ patch("src.review_engine.legacy_review") as legacy:
99
+ findings = service.review(oversized_diff)
100
+
101
+ assert len(findings) == 1
102
+ assert findings[0].rule == "diff-too-large"
103
+ assert findings[0].severity == "error"
104
+ assert findings[0].source == "review_engine"
105
+ parse.assert_not_called()
106
+ legacy.assert_not_called()
107
+
108
+
109
+ def test_review_allows_empty_diff():
110
+ service = ReviewService()
111
+
112
+ with patch("src.review_engine.parse_diff", return_value=[]) as parse, \
113
+ patch("src.review_engine.legacy_review", return_value=[]) as legacy:
114
+ findings = service.review("")
115
+
116
+ assert findings == []
117
+ parse.assert_called_once_with("")
118
+ legacy.assert_called_once_with("")
119
+
120
+
121
+ def test_review_handles_binary_like_decoded_diff_text(monkeypatch):
122
+ monkeypatch.setenv("GITHUB_REVIEW_MAX_DIFF_BYTES", "1024")
123
+ service = ReviewService()
124
+ binary_like_diff = "diff --git a/blob b/blob\n+\x00\x80\xff"
125
+
126
+ with patch("src.review_engine.parse_diff", return_value=[]) as parse, \
127
+ patch("src.review_engine.legacy_review", return_value=[]) as legacy:
128
+ findings = service.review(binary_like_diff)
129
+
130
+ assert findings == []
131
+ parse.assert_called_once_with(binary_like_diff)
132
+ legacy.assert_called_once_with(binary_like_diff)
133
+
134
+
135
+ @pytest.mark.xfail(
136
+ raises=UnicodeEncodeError,
137
+ reason="ReviewService.review accepts str and currently crashes on unpaired surrogates.",
138
+ )
139
+ def test_review_current_gap_non_utf8_surrogateescape_text():
140
+ ReviewService().review("\udcff")
141
+
142
+
143
+ def test_invalid_max_diff_size_env_falls_back_to_default(monkeypatch):
144
+ monkeypatch.setenv("GITHUB_REVIEW_MAX_DIFF_BYTES", "abc")
145
+
146
+ assert _get_max_diff_bytes() == _DEFAULT_MAX_DIFF_BYTES
147
+
148
+
149
+ def test_large_max_diff_size_env_override_is_clamped_to_hard_cap(monkeypatch, caplog):
150
+ ten_gib = 10 * 1024 * 1024 * 1024
151
+ monkeypatch.setenv("GITHUB_REVIEW_MAX_DIFF_BYTES", str(ten_gib))
152
+
153
+ assert _get_max_diff_bytes() == _MAX_DIFF_BYTES_HARD_CAP
154
+ assert "exceeds hard cap" in caplog.text
155
+
156
+
157
+ def test_review_allows_diff_under_limit():
158
+ service = ReviewService()
159
+ with patch("src.review_engine.parse_diff", return_value=[]), \
160
+ patch("src.review_engine.legacy_review", return_value=[]):
161
+ findings = service.review("small diff")
162
+ assert all(f.rule != "diff-too-large" for f in findings)
@@ -0,0 +1,78 @@
1
+ """Tests for analyzers/ruff.py."""
2
+ import json
3
+ import subprocess
4
+ from types import SimpleNamespace
5
+ from unittest.mock import patch
6
+
7
+ from src.analyzers.ruff import RuffAnalyzer
8
+
9
+
10
+ def test_ruff_analyzer_skips_non_python_files():
11
+ with patch("src.analyzers.ruff.subprocess.run") as mock_run:
12
+ assert RuffAnalyzer().analyze("README.md") == []
13
+ mock_run.assert_not_called()
14
+
15
+
16
+ def test_ruff_analyzer_parses_json_findings():
17
+ payload = [
18
+ {
19
+ "filename": "src/app.py",
20
+ "location": {"row": 3},
21
+ "code": "F401",
22
+ "message": "unused import",
23
+ "fix": {"message": "Remove import"},
24
+ },
25
+ {
26
+ "filename": "src/app.py",
27
+ "location": {"row": 5},
28
+ "code": "E501",
29
+ "message": "line too long",
30
+ },
31
+ ]
32
+ completed = SimpleNamespace(returncode=1, stdout=json.dumps(payload))
33
+
34
+ with patch("src.analyzers.ruff.subprocess.run", return_value=completed) as mock_run:
35
+ findings = RuffAnalyzer("custom-ruff").analyze("src/app.py")
36
+
37
+ assert [f.rule for f in findings] == ["F401", "E501"]
38
+ assert findings[0].severity == "error"
39
+ assert findings[0].suggestion == "Remove import"
40
+ assert findings[1].severity == "warning"
41
+ mock_run.assert_called_once_with(
42
+ ["custom-ruff", "check", "--output-format", "json", "src/app.py"],
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=30,
46
+ )
47
+
48
+
49
+ def test_ruff_analyzer_returns_empty_for_clean_output_and_failures():
50
+ with patch(
51
+ "src.analyzers.ruff.subprocess.run",
52
+ return_value=SimpleNamespace(returncode=0, stdout=""),
53
+ ):
54
+ assert RuffAnalyzer().analyze("src/app.py") == []
55
+
56
+ with patch(
57
+ "src.analyzers.ruff.subprocess.run",
58
+ return_value=SimpleNamespace(returncode=2, stdout="[]"),
59
+ ):
60
+ assert RuffAnalyzer().analyze("src/app.py") == []
61
+
62
+ with patch(
63
+ "src.analyzers.ruff.subprocess.run",
64
+ return_value=SimpleNamespace(returncode=1, stdout="not-json"),
65
+ ):
66
+ assert RuffAnalyzer().analyze("src/app.py") == []
67
+
68
+ with patch(
69
+ "src.analyzers.ruff.subprocess.run",
70
+ side_effect=FileNotFoundError,
71
+ ):
72
+ assert RuffAnalyzer().analyze("src/app.py") == []
73
+
74
+ with patch(
75
+ "src.analyzers.ruff.subprocess.run",
76
+ side_effect=subprocess.TimeoutExpired("ruff", 30),
77
+ ):
78
+ assert RuffAnalyzer().analyze("src/app.py") == []
tests/test_tools.py ADDED
@@ -0,0 +1,326 @@
1
+ """Tests for GitHub MCP tools"""
2
+ import unittest
3
+ from unittest.mock import patch, Mock
4
+ from src.analyzers.base import Finding
5
+ from src.policy import PolicyDecision
6
+ from src.tools import (
7
+ search_code,
8
+ list_issues,
9
+ get_pr_diff,
10
+ review_pr_diff,
11
+ comment_pr_review,
12
+ create_issue,
13
+ create_pr,
14
+ )
15
+ from src.review import review_diff
16
+
17
+
18
+ class TestTools(unittest.TestCase):
19
+
20
+ def _allow_policy(self):
21
+ policy = Mock()
22
+ policy.check_repo.return_value = PolicyDecision(
23
+ "allow", "repo allowed", "repo_allowlist:owner/*"
24
+ )
25
+ policy.check_branch_for_pr.return_value = PolicyDecision(
26
+ "allow", "branch allowed", "branch_unprotected"
27
+ )
28
+ return policy
29
+
30
+ def _audit(self):
31
+ audit = Mock()
32
+ audit.log.return_value = None
33
+ return audit
34
+
35
+ @patch('src.tools.GitHubClient')
36
+ def test_search_code(self, mock_client_class):
37
+ """Test search_code tool with mocked httpx response"""
38
+ # Setup mock
39
+ mock_client = Mock()
40
+ mock_client_class.return_value = mock_client
41
+ mock_client.search_code.return_value = {
42
+ "items": [
43
+ {
44
+ 'path': 'src/main.py',
45
+ 'repo': 'owner/test-repo',
46
+ 'url': 'https://github.com/owner/test-repo/blob/main/src/main.py'
47
+ }
48
+ ]
49
+ }
50
+
51
+ # Call function
52
+ result = search_code("def main", "owner/test-repo")
53
+
54
+ # Verify
55
+ self.assertIn("Found 1 results", result)
56
+ self.assertIn("src/main.py in owner/test-repo", result)
57
+ self.assertIn("https://github.com/owner/test-repo", result)
58
+ mock_client.search_code.assert_called_once_with("def main", "owner/test-repo")
59
+
60
+ def test_review_diff(self):
61
+ """Test review_diff catches print statement"""
62
+ diff_text = """diff --git a/src/test.py b/src/test.py
63
+ index 1234567..abcdefg 100644
64
+ --- a/src/test.py
65
+ +++ b/src/test.py
66
+ @@ -1,3 +1,4 @@
67
+ def hello():
68
+ + print("Debug message")
69
+ return "world"
70
+ """
71
+
72
+ issues = review_diff(diff_text)
73
+
74
+ # Should find the print statement
75
+ self.assertEqual(len(issues), 1)
76
+ self.assertEqual(issues[0]['severity'], 'warning')
77
+ self.assertEqual(issues[0]['rule'], 'no-print')
78
+ self.assertIn('Print statement found', issues[0]['message'])
79
+
80
+
81
+ def test_search_empty_results(self):
82
+ """Test search_code returns proper message for no results"""
83
+ from unittest.mock import patch, Mock
84
+ with patch('src.tools.GitHubClient') as mock_cls:
85
+ mock_cls.return_value.search_code.return_value = {"items": []}
86
+ result = search_code("nonexistent", "owner/repo")
87
+ self.assertIn("No results", result)
88
+
89
+ def test_search_error(self):
90
+ """Test search_code handles API errors gracefully"""
91
+ from unittest.mock import patch, Mock
92
+ with patch('src.tools.GitHubClient') as mock_cls:
93
+ mock_cls.return_value.search_code.return_value = {"error": "API rate limit exceeded"}
94
+ result = search_code("test", "owner/repo")
95
+ self.assertIn("Error", result)
96
+ self.assertIn("rate limit", result)
97
+
98
+ def test_list_issues_success(self):
99
+ with patch('src.tools.GitHubClient') as mock_cls:
100
+ mock_cls.return_value.list_issues.return_value = [
101
+ {"number": 1, "title": "Bug", "html_url": "https://example/1"}
102
+ ]
103
+ result = list_issues("owner/repo", "closed")
104
+ self.assertIn("Issues in owner/repo (closed)", result)
105
+ self.assertIn("#1: Bug", result)
106
+ mock_cls.return_value.list_issues.assert_called_once_with("owner/repo", "closed")
107
+
108
+ def test_list_issues_empty_and_error(self):
109
+ with patch('src.tools.GitHubClient') as mock_cls:
110
+ mock_cls.return_value.list_issues.return_value = []
111
+ self.assertIn("No open issues", list_issues("owner/repo"))
112
+
113
+ mock_cls.return_value.list_issues.return_value = {"error": "bad credentials"}
114
+ self.assertEqual(list_issues("owner/repo"), "Error: bad credentials")
115
+
116
+ def test_get_pr_diff_success_and_error(self):
117
+ with patch('src.tools.GitHubClient') as mock_cls:
118
+ mock_cls.return_value.get_pr_diff.return_value = {"diff": "+change"}
119
+ self.assertIn("PR #7 diff:\n\n+change", get_pr_diff("owner/repo", 7))
120
+
121
+ mock_cls.return_value.get_pr_diff.return_value = {"error": "not found"}
122
+ self.assertEqual(get_pr_diff("owner/repo", 7), "Error: not found")
123
+
124
+ def test_review_pr_diff_formats_findings(self):
125
+ finding = Finding(
126
+ severity="error", file="src/app.py", line=3, rule="F401",
127
+ message="unused import", source="ruff"
128
+ )
129
+ with patch('src.tools.GitHubClient') as mock_cls, \
130
+ patch('src.tools.ReviewService') as mock_review:
131
+ mock_cls.return_value.get_pr_diff.return_value = {"diff": "diff"}
132
+ mock_review.return_value.review.return_value = [finding]
133
+ result = review_pr_diff("owner/repo", 8)
134
+ self.assertIn("Code review for PR #8 (1 issues)", result)
135
+ self.assertIn("src/app.py:3", result)
136
+ self.assertIn("[F401/ruff]", result)
137
+
138
+ def test_review_pr_diff_handles_error_and_no_findings(self):
139
+ with patch('src.tools.GitHubClient') as mock_cls, \
140
+ patch('src.tools.ReviewService') as mock_review:
141
+ mock_cls.return_value.get_pr_diff.return_value = {"error": "rate limited"}
142
+ self.assertEqual(review_pr_diff("owner/repo", 8), "Error: rate limited")
143
+
144
+ mock_cls.return_value.get_pr_diff.return_value = {"diff": "diff"}
145
+ mock_review.return_value.review.side_effect = RuntimeError("ruff crashed")
146
+ self.assertIn("looks good", review_pr_diff("owner/repo", 8))
147
+
148
+ def test_review_pr_diff_returns_diff_too_large_finding(self):
149
+ with patch.dict(
150
+ "os.environ",
151
+ {"GITHUB_REVIEW_MAX_DIFF_BYTES": "1024"},
152
+ clear=False,
153
+ ), patch('src.tools.GitHubClient') as mock_cls:
154
+ mock_cls.return_value.get_pr_diff.return_value = {"diff": "x" * 1025}
155
+
156
+ result = review_pr_diff("owner/repo", 8)
157
+
158
+ self.assertIn("Code review for PR #8 (1 issues)", result)
159
+ self.assertIn("Diff too large (1 KB, limit 1 KB)", result)
160
+ self.assertIn("[diff-too-large/review_engine]", result)
161
+ self.assertIn(":0", result)
162
+
163
+ def test_comment_pr_review_posts_top_findings(self):
164
+ findings = [
165
+ Finding("warning", f"src/{i}.py", i, "R", f"message {i}", source="regex")
166
+ for i in range(1, 12)
167
+ ]
168
+ with patch('src.tools.GitHubClient') as mock_cls, \
169
+ patch('src.tools.ReviewService') as mock_review:
170
+ client = mock_cls.return_value
171
+ client.get_pr_diff.return_value = {"diff": "diff"}
172
+ client.create_review_comment.side_effect = [{"id": i} for i in range(10)]
173
+ mock_review.return_value.review.return_value = findings
174
+
175
+ result = comment_pr_review("owner/repo", 9)
176
+
177
+ self.assertIn("Posted 10 review comments", result)
178
+ self.assertIn("11 total issues", result)
179
+ self.assertEqual(client.create_review_comment.call_count, 10)
180
+
181
+ def test_comment_pr_review_counts_failed_posts_and_empty_review(self):
182
+ finding = Finding("warning", "src/app.py", 5, "R", "message", source="regex")
183
+ with patch('src.tools.GitHubClient') as mock_cls, \
184
+ patch('src.tools.ReviewService') as mock_review:
185
+ client = mock_cls.return_value
186
+ client.get_pr_diff.return_value = {"error": "missing diff"}
187
+ self.assertEqual(comment_pr_review("owner/repo", 9), "Error: missing diff")
188
+
189
+ client.get_pr_diff.return_value = {"diff": "diff"}
190
+ mock_review.return_value.review.return_value = []
191
+ self.assertIn("looks good", comment_pr_review("owner/repo", 9))
192
+
193
+ mock_review.return_value.review.return_value = [finding]
194
+ client.create_review_comment.return_value = {"error": "validation failed"}
195
+ self.assertIn("Posted 0 review comments", comment_pr_review("owner/repo", 9))
196
+
197
+ def test_create_issue_success(self):
198
+ """Test create_issue tool formats success response"""
199
+ with patch('src.tools.GitHubClient') as mock_cls, \
200
+ patch('src.tools._get_policy', return_value=self._allow_policy()), \
201
+ patch('src.tools._get_audit', return_value=self._audit()):
202
+ mock_cls.return_value.create_issue.return_value = {
203
+ "number": 42, "title": "Test Issue", "html_url": "https://github.com/owner/repo/issues/42"
204
+ }
205
+ result = create_issue("owner/repo", "Test Issue", "Body text")
206
+ self.assertIn("42", result)
207
+ self.assertIn("Test Issue", result)
208
+
209
+ def test_create_issue_error(self):
210
+ """Test create_issue handles errors"""
211
+ with patch('src.tools.GitHubClient') as mock_cls, \
212
+ patch('src.tools._get_policy', return_value=self._allow_policy()), \
213
+ patch('src.tools._get_audit', return_value=self._audit()):
214
+ mock_cls.return_value.create_issue.return_value = {"error": "Not authorized"}
215
+ result = create_issue("owner/repo", "Test", "Body")
216
+ self.assertIn("Error", result)
217
+
218
+ def test_create_issue_policy_denied_and_dry_run(self):
219
+ denied_policy = self._allow_policy()
220
+ denied_policy.check_repo.return_value = PolicyDecision(
221
+ "deny", "repo blocked", "repo_allowlist:deny_unlisted"
222
+ )
223
+ audit = self._audit()
224
+ with patch('src.tools._get_policy', return_value=denied_policy), \
225
+ patch('src.tools._get_audit', return_value=audit), \
226
+ patch('src.tools.GitHubClient') as mock_cls:
227
+ result = create_issue("owner/repo", "Title", "Body")
228
+ self.assertIn("Policy Denied: repo blocked", result)
229
+ mock_cls.assert_not_called()
230
+ audit.log.assert_called_once()
231
+
232
+ with patch('src.tools._get_policy', return_value=self._allow_policy()), \
233
+ patch('src.tools._get_audit', return_value=self._audit()), \
234
+ patch('src.tools.GitHubClient') as mock_cls:
235
+ result = create_issue("owner/repo", "Title", "x" * 130, dry_run=True)
236
+ self.assertIn("[DRY RUN] Would create issue", result)
237
+ self.assertIn("...", result)
238
+ mock_cls.assert_not_called()
239
+
240
+ def test_create_pr_success_and_error(self):
241
+ with patch('src.tools.GitHubClient') as mock_cls, \
242
+ patch('src.tools._get_policy', return_value=self._allow_policy()), \
243
+ patch('src.tools._get_audit', return_value=self._audit()):
244
+ mock_cls.return_value.create_pr.return_value = {
245
+ "number": 5, "title": "Fix", "html_url": "https://example/pr/5"
246
+ }
247
+ result = create_pr("owner/repo", "Fix", "Body", "feature", "develop")
248
+ self.assertIn("PR created: #5: Fix", result)
249
+ mock_cls.return_value.create_pr.assert_called_once_with(
250
+ "owner/repo", "Fix", "Body", "feature", "develop"
251
+ )
252
+
253
+ mock_cls.return_value.create_pr.return_value = {"error": "branch missing"}
254
+ self.assertEqual(
255
+ create_pr("owner/repo", "Fix", "Body", "feature", "develop"),
256
+ "Error: branch missing",
257
+ )
258
+
259
+ def test_create_pr_policy_denials_and_dry_run(self):
260
+ repo_denied = self._allow_policy()
261
+ repo_denied.check_repo.return_value = PolicyDecision(
262
+ "deny", "repo blocked", "repo_allowlist:deny_unlisted"
263
+ )
264
+ branch_denied = self._allow_policy()
265
+ branch_denied.check_branch_for_pr.return_value = PolicyDecision(
266
+ "deny", "main blocked", "protected_branch:main"
267
+ )
268
+
269
+ with patch('src.tools._get_policy', return_value=repo_denied), \
270
+ patch('src.tools._get_audit', return_value=self._audit()), \
271
+ patch('src.tools.GitHubClient') as mock_cls:
272
+ self.assertIn(
273
+ "Policy Denied: repo blocked",
274
+ create_pr("owner/repo", "Fix", "Body", "feature", "main"),
275
+ )
276
+ mock_cls.assert_not_called()
277
+
278
+ with patch('src.tools._get_policy', return_value=branch_denied), \
279
+ patch('src.tools._get_audit', return_value=self._audit()), \
280
+ patch('src.tools.GitHubClient') as mock_cls:
281
+ self.assertIn(
282
+ "Policy Denied: main blocked",
283
+ create_pr("owner/repo", "Fix", "Body", "feature", "main"),
284
+ )
285
+ mock_cls.assert_not_called()
286
+
287
+ with patch('src.tools._get_policy', return_value=self._allow_policy()), \
288
+ patch('src.tools._get_audit', return_value=self._audit()), \
289
+ patch('src.tools.GitHubClient') as mock_cls:
290
+ result = create_pr(
291
+ "owner/repo", "Fix", "Body", "feature", "develop", dry_run=True
292
+ )
293
+ self.assertIn("[DRY RUN] Would create PR", result)
294
+ self.assertIn("Head: feature", result)
295
+ mock_cls.assert_not_called()
296
+
297
+ def test_review_diff_multiple_issues(self):
298
+ """Test review_diff catches multiple rule violations"""
299
+ diff = """diff --git a/src/app.py b/src/app.py
300
+ @@ -1,8 +1,12 @@
301
+ def process():
302
+ + password = "secret123"
303
+ data = get_data()
304
+ + print(data)
305
+ + # TODO: fix this later
306
+ return data
307
+ +except:
308
+ + pass
309
+ """
310
+ issues = review_diff(diff)
311
+ self.assertGreaterEqual(len(issues), 3)
312
+ rules = [i['rule'] for i in issues]
313
+ self.assertIn('no-print', rules)
314
+ self.assertIn('no-hardcoded-secrets', rules)
315
+ self.assertIn('no-todo-comments', rules)
316
+
317
+ def test_review_diff_clean(self):
318
+ """Test review_diff returns empty for clean code"""
319
+ diff = """diff --git a/src/app.py b/src/app.py
320
+ @@ -1,3 +1,4 @@
321
+ def hello():
322
+ + logger.info("Starting")
323
+ return True
324
+ """
325
+ issues = review_diff(diff)
326
+ self.assertEqual(len(issues), 0)