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

1"""Tests for GitHub PR review comment integration (#704).""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7from unittest.mock import MagicMock, patch 

8 

9import pytest 

10from assertpy import assert_that 

11 

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 

19 

20_TEST_TOKEN = ( 

21 "ghp_test_fixture_token" # noqa: S105 # nosec B105 — fake test fixture token 

22) 

23_EMPTY_TOKEN = "" # noqa: S105 # nosec B105 

24 

25 

26@pytest.fixture 

27def test_token() -> str: 

28 """Provide a fake GitHub token for testing.""" 

29 return _TEST_TOKEN 

30 

31 

32# -- TestDetectPRNumber: Tests for PR number detection from GITHUB_REF. ------ 

33 

34 

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) 

44 

45 

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) 

53 

54 

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

62 

63 

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

71 

72 

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

77 

78 

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

86 

87 

88# -- TestGitHubPRReporter: Tests for the GitHubPRReporter class. ------------- 

89 

90 

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

99 

100 

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

109 

110 

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

119 

120 

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

130 

131 

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) 

144 

145 

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

151 

152 

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"]) 

161 

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

169 

170 

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 ] 

188 

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

193 

194 

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 ) 

202 

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) 

207 

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"}) 

220 

221 

222# -- TestFormatSummaryComment: Tests for summary comment formatting. --------- 

223 

224 

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

231 

232 

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

243 

244 

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

255 

256 

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

266 

267 

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*") 

276 

277 

278# -- TestFormatInlineComment: Tests for inline comment formatting. ----------- 

279 

280 

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

291 

292 

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

298 

299 

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

309 

310 

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

319 

320 

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

331 

332 

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("``````") 

341 

342 

343# -- TestWorkspaceRelativePaths: workspace_root produces repo-relative paths. - 

344 

345 

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 ] 

365 

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

376 

377 

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 ] 

397 

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