xai-review 0.26.0__py3-none-any.whl → 0.28.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.

Potentially problematic release.


This version of xai-review might be problematic. Click here for more details.

Files changed (164) hide show
  1. ai_review/cli/commands/run_inline_reply_review.py +7 -0
  2. ai_review/cli/commands/run_summary_reply_review.py +7 -0
  3. ai_review/cli/main.py +17 -0
  4. ai_review/clients/bitbucket/pr/client.py +45 -8
  5. ai_review/clients/bitbucket/pr/schema/comments.py +21 -2
  6. ai_review/clients/bitbucket/pr/schema/files.py +8 -3
  7. ai_review/clients/bitbucket/pr/schema/pull_request.py +1 -5
  8. ai_review/clients/bitbucket/pr/schema/user.py +7 -0
  9. ai_review/clients/bitbucket/tools.py +6 -0
  10. ai_review/clients/github/pr/client.py +98 -13
  11. ai_review/clients/github/pr/schema/comments.py +23 -1
  12. ai_review/clients/github/pr/schema/files.py +2 -1
  13. ai_review/clients/github/pr/schema/pull_request.py +1 -4
  14. ai_review/clients/github/pr/schema/reviews.py +2 -1
  15. ai_review/clients/github/pr/schema/user.py +6 -0
  16. ai_review/clients/github/pr/types.py +11 -1
  17. ai_review/clients/github/tools.py +6 -0
  18. ai_review/clients/gitlab/mr/client.py +67 -7
  19. ai_review/clients/gitlab/mr/schema/changes.py +1 -5
  20. ai_review/clients/gitlab/mr/schema/discussions.py +19 -8
  21. ai_review/clients/gitlab/mr/schema/notes.py +5 -1
  22. ai_review/clients/gitlab/mr/schema/user.py +7 -0
  23. ai_review/clients/gitlab/mr/types.py +16 -7
  24. ai_review/clients/gitlab/tools.py +5 -0
  25. ai_review/libs/config/prompt.py +96 -64
  26. ai_review/libs/config/review.py +2 -0
  27. ai_review/libs/config/vcs/base.py +2 -0
  28. ai_review/libs/config/vcs/pagination.py +6 -0
  29. ai_review/libs/http/paginate.py +43 -0
  30. ai_review/libs/llm/output_json_parser.py +60 -0
  31. ai_review/prompts/default_inline_reply.md +10 -0
  32. ai_review/prompts/default_summary_reply.md +14 -0
  33. ai_review/prompts/default_system_inline_reply.md +31 -0
  34. ai_review/prompts/default_system_summary_reply.md +13 -0
  35. ai_review/services/artifacts/schema.py +2 -2
  36. ai_review/services/hook/constants.py +14 -0
  37. ai_review/services/hook/service.py +95 -4
  38. ai_review/services/hook/types.py +18 -2
  39. ai_review/services/prompt/adapter.py +1 -1
  40. ai_review/services/prompt/service.py +49 -3
  41. ai_review/services/prompt/tools.py +21 -0
  42. ai_review/services/prompt/types.py +23 -0
  43. ai_review/services/review/gateway/comment.py +45 -6
  44. ai_review/services/review/gateway/llm.py +2 -1
  45. ai_review/services/review/gateway/types.py +50 -0
  46. ai_review/services/review/internal/inline/service.py +40 -0
  47. ai_review/services/review/internal/inline/types.py +8 -0
  48. ai_review/services/review/internal/inline_reply/schema.py +23 -0
  49. ai_review/services/review/internal/inline_reply/service.py +20 -0
  50. ai_review/services/review/internal/inline_reply/types.py +8 -0
  51. ai_review/services/review/{policy → internal/policy}/service.py +2 -1
  52. ai_review/services/review/internal/policy/types.py +15 -0
  53. ai_review/services/review/{summary → internal/summary}/service.py +2 -2
  54. ai_review/services/review/{summary → internal/summary}/types.py +1 -1
  55. ai_review/services/review/internal/summary_reply/__init__.py +0 -0
  56. ai_review/services/review/internal/summary_reply/schema.py +8 -0
  57. ai_review/services/review/internal/summary_reply/service.py +15 -0
  58. ai_review/services/review/internal/summary_reply/types.py +8 -0
  59. ai_review/services/review/runner/__init__.py +0 -0
  60. ai_review/services/review/runner/context.py +72 -0
  61. ai_review/services/review/runner/inline.py +80 -0
  62. ai_review/services/review/runner/inline_reply.py +80 -0
  63. ai_review/services/review/runner/summary.py +71 -0
  64. ai_review/services/review/runner/summary_reply.py +79 -0
  65. ai_review/services/review/runner/types.py +6 -0
  66. ai_review/services/review/service.py +78 -110
  67. ai_review/services/vcs/bitbucket/adapter.py +24 -0
  68. ai_review/services/vcs/bitbucket/client.py +107 -42
  69. ai_review/services/vcs/github/adapter.py +35 -0
  70. ai_review/services/vcs/github/client.py +105 -44
  71. ai_review/services/vcs/gitlab/adapter.py +26 -0
  72. ai_review/services/vcs/gitlab/client.py +91 -38
  73. ai_review/services/vcs/types.py +34 -0
  74. ai_review/tests/fixtures/clients/bitbucket.py +2 -2
  75. ai_review/tests/fixtures/clients/github.py +35 -6
  76. ai_review/tests/fixtures/clients/gitlab.py +42 -3
  77. ai_review/tests/fixtures/libs/__init__.py +0 -0
  78. ai_review/tests/fixtures/libs/llm/__init__.py +0 -0
  79. ai_review/tests/fixtures/libs/llm/output_json_parser.py +13 -0
  80. ai_review/tests/fixtures/services/hook.py +8 -0
  81. ai_review/tests/fixtures/services/llm.py +8 -5
  82. ai_review/tests/fixtures/services/prompt.py +70 -0
  83. ai_review/tests/fixtures/services/review/base.py +41 -0
  84. ai_review/tests/fixtures/services/review/gateway/__init__.py +0 -0
  85. ai_review/tests/fixtures/services/review/gateway/comment.py +98 -0
  86. ai_review/tests/fixtures/services/review/gateway/llm.py +17 -0
  87. ai_review/tests/fixtures/services/review/internal/__init__.py +0 -0
  88. ai_review/tests/fixtures/services/review/{inline.py → internal/inline.py} +8 -6
  89. ai_review/tests/fixtures/services/review/internal/inline_reply.py +25 -0
  90. ai_review/tests/fixtures/services/review/internal/policy.py +28 -0
  91. ai_review/tests/fixtures/services/review/internal/summary.py +21 -0
  92. ai_review/tests/fixtures/services/review/internal/summary_reply.py +19 -0
  93. ai_review/tests/fixtures/services/review/runner/__init__.py +0 -0
  94. ai_review/tests/fixtures/services/review/runner/context.py +50 -0
  95. ai_review/tests/fixtures/services/review/runner/inline.py +50 -0
  96. ai_review/tests/fixtures/services/review/runner/inline_reply.py +50 -0
  97. ai_review/tests/fixtures/services/review/runner/summary.py +50 -0
  98. ai_review/tests/fixtures/services/review/runner/summary_reply.py +50 -0
  99. ai_review/tests/fixtures/services/vcs.py +23 -0
  100. ai_review/tests/suites/cli/__init__.py +0 -0
  101. ai_review/tests/suites/cli/test_main.py +54 -0
  102. ai_review/tests/suites/clients/bitbucket/__init__.py +0 -0
  103. ai_review/tests/suites/clients/bitbucket/test_client.py +14 -0
  104. ai_review/tests/suites/clients/bitbucket/test_tools.py +31 -0
  105. ai_review/tests/suites/clients/github/test_tools.py +31 -0
  106. ai_review/tests/suites/clients/gitlab/test_tools.py +26 -0
  107. ai_review/tests/suites/libs/config/test_prompt.py +108 -28
  108. ai_review/tests/suites/libs/http/__init__.py +0 -0
  109. ai_review/tests/suites/libs/http/test_paginate.py +95 -0
  110. ai_review/tests/suites/libs/llm/__init__.py +0 -0
  111. ai_review/tests/suites/libs/llm/test_output_json_parser.py +155 -0
  112. ai_review/tests/suites/services/hook/test_service.py +88 -4
  113. ai_review/tests/suites/services/prompt/test_adapter.py +3 -3
  114. ai_review/tests/suites/services/prompt/test_service.py +102 -58
  115. ai_review/tests/suites/services/prompt/test_tools.py +86 -1
  116. ai_review/tests/suites/services/review/gateway/__init__.py +0 -0
  117. ai_review/tests/suites/services/review/gateway/test_comment.py +253 -0
  118. ai_review/tests/suites/services/review/gateway/test_llm.py +82 -0
  119. ai_review/tests/suites/services/review/internal/__init__.py +0 -0
  120. ai_review/tests/suites/services/review/internal/inline/__init__.py +0 -0
  121. ai_review/tests/suites/services/review/{inline → internal/inline}/test_schema.py +1 -1
  122. ai_review/tests/suites/services/review/internal/inline/test_service.py +81 -0
  123. ai_review/tests/suites/services/review/internal/inline_reply/__init__.py +0 -0
  124. ai_review/tests/suites/services/review/internal/inline_reply/test_schema.py +57 -0
  125. ai_review/tests/suites/services/review/internal/inline_reply/test_service.py +72 -0
  126. ai_review/tests/suites/services/review/internal/policy/__init__.py +0 -0
  127. ai_review/tests/suites/services/review/{policy → internal/policy}/test_service.py +1 -1
  128. ai_review/tests/suites/services/review/internal/summary/__init__.py +0 -0
  129. ai_review/tests/suites/services/review/{summary → internal/summary}/test_schema.py +1 -1
  130. ai_review/tests/suites/services/review/{summary → internal/summary}/test_service.py +2 -2
  131. ai_review/tests/suites/services/review/internal/summary_reply/__init__.py +0 -0
  132. ai_review/tests/suites/services/review/internal/summary_reply/test_schema.py +19 -0
  133. ai_review/tests/suites/services/review/internal/summary_reply/test_service.py +21 -0
  134. ai_review/tests/suites/services/review/runner/__init__.py +0 -0
  135. ai_review/tests/suites/services/review/runner/test_context.py +89 -0
  136. ai_review/tests/suites/services/review/runner/test_inline.py +100 -0
  137. ai_review/tests/suites/services/review/runner/test_inline_reply.py +109 -0
  138. ai_review/tests/suites/services/review/runner/test_summary.py +87 -0
  139. ai_review/tests/suites/services/review/runner/test_summary_reply.py +97 -0
  140. ai_review/tests/suites/services/review/test_service.py +64 -97
  141. ai_review/tests/suites/services/vcs/bitbucket/test_adapter.py +109 -0
  142. ai_review/tests/suites/services/vcs/bitbucket/{test_service.py → test_client.py} +88 -1
  143. ai_review/tests/suites/services/vcs/github/test_adapter.py +162 -0
  144. ai_review/tests/suites/services/vcs/github/{test_service.py → test_client.py} +102 -2
  145. ai_review/tests/suites/services/vcs/gitlab/test_adapter.py +105 -0
  146. ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +99 -1
  147. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/METADATA +8 -5
  148. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/RECORD +160 -75
  149. ai_review/services/review/inline/service.py +0 -54
  150. ai_review/services/review/inline/types.py +0 -11
  151. ai_review/tests/fixtures/services/review/summary.py +0 -19
  152. ai_review/tests/suites/services/review/inline/test_service.py +0 -107
  153. /ai_review/{services/review/inline → libs/llm}/__init__.py +0 -0
  154. /ai_review/services/review/{policy → internal}/__init__.py +0 -0
  155. /ai_review/services/review/{summary → internal/inline}/__init__.py +0 -0
  156. /ai_review/services/review/{inline → internal/inline}/schema.py +0 -0
  157. /ai_review/{tests/suites/services/review/inline → services/review/internal/inline_reply}/__init__.py +0 -0
  158. /ai_review/{tests/suites/services/review → services/review/internal}/policy/__init__.py +0 -0
  159. /ai_review/{tests/suites/services/review → services/review/internal}/summary/__init__.py +0 -0
  160. /ai_review/services/review/{summary → internal/summary}/schema.py +0 -0
  161. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/WHEEL +0 -0
  162. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/entry_points.txt +0 -0
  163. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/licenses/LICENSE +0 -0
  164. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,253 @@
1
+ import pytest
2
+
3
+ from ai_review.config import settings
4
+ from ai_review.services.review.gateway.comment import ReviewCommentGateway
5
+ from ai_review.services.review.internal.inline.schema import InlineCommentSchema, InlineCommentListSchema
6
+ from ai_review.services.review.internal.inline_reply.schema import InlineCommentReplySchema
7
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
8
+ from ai_review.services.review.internal.summary_reply.schema import SummaryCommentReplySchema
9
+ from ai_review.services.vcs.types import ReviewThreadSchema, ReviewCommentSchema, ThreadKind
10
+ from ai_review.tests.fixtures.services.vcs import FakeVCSClient
11
+
12
+
13
+ # === INLINE THREADS ===
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_get_inline_threads_filters_by_tag(
17
+ fake_vcs_client: FakeVCSClient,
18
+ review_comment_gateway: ReviewCommentGateway,
19
+ ):
20
+ """Should return only threads containing AI inline tags."""
21
+ threads = [
22
+ ReviewThreadSchema(
23
+ id="1",
24
+ kind=ThreadKind.INLINE,
25
+ file="a.py",
26
+ comments=[ReviewCommentSchema(id="1", body=f"Hello {settings.review.inline_reply_tag}")]
27
+ ),
28
+ ReviewThreadSchema(
29
+ id="2",
30
+ kind=ThreadKind.INLINE,
31
+ file="b.py",
32
+ comments=[ReviewCommentSchema(id="2", body="No AI tag here")]
33
+ ),
34
+ ]
35
+ fake_vcs_client.responses["get_inline_threads"] = threads
36
+
37
+ result = await review_comment_gateway.get_inline_threads()
38
+
39
+ assert len(result) == 1
40
+ assert result[0].id == "1"
41
+ assert any(call[0] == "get_inline_threads" for call in fake_vcs_client.calls)
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_get_summary_threads_filters_by_tag(
46
+ fake_vcs_client: FakeVCSClient,
47
+ review_comment_gateway: ReviewCommentGateway,
48
+ ):
49
+ """Should return only threads containing AI summary tags."""
50
+ threads = [
51
+ ReviewThreadSchema(
52
+ id="10",
53
+ kind=ThreadKind.SUMMARY,
54
+ comments=[ReviewCommentSchema(id="1", body=f"AI {settings.review.summary_reply_tag}")]
55
+ ),
56
+ ReviewThreadSchema(
57
+ id="11",
58
+ kind=ThreadKind.SUMMARY,
59
+ comments=[ReviewCommentSchema(id="2", body="No tags here")]
60
+ ),
61
+ ]
62
+ fake_vcs_client.responses["get_general_threads"] = threads
63
+
64
+ result = await review_comment_gateway.get_summary_threads()
65
+
66
+ assert len(result) == 1
67
+ assert result[0].id == "10"
68
+ assert any(call[0] == "get_general_threads" for call in fake_vcs_client.calls)
69
+
70
+
71
+ # === EXISTING COMMENTS ===
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_has_existing_inline_comments_true(
75
+ capsys,
76
+ fake_vcs_client: FakeVCSClient,
77
+ review_comment_gateway: ReviewCommentGateway,
78
+ ):
79
+ """Should detect existing inline comments and log skip message."""
80
+ fake_vcs_client.responses["get_inline_comments"] = [
81
+ ReviewCommentSchema(id="1", body=f"{settings.review.inline_tag} existing comment")
82
+ ]
83
+
84
+ result = await review_comment_gateway.has_existing_inline_comments()
85
+ output = capsys.readouterr().out
86
+
87
+ assert result is True
88
+ assert "AI inline comments already exist" in output
89
+
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_has_existing_summary_comments_false(
93
+ fake_vcs_client: FakeVCSClient,
94
+ review_comment_gateway: ReviewCommentGateway,
95
+ ):
96
+ """Should return False when no summary AI comments exist."""
97
+ fake_vcs_client.responses["get_general_comments"] = [
98
+ ReviewCommentSchema(id="1", body="Regular comment")
99
+ ]
100
+ result = await review_comment_gateway.has_existing_summary_comments()
101
+ assert result is False
102
+
103
+
104
+ # === INLINE REPLY ===
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_process_inline_reply_happy_path(
108
+ fake_vcs_client: FakeVCSClient,
109
+ review_comment_gateway: ReviewCommentGateway,
110
+ ):
111
+ """Should create inline reply and emit hook events."""
112
+ reply = InlineCommentReplySchema(message="AI reply text")
113
+
114
+ await review_comment_gateway.process_inline_reply("t1", reply)
115
+
116
+ assert any(call[0] == "create_inline_reply" for call in fake_vcs_client.calls)
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_process_inline_reply_error(
121
+ capsys,
122
+ fake_vcs_client: FakeVCSClient,
123
+ review_comment_gateway: ReviewCommentGateway,
124
+ ):
125
+ """Should log and emit error if VCS fails to create reply."""
126
+
127
+ async def failing_create_inline_reply(thread_id: str, body: str):
128
+ raise RuntimeError("API error")
129
+
130
+ fake_vcs_client.create_inline_reply = failing_create_inline_reply
131
+
132
+ reply = InlineCommentReplySchema(message="AI reply text")
133
+ await review_comment_gateway.process_inline_reply("t1", reply)
134
+ output = capsys.readouterr().out
135
+
136
+ assert "Failed to create inline reply" in output
137
+
138
+
139
+ # === SUMMARY REPLY ===
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_process_summary_reply_success(
143
+ fake_vcs_client: FakeVCSClient,
144
+ review_comment_gateway: ReviewCommentGateway,
145
+ ):
146
+ """Should create summary reply comment."""
147
+ reply = SummaryCommentReplySchema(text="AI summary reply")
148
+ await review_comment_gateway.process_summary_reply("t42", reply)
149
+ assert any(call[0] == "create_summary_reply" for call in fake_vcs_client.calls)
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_process_summary_reply_error(
154
+ capsys,
155
+ fake_vcs_client: FakeVCSClient,
156
+ review_comment_gateway: ReviewCommentGateway,
157
+ ):
158
+ """Should log and emit error on exception in summary reply."""
159
+
160
+ async def failing_create_summary_reply(thread_id: str, body: str):
161
+ raise RuntimeError("Network fail")
162
+
163
+ fake_vcs_client.create_summary_reply = failing_create_summary_reply
164
+
165
+ reply = SummaryCommentReplySchema(text="AI summary reply")
166
+ await review_comment_gateway.process_summary_reply("t42", reply)
167
+ output = capsys.readouterr().out
168
+
169
+ assert "Failed to create summary reply" in output
170
+
171
+
172
+ # === INLINE COMMENT ===
173
+
174
+ @pytest.mark.asyncio
175
+ async def test_process_inline_comment_happy_path(
176
+ fake_vcs_client: FakeVCSClient,
177
+ review_comment_gateway: ReviewCommentGateway,
178
+ ):
179
+ """Should create inline comment via VCS."""
180
+ comment = InlineCommentSchema(file="f.py", line=1, message="AI inline comment")
181
+ await review_comment_gateway.process_inline_comment(comment)
182
+ assert any(call[0] == "create_inline_comment" for call in fake_vcs_client.calls)
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_process_inline_comment_error_fallback(
187
+ capsys,
188
+ fake_vcs_client: FakeVCSClient,
189
+ review_comment_gateway: ReviewCommentGateway,
190
+ ):
191
+ """Should fall back to summary comment when inline comment fails."""
192
+
193
+ async def failing_create_inline_comment(file: str, line: int, message: str):
194
+ raise RuntimeError("Failed to post inline")
195
+
196
+ fake_vcs_client.create_inline_comment = failing_create_inline_comment
197
+
198
+ comment = InlineCommentSchema(file="x.py", line=5, message="AI inline")
199
+ await review_comment_gateway.process_inline_comment(comment)
200
+ output = capsys.readouterr().out
201
+
202
+ assert "Falling back to general comment" in output
203
+ assert any(call[0] == "create_general_comment" for call in fake_vcs_client.calls)
204
+
205
+
206
+ # === SUMMARY COMMENT ===
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_process_summary_comment_happy_path(
210
+ fake_vcs_client: FakeVCSClient,
211
+ review_comment_gateway: ReviewCommentGateway,
212
+ ):
213
+ """Should create general summary comment successfully."""
214
+ comment = SummaryCommentSchema(text="AI summary")
215
+ await review_comment_gateway.process_summary_comment(comment)
216
+ assert any(call[0] == "create_general_comment" for call in fake_vcs_client.calls)
217
+
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_process_summary_comment_error(
221
+ capsys,
222
+ fake_vcs_client: FakeVCSClient,
223
+ review_comment_gateway: ReviewCommentGateway,
224
+ ):
225
+ """Should log error if summary comment creation fails."""
226
+
227
+ async def failing_create_general_comment(body: str):
228
+ raise RuntimeError("Backend down")
229
+
230
+ fake_vcs_client.create_general_comment = failing_create_general_comment
231
+
232
+ comment = SummaryCommentSchema(text="Broken")
233
+ await review_comment_gateway.process_summary_comment(comment)
234
+ output = capsys.readouterr().out
235
+
236
+ assert "Failed to process summary comment" in output
237
+
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_process_inline_comments_calls_each(
241
+ fake_vcs_client: FakeVCSClient,
242
+ review_comment_gateway: ReviewCommentGateway,
243
+ ):
244
+ """Should process all inline comments concurrently."""
245
+ comments = InlineCommentListSchema(root=[
246
+ InlineCommentSchema(file="a.py", line=1, message="c1"),
247
+ InlineCommentSchema(file="b.py", line=2, message="c2"),
248
+ ])
249
+
250
+ await review_comment_gateway.process_inline_comments(comments)
251
+
252
+ created = [call for call in fake_vcs_client.calls if call[0] == "create_inline_comment"]
253
+ assert len(created) == 2
@@ -0,0 +1,82 @@
1
+ import pytest
2
+
3
+ from ai_review.services.llm.types import ChatResultSchema
4
+ from ai_review.services.review.gateway.llm import ReviewLLMGateway
5
+ from ai_review.tests.fixtures.services.artifacts import FakeArtifactsService
6
+ from ai_review.tests.fixtures.services.cost import FakeCostService
7
+ from ai_review.tests.fixtures.services.llm import FakeLLMClient
8
+
9
+
10
+ @pytest.fixture
11
+ def review_llm_gateway(
12
+ fake_llm_client: FakeLLMClient,
13
+ fake_cost_service: FakeCostService,
14
+ fake_artifacts_service: FakeArtifactsService,
15
+ ) -> ReviewLLMGateway:
16
+ """Fixture providing ReviewLLMGateway with fake dependencies."""
17
+ return ReviewLLMGateway(
18
+ llm=fake_llm_client,
19
+ cost=fake_cost_service,
20
+ artifacts=fake_artifacts_service,
21
+ )
22
+
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_ask_happy_path(
26
+ review_llm_gateway: ReviewLLMGateway,
27
+ fake_llm_client: FakeLLMClient,
28
+ fake_cost_service: FakeCostService,
29
+ fake_artifacts_service: FakeArtifactsService,
30
+ ):
31
+ """Should call LLM, calculate cost, save artifacts, and return text."""
32
+ fake_llm_client.responses["chat"] = ChatResultSchema(text="FAKE_RESPONSE")
33
+
34
+ result = await review_llm_gateway.ask("PROMPT", "SYSTEM_PROMPT")
35
+
36
+ assert result == "FAKE_RESPONSE"
37
+ assert any(call[0] == "chat" for call in fake_llm_client.calls)
38
+ assert any(call[0] == "calculate" for call in fake_cost_service.calls)
39
+ assert any(call[0] == "save_llm_interaction" for call in fake_artifacts_service.calls)
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_ask_warns_on_empty_response(
44
+ capsys,
45
+ review_llm_gateway: ReviewLLMGateway,
46
+ fake_llm_client: FakeLLMClient,
47
+ fake_cost_service: FakeCostService,
48
+ fake_artifacts_service: FakeArtifactsService,
49
+ ):
50
+ """Should warn if LLM returns an empty response."""
51
+ fake_llm_client.responses["chat"] = ChatResultSchema(text="")
52
+
53
+ result = await review_llm_gateway.ask("PROMPT", "SYSTEM_PROMPT")
54
+ output = capsys.readouterr().out
55
+
56
+ assert result == ""
57
+ assert "LLM returned an empty response" in output
58
+
59
+ assert any(call[0] == "chat" for call in fake_llm_client.calls)
60
+ assert any(call[0] == "calculate" for call in fake_cost_service.calls)
61
+ assert any(call[0] == "save_llm_interaction" for call in fake_artifacts_service.calls)
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_ask_handles_llm_error(
66
+ capsys,
67
+ fake_llm_client: FakeLLMClient,
68
+ review_llm_gateway: ReviewLLMGateway,
69
+ ):
70
+ """Should handle exceptions gracefully and log error."""
71
+
72
+ async def failing_chat(prompt: str, prompt_system: str):
73
+ raise RuntimeError("LLM connection failed")
74
+
75
+ fake_llm_client.chat = failing_chat
76
+
77
+ result = await review_llm_gateway.ask("PROMPT", "SYSTEM_PROMPT")
78
+ output = capsys.readouterr().out
79
+
80
+ assert result is None
81
+ assert "LLM request failed" in output
82
+ assert "RuntimeError" in output
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
 
3
3
  from ai_review.config import settings
4
- from ai_review.services.review.inline.schema import (
4
+ from ai_review.services.review.internal.inline.schema import (
5
5
  InlineCommentSchema,
6
6
  InlineCommentListSchema,
7
7
  )
@@ -0,0 +1,81 @@
1
+ from ai_review.services.review.internal.inline.schema import InlineCommentListSchema
2
+ from ai_review.services.review.internal.inline.service import InlineCommentService
3
+
4
+
5
+ def test_empty_output_returns_empty_list(inline_comment_service: InlineCommentService):
6
+ result = inline_comment_service.parse_model_output("")
7
+ assert isinstance(result, InlineCommentListSchema)
8
+ assert result.root == []
9
+
10
+
11
+ def test_valid_json_array_parsed(inline_comment_service: InlineCommentService):
12
+ json_output = '[{"file": "a.py", "line": 1, "message": "use f-string"}]'
13
+ result = inline_comment_service.parse_model_output(json_output)
14
+ assert len(result.root) == 1
15
+ assert result.root[0].file == "a.py"
16
+ assert result.root[0].line == 1
17
+ assert result.root[0].message == "use f-string"
18
+
19
+
20
+ def test_json_inside_code_block_parsed(inline_comment_service: InlineCommentService):
21
+ output = """```json
22
+ [
23
+ {"file": "b.py", "line": 42, "message": "check for None"}
24
+ ]
25
+ ```"""
26
+ result = inline_comment_service.parse_model_output(output)
27
+ assert len(result.root) == 1
28
+ assert result.root[0].file == "b.py"
29
+ assert result.root[0].line == 42
30
+
31
+
32
+ def test_non_json_but_array_inside_text(inline_comment_service: InlineCommentService):
33
+ output = "some explanation...\n[ {\"file\": \"c.py\", \"line\": 7, \"message\": \"fix this\"} ]\nend"
34
+ result = inline_comment_service.parse_model_output(output)
35
+ assert len(result.root) == 1
36
+ assert result.root[0].file == "c.py"
37
+ assert result.root[0].line == 7
38
+
39
+
40
+ def test_invalid_json_array_logs_and_returns_empty(inline_comment_service: InlineCommentService):
41
+ output = '[{"file": "d.py", "line": "oops", "message": "bad"}]'
42
+ result = inline_comment_service.parse_model_output(output)
43
+ assert result.root == []
44
+
45
+
46
+ def test_no_json_array_found_logs_and_returns_empty(inline_comment_service: InlineCommentService):
47
+ output = "this is not json at all"
48
+ result = inline_comment_service.parse_model_output(output)
49
+ assert result.root == []
50
+
51
+
52
+ def test_json_with_raw_newline_sanitized(inline_comment_service: InlineCommentService):
53
+ output = '[{"file": "e.py", "line": 3, "message": "line1\nline2"}]'
54
+ result = inline_comment_service.parse_model_output(output)
55
+ assert len(result.root) == 1
56
+ assert result.root[0].file == "e.py"
57
+ assert result.root[0].line == 3
58
+ assert result.root[0].message == "line1\nline2"
59
+
60
+
61
+ def test_json_with_tab_character_sanitized(inline_comment_service: InlineCommentService):
62
+ output = '[{"file": "f.py", "line": 4, "message": "a\tb"}]'
63
+ result = inline_comment_service.parse_model_output(output)
64
+ assert len(result.root) == 1
65
+ assert result.root[0].message == "a\tb"
66
+
67
+
68
+ def test_json_with_null_byte_sanitized(inline_comment_service: InlineCommentService):
69
+ raw = "abc\0def"
70
+ output = f'[{{"file": "g.py", "line": 5, "message": "{raw}"}}]'
71
+ result = inline_comment_service.parse_model_output(output)
72
+ assert len(result.root) == 1
73
+ assert result.root[0].message == "abc\0def"
74
+
75
+
76
+ def test_json_with_multiple_control_chars(inline_comment_service: InlineCommentService):
77
+ raw = "x\n\ry\t\0z"
78
+ output = f'[{{"file": "h.py", "line": 6, "message": "{raw}"}}]'
79
+ result = inline_comment_service.parse_model_output(output)
80
+ assert len(result.root) == 1
81
+ assert result.root[0].message == "x\n\ry\t\0z"
@@ -0,0 +1,57 @@
1
+ import pytest
2
+
3
+ from ai_review.config import settings
4
+ from ai_review.services.review.internal.inline_reply.schema import InlineCommentReplySchema
5
+
6
+
7
+ def test_message_is_trimmed_by_validator():
8
+ """Message should be stripped of leading/trailing whitespace."""
9
+ schema = InlineCommentReplySchema(message=" fix this issue ")
10
+ assert schema.message == "fix this issue"
11
+
12
+
13
+ def test_body_without_suggestion():
14
+ """Body should contain only message when no suggestion is provided."""
15
+ schema = InlineCommentReplySchema(message="Use f-string")
16
+ assert schema.body == "Use f-string"
17
+ assert "```suggestion" not in schema.body
18
+
19
+
20
+ def test_body_with_suggestion():
21
+ """Body should include formatted suggestion block when suggestion is present."""
22
+ schema = InlineCommentReplySchema(
23
+ message="Replace concatenation with f-string",
24
+ suggestion='print(f"Hello {name}")'
25
+ )
26
+ expected = (
27
+ "Replace concatenation with f-string\n\n"
28
+ "```suggestion\nprint(f\"Hello {name}\")\n```"
29
+ )
30
+ assert schema.body == expected
31
+
32
+
33
+ def test_body_with_tag(monkeypatch: pytest.MonkeyPatch):
34
+ """body_with_tag should append the configured inline reply tag."""
35
+ monkeypatch.setattr(settings.review, "inline_reply_tag", "#ai-reply")
36
+ schema = InlineCommentReplySchema(message="Looks good")
37
+ result = schema.body_with_tag
38
+ assert result.endswith("\n\n#ai-reply")
39
+ assert "#ai-reply" not in schema.body
40
+
41
+
42
+ def test_body_with_tag_and_suggestion(monkeypatch: pytest.MonkeyPatch):
43
+ """body_with_tag should include both suggestion and tag."""
44
+ monkeypatch.setattr(settings.review, "inline_reply_tag", "#ai-reply")
45
+ schema = InlineCommentReplySchema(
46
+ message="Simplify condition",
47
+ suggestion="if x:"
48
+ )
49
+ result = schema.body_with_tag
50
+ assert "```suggestion" in result
51
+ assert result.endswith("\n\n#ai-reply")
52
+
53
+
54
+ def test_message_cannot_be_empty():
55
+ """Empty message should raise validation error (min_length=1)."""
56
+ with pytest.raises(ValueError):
57
+ InlineCommentReplySchema(message="")
@@ -0,0 +1,72 @@
1
+ from ai_review.services.review.internal.inline_reply.schema import InlineCommentReplySchema
2
+ from ai_review.services.review.internal.inline_reply.service import InlineCommentReplyService
3
+
4
+
5
+ def test_empty_output_returns_none(inline_comment_reply_service: InlineCommentReplyService):
6
+ """Empty LLM output should return None."""
7
+ result = inline_comment_reply_service.parse_model_output("")
8
+ assert result is None
9
+
10
+
11
+ def test_valid_json_object_parsed(inline_comment_reply_service: InlineCommentReplyService):
12
+ """A valid JSON object should be parsed successfully."""
13
+ output = '{"message": "Looks good!"}'
14
+ result = inline_comment_reply_service.parse_model_output(output)
15
+
16
+ assert isinstance(result, InlineCommentReplySchema)
17
+ assert result.message == "Looks good!"
18
+ assert result.suggestion is None
19
+
20
+
21
+ def test_valid_json_with_suggestion(inline_comment_reply_service: InlineCommentReplyService):
22
+ """Parser should correctly handle JSON with both message and suggestion."""
23
+ output = '{"message": "Consider refactoring", "suggestion": "use helper()"}'
24
+ result = inline_comment_reply_service.parse_model_output(output)
25
+
26
+ assert isinstance(result, InlineCommentReplySchema)
27
+ assert result.message == "Consider refactoring"
28
+ assert result.suggestion == "use helper()"
29
+ assert "```suggestion" in result.body
30
+ assert result.message in result.body
31
+
32
+
33
+ def test_json_inside_code_block_parsed(inline_comment_reply_service: InlineCommentReplyService):
34
+ """JSON inside a ```json code block should be extracted successfully."""
35
+ output = """```json
36
+ {"message": "Please add docstring"}
37
+ ```"""
38
+ result = inline_comment_reply_service.parse_model_output(output)
39
+
40
+ assert isinstance(result, InlineCommentReplySchema)
41
+ assert result.message == "Please add docstring"
42
+ assert result.suggestion is None
43
+
44
+
45
+ def test_invalid_json_returns_none(inline_comment_reply_service: InlineCommentReplyService):
46
+ """Invalid JSON (wrong field type) should return None."""
47
+ output = '{"message": 12345}'
48
+ result = inline_comment_reply_service.parse_model_output(output)
49
+ assert result is None
50
+
51
+
52
+ def test_non_json_text_returns_none(inline_comment_reply_service: InlineCommentReplyService):
53
+ """Non-JSON text should return None."""
54
+ output = "some random text output"
55
+ result = inline_comment_reply_service.parse_model_output(output)
56
+ assert result is None
57
+
58
+
59
+ def test_json_with_empty_message_returns_none(inline_comment_reply_service: InlineCommentReplyService):
60
+ """JSON with an empty message field should return None (violates min_length)."""
61
+ output = '{"message": ""}'
62
+ result = inline_comment_reply_service.parse_model_output(output)
63
+ assert result is None
64
+
65
+
66
+ def test_message_is_trimmed(inline_comment_reply_service: InlineCommentReplyService):
67
+ """Message should be trimmed — leading and trailing spaces removed."""
68
+ output = '{"message": " spaced out "}'
69
+ result = inline_comment_reply_service.parse_model_output(output)
70
+
71
+ assert isinstance(result, InlineCommentReplySchema)
72
+ assert result.message == "spaced out"
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
 
3
3
  from ai_review.config import settings
4
- from ai_review.services.review.policy.service import ReviewPolicyService
4
+ from ai_review.services.review.internal.policy.service import ReviewPolicyService
5
5
 
6
6
 
7
7
  @pytest.fixture(autouse=True)
@@ -1,5 +1,5 @@
1
1
  from ai_review.config import settings
2
- from ai_review.services.review.summary.schema import SummaryCommentSchema
2
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
3
3
 
4
4
 
5
5
  def test_normalize_text_strips_whitespace():
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
 
3
- from ai_review.services.review.summary.schema import SummaryCommentSchema
4
- from ai_review.services.review.summary.service import SummaryCommentService
3
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
4
+ from ai_review.services.review.internal.summary.service import SummaryCommentService
5
5
 
6
6
 
7
7
  @pytest.mark.parametrize(
@@ -0,0 +1,19 @@
1
+ from ai_review.config import settings
2
+ from ai_review.services.review.internal.summary_reply.schema import SummaryCommentReplySchema
3
+
4
+
5
+ def test_body_with_tag_appends_reply_tag(monkeypatch):
6
+ """body_with_tag should append the configured summary reply tag."""
7
+ monkeypatch.setattr(settings.review, "summary_reply_tag", "#ai-summary-reply")
8
+ comment = SummaryCommentReplySchema(text="This is a summary reply")
9
+
10
+ result = comment.body_with_tag
11
+ assert result.startswith("This is a summary reply")
12
+ assert result.endswith("\n\n#ai-summary-reply")
13
+ assert "\n\n#ai-summary-reply" in result
14
+
15
+
16
+ def test_inherits_text_normalization_from_parent():
17
+ """SummaryCommentReplySchema should inherit normalization behavior."""
18
+ comment = SummaryCommentReplySchema(text=" spaced summary reply ")
19
+ assert comment.text == "spaced summary reply"
@@ -0,0 +1,21 @@
1
+ import pytest
2
+
3
+ from ai_review.services.review.internal.summary_reply.schema import SummaryCommentReplySchema
4
+ from ai_review.services.review.internal.summary_reply.service import SummaryCommentReplyService
5
+
6
+
7
+ @pytest.mark.parametrize(
8
+ "raw, expected",
9
+ [
10
+ ("Some reply", "Some reply"),
11
+ (" padded reply ", "padded reply"),
12
+ ("", ""),
13
+ (None, ""),
14
+ ],
15
+ )
16
+ def test_parse_model_output_normalizes_and_wraps(raw: str | None, expected: str):
17
+ """parse_model_output should normalize input and wrap it into schema."""
18
+ result = SummaryCommentReplyService.parse_model_output(raw)
19
+
20
+ assert isinstance(result, SummaryCommentReplySchema)
21
+ assert result.text == expected