Coverage for tests / unit / ai / test_github_pr.py: 100%
157 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tests for GitHub PR review comment integration (#704)."""
3from __future__ import annotations
5import json
6from pathlib import Path
7from unittest.mock import MagicMock, patch
9import pytest
10from assertpy import assert_that
12from lintro.ai.integrations.github_pr import (
13 GitHubPRReporter,
14 _detect_pr_number,
15 _format_inline_comment,
16 _format_summary_comment,
17)
18from lintro.ai.models import AIFixSuggestion, AISummary
20_TEST_TOKEN = (
21 "ghp_test_fixture_token" # noqa: S105 # nosec B105 — fake test fixture token
22)
23_EMPTY_TOKEN = "" # noqa: S105 # nosec B105
26@pytest.fixture
27def test_token() -> str:
28 """Provide a fake GitHub token for testing."""
29 return _TEST_TOKEN
32# -- TestDetectPRNumber: Tests for PR number detection from GITHUB_REF. ------
35def test_detects_pr_number_from_event_payload(tmp_path: Path) -> None:
36 """Detect PR number from GITHUB_EVENT_PATH payload."""
37 event_file = tmp_path / "event.json"
38 event_file.write_text('{"number": 99}')
39 with patch.dict(
40 "os.environ",
41 {"GITHUB_EVENT_PATH": str(event_file), "GITHUB_REF": "refs/pull/42/merge"},
42 ):
43 assert_that(_detect_pr_number()).is_equal_to(99)
46def test_detects_pr_number_from_ref() -> None:
47 """Detect PR number from GITHUB_REF."""
48 with patch.dict(
49 "os.environ",
50 {"GITHUB_REF": "refs/pull/42/merge", "GITHUB_EVENT_PATH": ""},
51 ):
52 assert_that(_detect_pr_number()).is_equal_to(42)
55def test_returns_none_for_branch_ref() -> None:
56 """Return None for branch refs."""
57 with patch.dict(
58 "os.environ",
59 {"GITHUB_REF": "refs/heads/main", "GITHUB_EVENT_PATH": ""},
60 ):
61 assert_that(_detect_pr_number()).is_none()
64def test_returns_none_for_empty_ref() -> None:
65 """Return None for empty GITHUB_REF."""
66 with patch.dict(
67 "os.environ",
68 {"GITHUB_REF": "", "GITHUB_EVENT_PATH": ""},
69 ):
70 assert_that(_detect_pr_number()).is_none()
73def test_returns_none_for_missing_ref() -> None:
74 """Return None when GITHUB_REF is missing."""
75 with patch.dict("os.environ", {}, clear=True):
76 assert_that(_detect_pr_number()).is_none()
79def test_returns_none_for_malformed_ref() -> None:
80 """Return None for malformed pull ref."""
81 with patch.dict(
82 "os.environ",
83 {"GITHUB_REF": "refs/pull//merge", "GITHUB_EVENT_PATH": ""},
84 ):
85 assert_that(_detect_pr_number()).is_none()
88# -- TestGitHubPRReporter: Tests for the GitHubPRReporter class. -------------
91def test_is_available_with_all_context(test_token: str) -> None:
92 """Report available with token, repo, and PR."""
93 reporter = GitHubPRReporter(
94 token=test_token,
95 repo="owner/repo",
96 pr_number=1,
97 )
98 assert_that(reporter.is_available()).is_true()
101def test_is_not_available_without_token() -> None:
102 """Report unavailable when token is empty."""
103 reporter = GitHubPRReporter(
104 token=_EMPTY_TOKEN,
105 repo="owner/repo",
106 pr_number=1,
107 )
108 assert_that(reporter.is_available()).is_false()
111def test_is_not_available_without_repo(test_token: str) -> None:
112 """Report unavailable when repo is empty."""
113 reporter = GitHubPRReporter(
114 token=test_token,
115 repo="",
116 pr_number=1,
117 )
118 assert_that(reporter.is_available()).is_false()
121def test_is_not_available_without_pr_number(test_token: str) -> None:
122 """Report unavailable when PR number is None."""
123 with patch.dict("os.environ", {"GITHUB_REF": "", "GITHUB_EVENT_PATH": ""}):
124 reporter = GitHubPRReporter(
125 token=test_token,
126 repo="owner/repo",
127 pr_number=None,
128 )
129 assert_that(reporter.is_available()).is_false()
132def test_reads_env_vars() -> None:
133 """Read token, repo, and PR number from environment."""
134 env = {
135 "GITHUB_TOKEN": "ghp_from_env",
136 "GITHUB_REPOSITORY": "org/repo",
137 "GITHUB_REF": "refs/pull/99/merge",
138 }
139 with patch.dict("os.environ", env, clear=True):
140 reporter = GitHubPRReporter()
141 assert_that(reporter.token).is_equal_to("ghp_from_env")
142 assert_that(reporter.repo).is_equal_to("org/repo")
143 assert_that(reporter.pr_number).is_equal_to(99)
146def test_post_review_comments_returns_false_when_unavailable() -> None:
147 """Return False when reporter is unavailable."""
148 reporter = GitHubPRReporter(token=_EMPTY_TOKEN, repo="", pr_number=None)
149 result = reporter.post_review_comments([], summary=None)
150 assert_that(result).is_false()
153def test_post_review_comments_posts_summary(test_token: str) -> None:
154 """Post summary as issue comment."""
155 reporter = GitHubPRReporter(
156 token=test_token,
157 repo="owner/repo",
158 pr_number=5,
159 )
160 summary = AISummary(overview="Test overview", key_patterns=["pattern1"])
162 with patch.object(reporter, "_post_issue_comment", return_value=True) as mock:
163 result = reporter.post_review_comments([], summary=summary)
164 assert_that(result).is_true()
165 mock.assert_called_once()
166 body = mock.call_args[0][0]
167 assert_that(body).contains("Test overview")
168 assert_that(body).contains("pattern1")
171def test_post_review_comments_posts_suggestions(test_token: str) -> None:
172 """Post fix suggestions as review comments."""
173 reporter = GitHubPRReporter(
174 token=test_token,
175 repo="owner/repo",
176 pr_number=5,
177 )
178 suggestions = [
179 AIFixSuggestion(
180 file="src/main.py",
181 line=10,
182 code="B101",
183 tool_name="bandit",
184 explanation="Replace assert",
185 confidence="high",
186 ),
187 ]
189 with patch.object(reporter, "_post_review", return_value=True) as mock:
190 result = reporter.post_review_comments(suggestions)
191 assert_that(result).is_true()
192 mock.assert_called_once()
195def test_api_request_constructs_correct_request(test_token: str) -> None:
196 """Construct API request with auth header and JSON body."""
197 reporter = GitHubPRReporter(
198 token=test_token,
199 repo="owner/repo",
200 pr_number=5,
201 )
203 mock_response = MagicMock()
204 mock_response.status = 201
205 mock_response.__enter__ = MagicMock(return_value=mock_response)
206 mock_response.__exit__ = MagicMock(return_value=False)
208 with patch("urllib.request.urlopen", return_value=mock_response) as mock_open:
209 result = reporter._api_request(
210 "POST",
211 "https://api.github.com/test",
212 {"key": "value"},
213 )
214 assert_that(result).is_true()
215 req = mock_open.call_args[0][0]
216 assert_that(req.get_header("Authorization")).is_equal_to(
217 f"Bearer {test_token}",
218 )
219 assert_that(json.loads(req.data)).is_equal_to({"key": "value"})
222# -- TestFormatSummaryComment: Tests for summary comment formatting. ---------
225def test_includes_overview() -> None:
226 """Include overview text in summary comment."""
227 summary = AISummary(overview="High-level assessment")
228 result = _format_summary_comment(summary)
229 assert_that(result).contains("High-level assessment")
230 assert_that(result).contains("## Lintro AI Summary")
233def test_includes_key_patterns() -> None:
234 """Include key patterns section in summary."""
235 summary = AISummary(
236 overview="Overview",
237 key_patterns=["Missing types", "No tests"],
238 )
239 result = _format_summary_comment(summary)
240 assert_that(result).contains("### Key Patterns")
241 assert_that(result).contains("- Missing types")
242 assert_that(result).contains("- No tests")
245def test_includes_priority_actions() -> None:
246 """Include numbered priority actions in summary."""
247 summary = AISummary(
248 overview="Overview",
249 priority_actions=["Fix imports", "Add tests"],
250 )
251 result = _format_summary_comment(summary)
252 assert_that(result).contains("### Priority Actions")
253 assert_that(result).contains("1. Fix imports")
254 assert_that(result).contains("2. Add tests")
257def test_includes_triage_suggestions() -> None:
258 """Include triage suggestions in summary."""
259 summary = AISummary(
260 overview="Overview",
261 triage_suggestions=["Consider suppressing X"],
262 )
263 result = _format_summary_comment(summary)
264 assert_that(result).contains("### Triage")
265 assert_that(result).contains("- Consider suppressing X")
268def test_includes_effort_estimate() -> None:
269 """Include effort estimate in summary."""
270 summary = AISummary(
271 overview="Overview",
272 estimated_effort="2-3 hours",
273 )
274 result = _format_summary_comment(summary)
275 assert_that(result).contains("*Estimated effort: 2-3 hours*")
278# -- TestFormatInlineComment: Tests for inline comment formatting. -----------
281def test_includes_code_and_tool() -> None:
282 """Include rule code and tool name in comment."""
283 s = AIFixSuggestion(
284 code="B101",
285 tool_name="bandit",
286 explanation="Replace assert",
287 )
288 result = _format_inline_comment(s)
289 assert_that(result).contains("**B101**")
290 assert_that(result).contains("(bandit)")
293def test_includes_explanation() -> None:
294 """Include explanation text in comment."""
295 s = AIFixSuggestion(explanation="Use if/raise instead")
296 result = _format_inline_comment(s)
297 assert_that(result).contains("Use if/raise instead")
300def test_includes_diff() -> None:
301 """Include diff block in comment."""
302 s = AIFixSuggestion(
303 diff="-old\n+new",
304 explanation="Fix",
305 )
306 result = _format_inline_comment(s)
307 assert_that(result).contains("```diff")
308 assert_that(result).contains("-old\n+new")
311def test_includes_suggestion_block() -> None:
312 """Include suggestion code block in comment."""
313 s = AIFixSuggestion(
314 suggested_code="if not x:\n raise ValueError",
315 explanation="Fix",
316 )
317 result = _format_inline_comment(s)
318 assert_that(result).contains("```suggestion")
321def test_includes_confidence_and_risk() -> None:
322 """Include confidence and risk level in comment."""
323 s = AIFixSuggestion(
324 confidence="high",
325 risk_level="safe-style",
326 explanation="Fix",
327 )
328 result = _format_inline_comment(s)
329 assert_that(result).contains("Confidence: high")
330 assert_that(result).contains("Risk: safe-style")
333def test_sanitizes_backticks_in_diff() -> None:
334 """Sanitize triple backticks inside diff content."""
335 s = AIFixSuggestion(
336 diff="```python\ncode\n```",
337 explanation="Fix",
338 )
339 result = _format_inline_comment(s)
340 assert_that(result).does_not_contain("``````")
343# -- TestWorkspaceRelativePaths: workspace_root produces repo-relative paths. -
346def test_post_review_uses_workspace_relative_paths(test_token: str) -> None:
347 """Use workspace-relative paths when workspace_root is set."""
348 workspace = Path("/home/runner/work/repo")
349 reporter = GitHubPRReporter(
350 token=test_token,
351 repo="owner/repo",
352 pr_number=5,
353 workspace_root=workspace,
354 )
355 suggestions = [
356 AIFixSuggestion(
357 file=str(workspace / "src" / "main.py"),
358 line=10,
359 code="B101",
360 tool_name="bandit",
361 explanation="Replace assert",
362 confidence="high",
363 ),
364 ]
366 diff = {"src/main.py": {10}}
367 with (
368 patch.object(reporter, "_fetch_pr_diff_lines", return_value=diff),
369 patch.object(reporter, "_api_request", return_value=True) as mock_api,
370 ):
371 reporter._post_review(suggestions)
372 payload = mock_api.call_args[0][2]
373 comment_path = payload["comments"][0]["path"]
374 # Should be relative to workspace_root, not an absolute path
375 assert_that(comment_path).is_equal_to("src/main.py")
378def test_post_review_skips_out_of_workspace_suggestions(test_token: str) -> None:
379 """Suggestions with files outside workspace_root are silently skipped."""
380 workspace = Path("/home/runner/work/repo")
381 reporter = GitHubPRReporter(
382 token=test_token,
383 repo="owner/repo",
384 pr_number=5,
385 workspace_root=workspace,
386 )
387 suggestions = [
388 AIFixSuggestion(
389 file="/tmp/outside/secret.py",
390 line=5,
391 code="B101",
392 tool_name="bandit",
393 explanation="Fix",
394 confidence="high",
395 ),
396 ]
398 diff = {"some/other/file.py": {1, 2, 3}}
399 with (
400 patch.object(reporter, "_fetch_pr_diff_lines", return_value=diff),
401 patch.object(reporter, "_api_request", return_value=True) as mock_api,
402 ):
403 reporter._post_review(suggestions)
404 # Out-of-workspace suggestion should be filtered; no API call made
405 mock_api.assert_not_called()