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.
- ai_review/cli/commands/run_inline_reply_review.py +7 -0
- ai_review/cli/commands/run_summary_reply_review.py +7 -0
- ai_review/cli/main.py +17 -0
- ai_review/clients/bitbucket/pr/client.py +45 -8
- ai_review/clients/bitbucket/pr/schema/comments.py +21 -2
- ai_review/clients/bitbucket/pr/schema/files.py +8 -3
- ai_review/clients/bitbucket/pr/schema/pull_request.py +1 -5
- ai_review/clients/bitbucket/pr/schema/user.py +7 -0
- ai_review/clients/bitbucket/tools.py +6 -0
- ai_review/clients/github/pr/client.py +98 -13
- ai_review/clients/github/pr/schema/comments.py +23 -1
- ai_review/clients/github/pr/schema/files.py +2 -1
- ai_review/clients/github/pr/schema/pull_request.py +1 -4
- ai_review/clients/github/pr/schema/reviews.py +2 -1
- ai_review/clients/github/pr/schema/user.py +6 -0
- ai_review/clients/github/pr/types.py +11 -1
- ai_review/clients/github/tools.py +6 -0
- ai_review/clients/gitlab/mr/client.py +67 -7
- ai_review/clients/gitlab/mr/schema/changes.py +1 -5
- ai_review/clients/gitlab/mr/schema/discussions.py +19 -8
- ai_review/clients/gitlab/mr/schema/notes.py +5 -1
- ai_review/clients/gitlab/mr/schema/user.py +7 -0
- ai_review/clients/gitlab/mr/types.py +16 -7
- ai_review/clients/gitlab/tools.py +5 -0
- ai_review/libs/config/prompt.py +96 -64
- ai_review/libs/config/review.py +2 -0
- ai_review/libs/config/vcs/base.py +2 -0
- ai_review/libs/config/vcs/pagination.py +6 -0
- ai_review/libs/http/paginate.py +43 -0
- ai_review/libs/llm/output_json_parser.py +60 -0
- ai_review/prompts/default_inline_reply.md +10 -0
- ai_review/prompts/default_summary_reply.md +14 -0
- ai_review/prompts/default_system_inline_reply.md +31 -0
- ai_review/prompts/default_system_summary_reply.md +13 -0
- ai_review/services/artifacts/schema.py +2 -2
- ai_review/services/hook/constants.py +14 -0
- ai_review/services/hook/service.py +95 -4
- ai_review/services/hook/types.py +18 -2
- ai_review/services/prompt/adapter.py +1 -1
- ai_review/services/prompt/service.py +49 -3
- ai_review/services/prompt/tools.py +21 -0
- ai_review/services/prompt/types.py +23 -0
- ai_review/services/review/gateway/comment.py +45 -6
- ai_review/services/review/gateway/llm.py +2 -1
- ai_review/services/review/gateway/types.py +50 -0
- ai_review/services/review/internal/inline/service.py +40 -0
- ai_review/services/review/internal/inline/types.py +8 -0
- ai_review/services/review/internal/inline_reply/schema.py +23 -0
- ai_review/services/review/internal/inline_reply/service.py +20 -0
- ai_review/services/review/internal/inline_reply/types.py +8 -0
- ai_review/services/review/{policy → internal/policy}/service.py +2 -1
- ai_review/services/review/internal/policy/types.py +15 -0
- ai_review/services/review/{summary → internal/summary}/service.py +2 -2
- ai_review/services/review/{summary → internal/summary}/types.py +1 -1
- ai_review/services/review/internal/summary_reply/__init__.py +0 -0
- ai_review/services/review/internal/summary_reply/schema.py +8 -0
- ai_review/services/review/internal/summary_reply/service.py +15 -0
- ai_review/services/review/internal/summary_reply/types.py +8 -0
- ai_review/services/review/runner/__init__.py +0 -0
- ai_review/services/review/runner/context.py +72 -0
- ai_review/services/review/runner/inline.py +80 -0
- ai_review/services/review/runner/inline_reply.py +80 -0
- ai_review/services/review/runner/summary.py +71 -0
- ai_review/services/review/runner/summary_reply.py +79 -0
- ai_review/services/review/runner/types.py +6 -0
- ai_review/services/review/service.py +78 -110
- ai_review/services/vcs/bitbucket/adapter.py +24 -0
- ai_review/services/vcs/bitbucket/client.py +107 -42
- ai_review/services/vcs/github/adapter.py +35 -0
- ai_review/services/vcs/github/client.py +105 -44
- ai_review/services/vcs/gitlab/adapter.py +26 -0
- ai_review/services/vcs/gitlab/client.py +91 -38
- ai_review/services/vcs/types.py +34 -0
- ai_review/tests/fixtures/clients/bitbucket.py +2 -2
- ai_review/tests/fixtures/clients/github.py +35 -6
- ai_review/tests/fixtures/clients/gitlab.py +42 -3
- ai_review/tests/fixtures/libs/__init__.py +0 -0
- ai_review/tests/fixtures/libs/llm/__init__.py +0 -0
- ai_review/tests/fixtures/libs/llm/output_json_parser.py +13 -0
- ai_review/tests/fixtures/services/hook.py +8 -0
- ai_review/tests/fixtures/services/llm.py +8 -5
- ai_review/tests/fixtures/services/prompt.py +70 -0
- ai_review/tests/fixtures/services/review/base.py +41 -0
- ai_review/tests/fixtures/services/review/gateway/__init__.py +0 -0
- ai_review/tests/fixtures/services/review/gateway/comment.py +98 -0
- ai_review/tests/fixtures/services/review/gateway/llm.py +17 -0
- ai_review/tests/fixtures/services/review/internal/__init__.py +0 -0
- ai_review/tests/fixtures/services/review/{inline.py → internal/inline.py} +8 -6
- ai_review/tests/fixtures/services/review/internal/inline_reply.py +25 -0
- ai_review/tests/fixtures/services/review/internal/policy.py +28 -0
- ai_review/tests/fixtures/services/review/internal/summary.py +21 -0
- ai_review/tests/fixtures/services/review/internal/summary_reply.py +19 -0
- ai_review/tests/fixtures/services/review/runner/__init__.py +0 -0
- ai_review/tests/fixtures/services/review/runner/context.py +50 -0
- ai_review/tests/fixtures/services/review/runner/inline.py +50 -0
- ai_review/tests/fixtures/services/review/runner/inline_reply.py +50 -0
- ai_review/tests/fixtures/services/review/runner/summary.py +50 -0
- ai_review/tests/fixtures/services/review/runner/summary_reply.py +50 -0
- ai_review/tests/fixtures/services/vcs.py +23 -0
- ai_review/tests/suites/cli/__init__.py +0 -0
- ai_review/tests/suites/cli/test_main.py +54 -0
- ai_review/tests/suites/clients/bitbucket/__init__.py +0 -0
- ai_review/tests/suites/clients/bitbucket/test_client.py +14 -0
- ai_review/tests/suites/clients/bitbucket/test_tools.py +31 -0
- ai_review/tests/suites/clients/github/test_tools.py +31 -0
- ai_review/tests/suites/clients/gitlab/test_tools.py +26 -0
- ai_review/tests/suites/libs/config/test_prompt.py +108 -28
- ai_review/tests/suites/libs/http/__init__.py +0 -0
- ai_review/tests/suites/libs/http/test_paginate.py +95 -0
- ai_review/tests/suites/libs/llm/__init__.py +0 -0
- ai_review/tests/suites/libs/llm/test_output_json_parser.py +155 -0
- ai_review/tests/suites/services/hook/test_service.py +88 -4
- ai_review/tests/suites/services/prompt/test_adapter.py +3 -3
- ai_review/tests/suites/services/prompt/test_service.py +102 -58
- ai_review/tests/suites/services/prompt/test_tools.py +86 -1
- ai_review/tests/suites/services/review/gateway/__init__.py +0 -0
- ai_review/tests/suites/services/review/gateway/test_comment.py +253 -0
- ai_review/tests/suites/services/review/gateway/test_llm.py +82 -0
- ai_review/tests/suites/services/review/internal/__init__.py +0 -0
- ai_review/tests/suites/services/review/internal/inline/__init__.py +0 -0
- ai_review/tests/suites/services/review/{inline → internal/inline}/test_schema.py +1 -1
- ai_review/tests/suites/services/review/internal/inline/test_service.py +81 -0
- ai_review/tests/suites/services/review/internal/inline_reply/__init__.py +0 -0
- ai_review/tests/suites/services/review/internal/inline_reply/test_schema.py +57 -0
- ai_review/tests/suites/services/review/internal/inline_reply/test_service.py +72 -0
- ai_review/tests/suites/services/review/internal/policy/__init__.py +0 -0
- ai_review/tests/suites/services/review/{policy → internal/policy}/test_service.py +1 -1
- ai_review/tests/suites/services/review/internal/summary/__init__.py +0 -0
- ai_review/tests/suites/services/review/{summary → internal/summary}/test_schema.py +1 -1
- ai_review/tests/suites/services/review/{summary → internal/summary}/test_service.py +2 -2
- ai_review/tests/suites/services/review/internal/summary_reply/__init__.py +0 -0
- ai_review/tests/suites/services/review/internal/summary_reply/test_schema.py +19 -0
- ai_review/tests/suites/services/review/internal/summary_reply/test_service.py +21 -0
- ai_review/tests/suites/services/review/runner/__init__.py +0 -0
- ai_review/tests/suites/services/review/runner/test_context.py +89 -0
- ai_review/tests/suites/services/review/runner/test_inline.py +100 -0
- ai_review/tests/suites/services/review/runner/test_inline_reply.py +109 -0
- ai_review/tests/suites/services/review/runner/test_summary.py +87 -0
- ai_review/tests/suites/services/review/runner/test_summary_reply.py +97 -0
- ai_review/tests/suites/services/review/test_service.py +64 -97
- ai_review/tests/suites/services/vcs/bitbucket/test_adapter.py +109 -0
- ai_review/tests/suites/services/vcs/bitbucket/{test_service.py → test_client.py} +88 -1
- ai_review/tests/suites/services/vcs/github/test_adapter.py +162 -0
- ai_review/tests/suites/services/vcs/github/{test_service.py → test_client.py} +102 -2
- ai_review/tests/suites/services/vcs/gitlab/test_adapter.py +105 -0
- ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +99 -1
- {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/METADATA +8 -5
- {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/RECORD +160 -75
- ai_review/services/review/inline/service.py +0 -54
- ai_review/services/review/inline/types.py +0 -11
- ai_review/tests/fixtures/services/review/summary.py +0 -19
- ai_review/tests/suites/services/review/inline/test_service.py +0 -107
- /ai_review/{services/review/inline → libs/llm}/__init__.py +0 -0
- /ai_review/services/review/{policy → internal}/__init__.py +0 -0
- /ai_review/services/review/{summary → internal/inline}/__init__.py +0 -0
- /ai_review/services/review/{inline → internal/inline}/schema.py +0 -0
- /ai_review/{tests/suites/services/review/inline → services/review/internal/inline_reply}/__init__.py +0 -0
- /ai_review/{tests/suites/services/review → services/review/internal}/policy/__init__.py +0 -0
- /ai_review/{tests/suites/services/review → services/review/internal}/summary/__init__.py +0 -0
- /ai_review/services/review/{summary → internal/summary}/schema.py +0 -0
- {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/WHEEL +0 -0
- {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/entry_points.txt +0 -0
- {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
File without changes
|
|
File without changes
|
|
@@ -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"
|
|
File without changes
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
@@ -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(
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|