xai-review 0.27.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/schema/comments.py +14 -0
- ai_review/clients/bitbucket/pr/schema/pull_request.py +1 -5
- ai_review/clients/bitbucket/pr/schema/user.py +7 -0
- ai_review/clients/github/pr/client.py +35 -4
- ai_review/clients/github/pr/schema/comments.py +21 -0
- ai_review/clients/github/pr/schema/pull_request.py +1 -4
- ai_review/clients/github/pr/schema/user.py +6 -0
- ai_review/clients/github/pr/types.py +11 -1
- ai_review/clients/gitlab/mr/client.py +32 -1
- ai_review/clients/gitlab/mr/schema/changes.py +1 -5
- ai_review/clients/gitlab/mr/schema/discussions.py +17 -7
- ai_review/clients/gitlab/mr/schema/notes.py +3 -0
- ai_review/clients/gitlab/mr/schema/user.py +7 -0
- ai_review/clients/gitlab/mr/types.py +16 -7
- ai_review/libs/config/prompt.py +96 -64
- ai_review/libs/config/review.py +2 -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/libs/config/test_prompt.py +108 -28
- 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.27.0.dist-info → xai_review-0.28.0.dist-info}/METADATA +8 -5
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/RECORD +143 -70
- 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.27.0.dist-info → xai_review-0.28.0.dist-info}/WHEEL +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/entry_points.txt +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/licenses/LICENSE +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from ai_review.services.review.runner.context import ContextReviewRunner
|
|
4
|
+
from ai_review.tests.fixtures.services.cost import FakeCostService
|
|
5
|
+
from ai_review.tests.fixtures.services.diff import FakeDiffService
|
|
6
|
+
from ai_review.tests.fixtures.services.prompt import FakePromptService
|
|
7
|
+
from ai_review.tests.fixtures.services.review.gateway.comment import FakeReviewCommentGateway
|
|
8
|
+
from ai_review.tests.fixtures.services.review.gateway.llm import FakeReviewLLMGateway
|
|
9
|
+
from ai_review.tests.fixtures.services.review.internal.inline import FakeInlineCommentService
|
|
10
|
+
from ai_review.tests.fixtures.services.review.internal.policy import FakeReviewPolicyService
|
|
11
|
+
from ai_review.tests.fixtures.services.vcs import FakeVCSClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.asyncio
|
|
15
|
+
async def test_run_happy_path(
|
|
16
|
+
context_review_runner: ContextReviewRunner,
|
|
17
|
+
fake_vcs_client: FakeVCSClient,
|
|
18
|
+
fake_diff_service: FakeDiffService,
|
|
19
|
+
fake_cost_service: FakeCostService,
|
|
20
|
+
fake_prompt_service: FakePromptService,
|
|
21
|
+
fake_review_llm_gateway: FakeReviewLLMGateway,
|
|
22
|
+
fake_review_policy_service: FakeReviewPolicyService,
|
|
23
|
+
fake_review_comment_gateway: FakeReviewCommentGateway,
|
|
24
|
+
):
|
|
25
|
+
"""Should render all changed files, call LLM and post inline comments."""
|
|
26
|
+
await context_review_runner.run()
|
|
27
|
+
|
|
28
|
+
vcs_calls = [call[0] for call in fake_vcs_client.calls]
|
|
29
|
+
assert "get_review_info" in vcs_calls
|
|
30
|
+
|
|
31
|
+
assert any(call[0] == "render_files" for call in fake_diff_service.calls)
|
|
32
|
+
assert any(call[0] == "apply_for_files" for call in fake_review_policy_service.calls)
|
|
33
|
+
assert any(call[0] == "build_context_request" for call in fake_prompt_service.calls)
|
|
34
|
+
assert any(call[0] == "ask" for call in fake_review_llm_gateway.calls)
|
|
35
|
+
assert any(call[0] == "process_inline_comments" for call in fake_review_comment_gateway.calls)
|
|
36
|
+
|
|
37
|
+
assert any(call[0] == "aggregate" for call in fake_cost_service.calls)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_run_skips_when_existing_comments(
|
|
42
|
+
context_review_runner: ContextReviewRunner,
|
|
43
|
+
fake_vcs_client: FakeVCSClient,
|
|
44
|
+
fake_review_llm_gateway: FakeReviewLLMGateway,
|
|
45
|
+
fake_review_comment_gateway: FakeReviewCommentGateway,
|
|
46
|
+
):
|
|
47
|
+
"""Should skip context review if inline comments already exist."""
|
|
48
|
+
fake_review_comment_gateway.responses["has_existing_inline_comments"] = True
|
|
49
|
+
|
|
50
|
+
await context_review_runner.run()
|
|
51
|
+
|
|
52
|
+
vcs_calls = [call[0] for call in fake_vcs_client.calls]
|
|
53
|
+
assert vcs_calls == []
|
|
54
|
+
assert not any(call[0] == "ask" for call in fake_review_llm_gateway.calls)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_run_skips_when_no_changed_files(
|
|
59
|
+
context_review_runner: ContextReviewRunner,
|
|
60
|
+
fake_vcs_client: FakeVCSClient,
|
|
61
|
+
fake_review_policy_service: FakeReviewPolicyService,
|
|
62
|
+
):
|
|
63
|
+
"""Should skip when no changed files after policy filtering."""
|
|
64
|
+
fake_review_policy_service.responses["apply_for_files"] = []
|
|
65
|
+
|
|
66
|
+
await context_review_runner.run()
|
|
67
|
+
|
|
68
|
+
vcs_calls = [call[0] for call in fake_vcs_client.calls]
|
|
69
|
+
assert "get_review_info" in vcs_calls
|
|
70
|
+
assert any(call[0] == "apply_for_files" for call in fake_review_policy_service.calls)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_run_skips_when_no_comments_after_llm(
|
|
75
|
+
context_review_runner: ContextReviewRunner,
|
|
76
|
+
fake_review_llm_gateway: FakeReviewLLMGateway,
|
|
77
|
+
fake_review_policy_service: FakeReviewPolicyService,
|
|
78
|
+
fake_review_comment_gateway: FakeReviewCommentGateway,
|
|
79
|
+
fake_inline_comment_service: FakeInlineCommentService,
|
|
80
|
+
|
|
81
|
+
):
|
|
82
|
+
"""Should not post comments if LLM output is empty."""
|
|
83
|
+
fake_inline_comment_service.comments = []
|
|
84
|
+
|
|
85
|
+
await context_review_runner.run()
|
|
86
|
+
|
|
87
|
+
assert any(call[0] == "ask" for call in fake_review_llm_gateway.calls)
|
|
88
|
+
assert any(call[0] == "apply_for_context_comments" for call in fake_review_policy_service.calls)
|
|
89
|
+
assert not any(call[0] == "process_inline_comments" for call in fake_review_comment_gateway.calls)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from ai_review.services.review.runner.inline import InlineReviewRunner
|
|
4
|
+
from ai_review.services.vcs.types import ReviewInfoSchema
|
|
5
|
+
from ai_review.tests.fixtures.services.cost import FakeCostService
|
|
6
|
+
from ai_review.tests.fixtures.services.diff import FakeDiffService
|
|
7
|
+
from ai_review.tests.fixtures.services.git import FakeGitService
|
|
8
|
+
from ai_review.tests.fixtures.services.prompt import FakePromptService
|
|
9
|
+
from ai_review.tests.fixtures.services.review.gateway.comment import FakeReviewCommentGateway
|
|
10
|
+
from ai_review.tests.fixtures.services.review.gateway.llm import FakeReviewLLMGateway
|
|
11
|
+
from ai_review.tests.fixtures.services.review.internal.inline import FakeInlineCommentService
|
|
12
|
+
from ai_review.tests.fixtures.services.review.internal.policy import FakeReviewPolicyService
|
|
13
|
+
from ai_review.tests.fixtures.services.vcs import FakeVCSClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_run_happy_path(
|
|
18
|
+
inline_review_runner: InlineReviewRunner,
|
|
19
|
+
fake_vcs_client: FakeVCSClient,
|
|
20
|
+
fake_git_service: FakeGitService,
|
|
21
|
+
fake_diff_service: FakeDiffService,
|
|
22
|
+
fake_cost_service: FakeCostService,
|
|
23
|
+
fake_prompt_service: FakePromptService,
|
|
24
|
+
fake_review_llm_gateway: FakeReviewLLMGateway,
|
|
25
|
+
fake_review_policy_service: FakeReviewPolicyService,
|
|
26
|
+
fake_review_comment_gateway: FakeReviewCommentGateway,
|
|
27
|
+
):
|
|
28
|
+
"""Should process all changed files, call LLM and post inline comments."""
|
|
29
|
+
fake_git_service.responses["get_diff_for_file"] = "FAKE_DIFF"
|
|
30
|
+
|
|
31
|
+
await inline_review_runner.run()
|
|
32
|
+
|
|
33
|
+
vcs_calls = [call[0] for call in fake_vcs_client.calls]
|
|
34
|
+
assert "get_review_info" in vcs_calls
|
|
35
|
+
|
|
36
|
+
git_calls = [call[0] for call in fake_git_service.calls]
|
|
37
|
+
assert any(call == "get_diff_for_file" for call in git_calls)
|
|
38
|
+
|
|
39
|
+
assert any(call[0] == "render_file" for call in fake_diff_service.calls)
|
|
40
|
+
assert any(call[0] == "apply_for_files" for call in fake_review_policy_service.calls)
|
|
41
|
+
assert any(call[0] == "build_inline_request" for call in fake_prompt_service.calls)
|
|
42
|
+
assert any(call[0] == "ask" for call in fake_review_llm_gateway.calls)
|
|
43
|
+
assert any(call[0] == "process_inline_comments" for call in fake_review_comment_gateway.calls)
|
|
44
|
+
|
|
45
|
+
assert any(call[0] == "aggregate" for call in fake_cost_service.calls)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_run_skips_when_existing_comments(
|
|
50
|
+
inline_review_runner: InlineReviewRunner,
|
|
51
|
+
fake_vcs_client: FakeVCSClient,
|
|
52
|
+
fake_review_llm_gateway: FakeReviewLLMGateway,
|
|
53
|
+
fake_review_comment_gateway: FakeReviewCommentGateway,
|
|
54
|
+
):
|
|
55
|
+
"""Should skip review if there are already existing inline comments."""
|
|
56
|
+
fake_review_comment_gateway.responses["has_existing_inline_comments"] = True
|
|
57
|
+
|
|
58
|
+
await inline_review_runner.run()
|
|
59
|
+
|
|
60
|
+
vcs_calls = [call[0] for call in fake_vcs_client.calls]
|
|
61
|
+
assert vcs_calls == []
|
|
62
|
+
assert not any(call[0] == "ask" for call in fake_review_llm_gateway.calls)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_process_file_skips_when_no_diff(
|
|
67
|
+
inline_review_runner: InlineReviewRunner,
|
|
68
|
+
fake_git_service: FakeGitService,
|
|
69
|
+
fake_review_llm_gateway: FakeReviewLLMGateway,
|
|
70
|
+
fake_review_comment_gateway: FakeReviewCommentGateway,
|
|
71
|
+
):
|
|
72
|
+
"""Should skip processing file if no diff found."""
|
|
73
|
+
fake_git_service.responses["get_diff_for_file"] = ""
|
|
74
|
+
|
|
75
|
+
review_info = ReviewInfoSchema(base_sha="A", head_sha="B")
|
|
76
|
+
await inline_review_runner.process_file("file.py", review_info)
|
|
77
|
+
|
|
78
|
+
assert not any(call[0] == "ask" for call in fake_review_llm_gateway.calls)
|
|
79
|
+
assert not any(call[0] == "process_inline_comments" for call in fake_review_comment_gateway.calls)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_process_file_skips_when_no_comments_after_llm(
|
|
84
|
+
inline_review_runner: InlineReviewRunner,
|
|
85
|
+
fake_git_service: FakeGitService,
|
|
86
|
+
fake_review_llm_gateway: FakeReviewLLMGateway,
|
|
87
|
+
fake_review_policy_service: FakeReviewPolicyService,
|
|
88
|
+
fake_review_comment_gateway: FakeReviewCommentGateway,
|
|
89
|
+
fake_inline_comment_service: FakeInlineCommentService,
|
|
90
|
+
):
|
|
91
|
+
"""Should not post comments if model output produces no inline comments."""
|
|
92
|
+
fake_git_service.responses["get_diff_for_file"] = "SOME_DIFF"
|
|
93
|
+
fake_inline_comment_service.comments = []
|
|
94
|
+
|
|
95
|
+
review_info = ReviewInfoSchema(base_sha="A", head_sha="B")
|
|
96
|
+
await inline_review_runner.process_file("file.py", review_info)
|
|
97
|
+
|
|
98
|
+
assert any(call[0] == "ask" for call in fake_review_llm_gateway.calls)
|
|
99
|
+
assert any(call[0] == "apply_for_inline_comments" for call in fake_review_policy_service.calls)
|
|
100
|
+
assert not any(call[0] == "process_inline_comments" for call in fake_review_comment_gateway.calls)
|