xai-review 0.27.0__py3-none-any.whl → 0.29.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 (150) 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/schema/comments.py +14 -0
  5. ai_review/clients/bitbucket/pr/schema/pull_request.py +1 -5
  6. ai_review/clients/bitbucket/pr/schema/user.py +7 -0
  7. ai_review/clients/github/pr/client.py +35 -4
  8. ai_review/clients/github/pr/schema/comments.py +21 -0
  9. ai_review/clients/github/pr/schema/pull_request.py +1 -4
  10. ai_review/clients/github/pr/schema/user.py +6 -0
  11. ai_review/clients/github/pr/types.py +11 -1
  12. ai_review/clients/gitlab/mr/client.py +32 -1
  13. ai_review/clients/gitlab/mr/schema/changes.py +1 -5
  14. ai_review/clients/gitlab/mr/schema/discussions.py +14 -12
  15. ai_review/clients/gitlab/mr/schema/notes.py +5 -0
  16. ai_review/clients/gitlab/mr/schema/position.py +13 -0
  17. ai_review/clients/gitlab/mr/schema/user.py +7 -0
  18. ai_review/clients/gitlab/mr/types.py +16 -7
  19. ai_review/libs/asynchronous/gather.py +8 -1
  20. ai_review/libs/config/prompt.py +96 -64
  21. ai_review/libs/config/review.py +2 -0
  22. ai_review/libs/llm/output_json_parser.py +60 -0
  23. ai_review/prompts/default_inline_reply.md +10 -0
  24. ai_review/prompts/default_summary_reply.md +14 -0
  25. ai_review/prompts/default_system_inline_reply.md +31 -0
  26. ai_review/prompts/default_system_summary_reply.md +13 -0
  27. ai_review/services/artifacts/schema.py +2 -2
  28. ai_review/services/git/service.py +42 -11
  29. ai_review/services/hook/constants.py +14 -0
  30. ai_review/services/hook/service.py +95 -4
  31. ai_review/services/hook/types.py +18 -2
  32. ai_review/services/prompt/adapter.py +1 -1
  33. ai_review/services/prompt/service.py +49 -3
  34. ai_review/services/prompt/tools.py +21 -0
  35. ai_review/services/prompt/types.py +23 -0
  36. ai_review/services/review/gateway/comment.py +45 -6
  37. ai_review/services/review/gateway/llm.py +2 -1
  38. ai_review/services/review/gateway/types.py +50 -0
  39. ai_review/services/review/internal/inline/service.py +40 -0
  40. ai_review/services/review/internal/inline/types.py +8 -0
  41. ai_review/services/review/internal/inline_reply/schema.py +23 -0
  42. ai_review/services/review/internal/inline_reply/service.py +20 -0
  43. ai_review/services/review/internal/inline_reply/types.py +8 -0
  44. ai_review/services/review/{policy → internal/policy}/service.py +2 -1
  45. ai_review/services/review/internal/policy/types.py +15 -0
  46. ai_review/services/review/{summary → internal/summary}/service.py +2 -2
  47. ai_review/services/review/{summary → internal/summary}/types.py +1 -1
  48. ai_review/services/review/internal/summary_reply/__init__.py +0 -0
  49. ai_review/services/review/internal/summary_reply/schema.py +8 -0
  50. ai_review/services/review/internal/summary_reply/service.py +15 -0
  51. ai_review/services/review/internal/summary_reply/types.py +8 -0
  52. ai_review/services/review/runner/__init__.py +0 -0
  53. ai_review/services/review/runner/context.py +72 -0
  54. ai_review/services/review/runner/inline.py +80 -0
  55. ai_review/services/review/runner/inline_reply.py +80 -0
  56. ai_review/services/review/runner/summary.py +71 -0
  57. ai_review/services/review/runner/summary_reply.py +79 -0
  58. ai_review/services/review/runner/types.py +6 -0
  59. ai_review/services/review/service.py +78 -110
  60. ai_review/services/vcs/bitbucket/adapter.py +27 -0
  61. ai_review/services/vcs/bitbucket/client.py +118 -42
  62. ai_review/services/vcs/github/adapter.py +35 -0
  63. ai_review/services/vcs/github/client.py +105 -44
  64. ai_review/services/vcs/gitlab/adapter.py +28 -0
  65. ai_review/services/vcs/gitlab/client.py +103 -43
  66. ai_review/services/vcs/types.py +34 -0
  67. ai_review/tests/fixtures/clients/bitbucket.py +2 -2
  68. ai_review/tests/fixtures/clients/github.py +35 -6
  69. ai_review/tests/fixtures/clients/gitlab.py +71 -6
  70. ai_review/tests/fixtures/libs/__init__.py +0 -0
  71. ai_review/tests/fixtures/libs/llm/__init__.py +0 -0
  72. ai_review/tests/fixtures/libs/llm/output_json_parser.py +13 -0
  73. ai_review/tests/fixtures/services/hook.py +8 -0
  74. ai_review/tests/fixtures/services/llm.py +8 -5
  75. ai_review/tests/fixtures/services/prompt.py +70 -0
  76. ai_review/tests/fixtures/services/review/base.py +41 -0
  77. ai_review/tests/fixtures/services/review/gateway/__init__.py +0 -0
  78. ai_review/tests/fixtures/services/review/gateway/comment.py +98 -0
  79. ai_review/tests/fixtures/services/review/gateway/llm.py +17 -0
  80. ai_review/tests/fixtures/services/review/internal/__init__.py +0 -0
  81. ai_review/tests/fixtures/services/review/{inline.py → internal/inline.py} +8 -6
  82. ai_review/tests/fixtures/services/review/internal/inline_reply.py +25 -0
  83. ai_review/tests/fixtures/services/review/internal/policy.py +28 -0
  84. ai_review/tests/fixtures/services/review/internal/summary.py +21 -0
  85. ai_review/tests/fixtures/services/review/internal/summary_reply.py +19 -0
  86. ai_review/tests/fixtures/services/review/runner/__init__.py +0 -0
  87. ai_review/tests/fixtures/services/review/runner/context.py +50 -0
  88. ai_review/tests/fixtures/services/review/runner/inline.py +50 -0
  89. ai_review/tests/fixtures/services/review/runner/inline_reply.py +50 -0
  90. ai_review/tests/fixtures/services/review/runner/summary.py +50 -0
  91. ai_review/tests/fixtures/services/review/runner/summary_reply.py +50 -0
  92. ai_review/tests/fixtures/services/vcs.py +23 -0
  93. ai_review/tests/suites/cli/__init__.py +0 -0
  94. ai_review/tests/suites/cli/test_main.py +54 -0
  95. ai_review/tests/suites/libs/config/test_prompt.py +108 -28
  96. ai_review/tests/suites/libs/llm/__init__.py +0 -0
  97. ai_review/tests/suites/libs/llm/test_output_json_parser.py +155 -0
  98. ai_review/tests/suites/services/hook/test_service.py +88 -4
  99. ai_review/tests/suites/services/prompt/test_adapter.py +3 -3
  100. ai_review/tests/suites/services/prompt/test_service.py +102 -58
  101. ai_review/tests/suites/services/prompt/test_tools.py +86 -1
  102. ai_review/tests/suites/services/review/gateway/__init__.py +0 -0
  103. ai_review/tests/suites/services/review/gateway/test_comment.py +253 -0
  104. ai_review/tests/suites/services/review/gateway/test_llm.py +82 -0
  105. ai_review/tests/suites/services/review/internal/__init__.py +0 -0
  106. ai_review/tests/suites/services/review/internal/inline/__init__.py +0 -0
  107. ai_review/tests/suites/services/review/{inline → internal/inline}/test_schema.py +1 -1
  108. ai_review/tests/suites/services/review/internal/inline/test_service.py +81 -0
  109. ai_review/tests/suites/services/review/internal/inline_reply/__init__.py +0 -0
  110. ai_review/tests/suites/services/review/internal/inline_reply/test_schema.py +57 -0
  111. ai_review/tests/suites/services/review/internal/inline_reply/test_service.py +72 -0
  112. ai_review/tests/suites/services/review/internal/policy/__init__.py +0 -0
  113. ai_review/tests/suites/services/review/{policy → internal/policy}/test_service.py +1 -1
  114. ai_review/tests/suites/services/review/internal/summary/__init__.py +0 -0
  115. ai_review/tests/suites/services/review/{summary → internal/summary}/test_schema.py +1 -1
  116. ai_review/tests/suites/services/review/{summary → internal/summary}/test_service.py +2 -2
  117. ai_review/tests/suites/services/review/internal/summary_reply/__init__.py +0 -0
  118. ai_review/tests/suites/services/review/internal/summary_reply/test_schema.py +19 -0
  119. ai_review/tests/suites/services/review/internal/summary_reply/test_service.py +21 -0
  120. ai_review/tests/suites/services/review/runner/__init__.py +0 -0
  121. ai_review/tests/suites/services/review/runner/test_context.py +89 -0
  122. ai_review/tests/suites/services/review/runner/test_inline.py +100 -0
  123. ai_review/tests/suites/services/review/runner/test_inline_reply.py +109 -0
  124. ai_review/tests/suites/services/review/runner/test_summary.py +87 -0
  125. ai_review/tests/suites/services/review/runner/test_summary_reply.py +97 -0
  126. ai_review/tests/suites/services/review/test_service.py +64 -97
  127. ai_review/tests/suites/services/vcs/bitbucket/test_adapter.py +109 -0
  128. ai_review/tests/suites/services/vcs/bitbucket/{test_service.py → test_client.py} +88 -1
  129. ai_review/tests/suites/services/vcs/github/test_adapter.py +162 -0
  130. ai_review/tests/suites/services/vcs/github/{test_service.py → test_client.py} +102 -2
  131. ai_review/tests/suites/services/vcs/gitlab/test_adapter.py +134 -0
  132. ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +113 -3
  133. {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/METADATA +8 -5
  134. {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/RECORD +146 -72
  135. ai_review/services/review/inline/service.py +0 -54
  136. ai_review/services/review/inline/types.py +0 -11
  137. ai_review/tests/fixtures/services/review/summary.py +0 -19
  138. ai_review/tests/suites/services/review/inline/test_service.py +0 -107
  139. /ai_review/{services/review/inline → libs/llm}/__init__.py +0 -0
  140. /ai_review/services/review/{policy → internal}/__init__.py +0 -0
  141. /ai_review/services/review/{summary → internal/inline}/__init__.py +0 -0
  142. /ai_review/services/review/{inline → internal/inline}/schema.py +0 -0
  143. /ai_review/{tests/suites/services/review/inline → services/review/internal/inline_reply}/__init__.py +0 -0
  144. /ai_review/{tests/suites/services/review → services/review/internal}/policy/__init__.py +0 -0
  145. /ai_review/{tests/suites/services/review → services/review/internal}/summary/__init__.py +0 -0
  146. /ai_review/services/review/{summary → internal/summary}/schema.py +0 -0
  147. {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/WHEEL +0 -0
  148. {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/entry_points.txt +0 -0
  149. {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/licenses/LICENSE +0 -0
  150. {xai_review-0.27.0.dist-info → xai_review-0.29.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
@@ -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
@@ -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)