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.

Files changed (147) 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 +17 -7
  15. ai_review/clients/gitlab/mr/schema/notes.py +3 -0
  16. ai_review/clients/gitlab/mr/schema/user.py +7 -0
  17. ai_review/clients/gitlab/mr/types.py +16 -7
  18. ai_review/libs/config/prompt.py +96 -64
  19. ai_review/libs/config/review.py +2 -0
  20. ai_review/libs/llm/output_json_parser.py +60 -0
  21. ai_review/prompts/default_inline_reply.md +10 -0
  22. ai_review/prompts/default_summary_reply.md +14 -0
  23. ai_review/prompts/default_system_inline_reply.md +31 -0
  24. ai_review/prompts/default_system_summary_reply.md +13 -0
  25. ai_review/services/artifacts/schema.py +2 -2
  26. ai_review/services/hook/constants.py +14 -0
  27. ai_review/services/hook/service.py +95 -4
  28. ai_review/services/hook/types.py +18 -2
  29. ai_review/services/prompt/adapter.py +1 -1
  30. ai_review/services/prompt/service.py +49 -3
  31. ai_review/services/prompt/tools.py +21 -0
  32. ai_review/services/prompt/types.py +23 -0
  33. ai_review/services/review/gateway/comment.py +45 -6
  34. ai_review/services/review/gateway/llm.py +2 -1
  35. ai_review/services/review/gateway/types.py +50 -0
  36. ai_review/services/review/internal/inline/service.py +40 -0
  37. ai_review/services/review/internal/inline/types.py +8 -0
  38. ai_review/services/review/internal/inline_reply/schema.py +23 -0
  39. ai_review/services/review/internal/inline_reply/service.py +20 -0
  40. ai_review/services/review/internal/inline_reply/types.py +8 -0
  41. ai_review/services/review/{policy → internal/policy}/service.py +2 -1
  42. ai_review/services/review/internal/policy/types.py +15 -0
  43. ai_review/services/review/{summary → internal/summary}/service.py +2 -2
  44. ai_review/services/review/{summary → internal/summary}/types.py +1 -1
  45. ai_review/services/review/internal/summary_reply/__init__.py +0 -0
  46. ai_review/services/review/internal/summary_reply/schema.py +8 -0
  47. ai_review/services/review/internal/summary_reply/service.py +15 -0
  48. ai_review/services/review/internal/summary_reply/types.py +8 -0
  49. ai_review/services/review/runner/__init__.py +0 -0
  50. ai_review/services/review/runner/context.py +72 -0
  51. ai_review/services/review/runner/inline.py +80 -0
  52. ai_review/services/review/runner/inline_reply.py +80 -0
  53. ai_review/services/review/runner/summary.py +71 -0
  54. ai_review/services/review/runner/summary_reply.py +79 -0
  55. ai_review/services/review/runner/types.py +6 -0
  56. ai_review/services/review/service.py +78 -110
  57. ai_review/services/vcs/bitbucket/adapter.py +24 -0
  58. ai_review/services/vcs/bitbucket/client.py +107 -42
  59. ai_review/services/vcs/github/adapter.py +35 -0
  60. ai_review/services/vcs/github/client.py +105 -44
  61. ai_review/services/vcs/gitlab/adapter.py +26 -0
  62. ai_review/services/vcs/gitlab/client.py +91 -38
  63. ai_review/services/vcs/types.py +34 -0
  64. ai_review/tests/fixtures/clients/bitbucket.py +2 -2
  65. ai_review/tests/fixtures/clients/github.py +35 -6
  66. ai_review/tests/fixtures/clients/gitlab.py +42 -3
  67. ai_review/tests/fixtures/libs/__init__.py +0 -0
  68. ai_review/tests/fixtures/libs/llm/__init__.py +0 -0
  69. ai_review/tests/fixtures/libs/llm/output_json_parser.py +13 -0
  70. ai_review/tests/fixtures/services/hook.py +8 -0
  71. ai_review/tests/fixtures/services/llm.py +8 -5
  72. ai_review/tests/fixtures/services/prompt.py +70 -0
  73. ai_review/tests/fixtures/services/review/base.py +41 -0
  74. ai_review/tests/fixtures/services/review/gateway/__init__.py +0 -0
  75. ai_review/tests/fixtures/services/review/gateway/comment.py +98 -0
  76. ai_review/tests/fixtures/services/review/gateway/llm.py +17 -0
  77. ai_review/tests/fixtures/services/review/internal/__init__.py +0 -0
  78. ai_review/tests/fixtures/services/review/{inline.py → internal/inline.py} +8 -6
  79. ai_review/tests/fixtures/services/review/internal/inline_reply.py +25 -0
  80. ai_review/tests/fixtures/services/review/internal/policy.py +28 -0
  81. ai_review/tests/fixtures/services/review/internal/summary.py +21 -0
  82. ai_review/tests/fixtures/services/review/internal/summary_reply.py +19 -0
  83. ai_review/tests/fixtures/services/review/runner/__init__.py +0 -0
  84. ai_review/tests/fixtures/services/review/runner/context.py +50 -0
  85. ai_review/tests/fixtures/services/review/runner/inline.py +50 -0
  86. ai_review/tests/fixtures/services/review/runner/inline_reply.py +50 -0
  87. ai_review/tests/fixtures/services/review/runner/summary.py +50 -0
  88. ai_review/tests/fixtures/services/review/runner/summary_reply.py +50 -0
  89. ai_review/tests/fixtures/services/vcs.py +23 -0
  90. ai_review/tests/suites/cli/__init__.py +0 -0
  91. ai_review/tests/suites/cli/test_main.py +54 -0
  92. ai_review/tests/suites/libs/config/test_prompt.py +108 -28
  93. ai_review/tests/suites/libs/llm/__init__.py +0 -0
  94. ai_review/tests/suites/libs/llm/test_output_json_parser.py +155 -0
  95. ai_review/tests/suites/services/hook/test_service.py +88 -4
  96. ai_review/tests/suites/services/prompt/test_adapter.py +3 -3
  97. ai_review/tests/suites/services/prompt/test_service.py +102 -58
  98. ai_review/tests/suites/services/prompt/test_tools.py +86 -1
  99. ai_review/tests/suites/services/review/gateway/__init__.py +0 -0
  100. ai_review/tests/suites/services/review/gateway/test_comment.py +253 -0
  101. ai_review/tests/suites/services/review/gateway/test_llm.py +82 -0
  102. ai_review/tests/suites/services/review/internal/__init__.py +0 -0
  103. ai_review/tests/suites/services/review/internal/inline/__init__.py +0 -0
  104. ai_review/tests/suites/services/review/{inline → internal/inline}/test_schema.py +1 -1
  105. ai_review/tests/suites/services/review/internal/inline/test_service.py +81 -0
  106. ai_review/tests/suites/services/review/internal/inline_reply/__init__.py +0 -0
  107. ai_review/tests/suites/services/review/internal/inline_reply/test_schema.py +57 -0
  108. ai_review/tests/suites/services/review/internal/inline_reply/test_service.py +72 -0
  109. ai_review/tests/suites/services/review/internal/policy/__init__.py +0 -0
  110. ai_review/tests/suites/services/review/{policy → internal/policy}/test_service.py +1 -1
  111. ai_review/tests/suites/services/review/internal/summary/__init__.py +0 -0
  112. ai_review/tests/suites/services/review/{summary → internal/summary}/test_schema.py +1 -1
  113. ai_review/tests/suites/services/review/{summary → internal/summary}/test_service.py +2 -2
  114. ai_review/tests/suites/services/review/internal/summary_reply/__init__.py +0 -0
  115. ai_review/tests/suites/services/review/internal/summary_reply/test_schema.py +19 -0
  116. ai_review/tests/suites/services/review/internal/summary_reply/test_service.py +21 -0
  117. ai_review/tests/suites/services/review/runner/__init__.py +0 -0
  118. ai_review/tests/suites/services/review/runner/test_context.py +89 -0
  119. ai_review/tests/suites/services/review/runner/test_inline.py +100 -0
  120. ai_review/tests/suites/services/review/runner/test_inline_reply.py +109 -0
  121. ai_review/tests/suites/services/review/runner/test_summary.py +87 -0
  122. ai_review/tests/suites/services/review/runner/test_summary_reply.py +97 -0
  123. ai_review/tests/suites/services/review/test_service.py +64 -97
  124. ai_review/tests/suites/services/vcs/bitbucket/test_adapter.py +109 -0
  125. ai_review/tests/suites/services/vcs/bitbucket/{test_service.py → test_client.py} +88 -1
  126. ai_review/tests/suites/services/vcs/github/test_adapter.py +162 -0
  127. ai_review/tests/suites/services/vcs/github/{test_service.py → test_client.py} +102 -2
  128. ai_review/tests/suites/services/vcs/gitlab/test_adapter.py +105 -0
  129. ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +99 -1
  130. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/METADATA +8 -5
  131. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/RECORD +143 -70
  132. ai_review/services/review/inline/service.py +0 -54
  133. ai_review/services/review/inline/types.py +0 -11
  134. ai_review/tests/fixtures/services/review/summary.py +0 -19
  135. ai_review/tests/suites/services/review/inline/test_service.py +0 -107
  136. /ai_review/{services/review/inline → libs/llm}/__init__.py +0 -0
  137. /ai_review/services/review/{policy → internal}/__init__.py +0 -0
  138. /ai_review/services/review/{summary → internal/inline}/__init__.py +0 -0
  139. /ai_review/services/review/{inline → internal/inline}/schema.py +0 -0
  140. /ai_review/{tests/suites/services/review/inline → services/review/internal/inline_reply}/__init__.py +0 -0
  141. /ai_review/{tests/suites/services/review → services/review/internal}/policy/__init__.py +0 -0
  142. /ai_review/{tests/suites/services/review → services/review/internal}/summary/__init__.py +0 -0
  143. /ai_review/services/review/{summary → internal/summary}/schema.py +0 -0
  144. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/WHEEL +0 -0
  145. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/entry_points.txt +0 -0
  146. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/licenses/LICENSE +0 -0
  147. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
 
3
3
  from ai_review.services.vcs.bitbucket.client import BitbucketVCSClient
4
- from ai_review.services.vcs.types import ReviewInfoSchema, ReviewCommentSchema
4
+ from ai_review.services.vcs.types import ReviewInfoSchema, ReviewCommentSchema, ReviewThreadSchema, ThreadKind
5
5
  from ai_review.tests.fixtures.clients.bitbucket import FakeBitbucketPullRequestsHTTPClient
6
6
 
7
7
 
@@ -115,3 +115,90 @@ async def test_create_inline_comment_posts_comment(
115
115
  assert call_args["content"]["raw"] == message
116
116
  assert call_args["inline"]["path"] == file
117
117
  assert call_args["inline"]["to"] == line
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ @pytest.mark.usefixtures("bitbucket_http_client_config")
122
+ async def test_create_inline_reply_posts_comment(
123
+ bitbucket_vcs_client: BitbucketVCSClient,
124
+ fake_bitbucket_pull_requests_http_client: FakeBitbucketPullRequestsHTTPClient,
125
+ ):
126
+ """Should post a reply to an existing inline thread."""
127
+ thread_id = 42
128
+ message = "I agree with this inline comment."
129
+
130
+ await bitbucket_vcs_client.create_inline_reply(thread_id, message)
131
+
132
+ calls = [args for name, args in fake_bitbucket_pull_requests_http_client.calls if name == "create_comment"]
133
+ assert len(calls) == 1
134
+
135
+ call_args = calls[0]
136
+ assert call_args["parent"]["id"] == thread_id
137
+ assert call_args["content"]["raw"] == message
138
+ assert call_args["workspace"] == "workspace"
139
+ assert call_args["repo_slug"] == "repo"
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ @pytest.mark.usefixtures("bitbucket_http_client_config")
144
+ async def test_create_summary_reply_posts_comment_with_parent(
145
+ bitbucket_vcs_client: BitbucketVCSClient,
146
+ fake_bitbucket_pull_requests_http_client: FakeBitbucketPullRequestsHTTPClient,
147
+ ):
148
+ """Should post a reply to a general thread (same API with parent id)."""
149
+ thread_id = 7
150
+ message = "Thanks for the clarification."
151
+
152
+ await bitbucket_vcs_client.create_summary_reply(thread_id, message)
153
+
154
+ calls = [args for name, args in fake_bitbucket_pull_requests_http_client.calls if name == "create_comment"]
155
+ assert len(calls) == 1
156
+
157
+ call_args = calls[0]
158
+ assert call_args["parent"]["id"] == thread_id
159
+ assert call_args["content"]["raw"] == message
160
+ assert call_args["workspace"] == "workspace"
161
+ assert call_args["repo_slug"] == "repo"
162
+
163
+
164
+ @pytest.mark.asyncio
165
+ @pytest.mark.usefixtures("bitbucket_http_client_config")
166
+ async def test_get_inline_threads_groups_by_thread_id(
167
+ bitbucket_vcs_client: BitbucketVCSClient,
168
+ fake_bitbucket_pull_requests_http_client: FakeBitbucketPullRequestsHTTPClient,
169
+ ):
170
+ """Should group inline comments into threads."""
171
+ threads = await bitbucket_vcs_client.get_inline_threads()
172
+
173
+ assert all(isinstance(thread, ReviewThreadSchema) for thread in threads)
174
+ assert len(threads) == 1
175
+
176
+ thread = threads[0]
177
+ assert thread.kind == ThreadKind.INLINE
178
+ assert thread.file == "file.py"
179
+ assert thread.line == 5
180
+ assert len(thread.comments) == 1
181
+ assert isinstance(thread.comments[0], ReviewCommentSchema)
182
+
183
+ called_methods = [name for name, _ in fake_bitbucket_pull_requests_http_client.calls]
184
+ assert "get_comments" in called_methods
185
+
186
+
187
+ @pytest.mark.asyncio
188
+ @pytest.mark.usefixtures("bitbucket_http_client_config")
189
+ async def test_get_general_threads_groups_by_thread_id(
190
+ bitbucket_vcs_client: BitbucketVCSClient,
191
+ fake_bitbucket_pull_requests_http_client: FakeBitbucketPullRequestsHTTPClient,
192
+ ):
193
+ """Should group general (non-inline) comments into SUMMARY threads."""
194
+ threads = await bitbucket_vcs_client.get_general_threads()
195
+
196
+ assert all(isinstance(t, ReviewThreadSchema) for t in threads)
197
+ assert len(threads) == 1
198
+ thread = threads[0]
199
+ assert thread.kind == ThreadKind.SUMMARY
200
+ assert len(thread.comments) == 1
201
+ assert isinstance(thread.comments[0], ReviewCommentSchema)
202
+
203
+ called_methods = [name for name, _ in fake_bitbucket_pull_requests_http_client.calls]
204
+ assert "get_comments" in called_methods
@@ -0,0 +1,162 @@
1
+ from ai_review.clients.github.pr.schema.comments import (
2
+ GitHubPRCommentSchema,
3
+ GitHubIssueCommentSchema,
4
+ )
5
+ from ai_review.clients.github.pr.schema.user import GitHubUserSchema
6
+ from ai_review.services.vcs.github.adapter import (
7
+ get_review_comment_from_github_pr_comment,
8
+ get_review_comment_from_github_issue_comment,
9
+ )
10
+ from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
11
+
12
+
13
+ def test_maps_all_fields_correctly_for_pr_comment():
14
+ """Should map GitHub PR comment with all fields correctly."""
15
+ comment = GitHubPRCommentSchema(
16
+ id=101,
17
+ body="Looks fine to me",
18
+ path="src/utils.py",
19
+ line=42,
20
+ user=GitHubUserSchema(id=7, login="alice"),
21
+ in_reply_to_id=None,
22
+ )
23
+
24
+ result = get_review_comment_from_github_pr_comment(comment)
25
+
26
+ assert isinstance(result, ReviewCommentSchema)
27
+ assert result.id == 101
28
+ assert result.body == "Looks fine to me"
29
+ assert result.file == "src/utils.py"
30
+ assert result.line == 42
31
+ assert result.parent_id is None
32
+ assert result.thread_id == 101
33
+
34
+ assert isinstance(result.author, UserSchema)
35
+ assert result.author.id == 7
36
+ assert result.author.name == "alice"
37
+ assert result.author.username == "alice"
38
+
39
+
40
+ def test_maps_reply_comment_with_parent_id():
41
+ """Should assign parent_id and use it as thread_id for replies."""
42
+ comment = GitHubPRCommentSchema(
43
+ id=202,
44
+ body="Agreed with above",
45
+ path="src/main.py",
46
+ line=10,
47
+ user=GitHubUserSchema(id=8, login="bob"),
48
+ in_reply_to_id=101,
49
+ )
50
+
51
+ result = get_review_comment_from_github_pr_comment(comment)
52
+
53
+ assert result.parent_id == 101
54
+ assert result.thread_id == 101
55
+ assert result.id == 202
56
+
57
+
58
+ def test_maps_comment_without_user():
59
+ """Should handle missing user gracefully."""
60
+ comment = GitHubPRCommentSchema(
61
+ id=303,
62
+ body="Anonymous feedback",
63
+ path="src/app.py",
64
+ line=20,
65
+ user=None,
66
+ in_reply_to_id=None,
67
+ )
68
+
69
+ result = get_review_comment_from_github_pr_comment(comment)
70
+
71
+ assert isinstance(result.author, UserSchema)
72
+ assert result.author.id is None
73
+ assert result.author.name == ""
74
+ assert result.author.username == ""
75
+
76
+
77
+ def test_maps_comment_with_empty_body():
78
+ """Should default body to empty string if it's empty or None."""
79
+ comment = GitHubPRCommentSchema(
80
+ id=404,
81
+ body="",
82
+ path=None,
83
+ line=None,
84
+ user=GitHubUserSchema(id=1, login="bot"),
85
+ in_reply_to_id=None,
86
+ )
87
+
88
+ result = get_review_comment_from_github_pr_comment(comment)
89
+
90
+ assert isinstance(result, ReviewCommentSchema)
91
+ assert result.body == ""
92
+ assert result.file is None
93
+ assert result.line is None
94
+ assert result.thread_id == 404
95
+
96
+
97
+ def test_maps_issue_comment_all_fields():
98
+ """Should map GitHub issue-level comment correctly."""
99
+ comment = GitHubIssueCommentSchema(
100
+ id=555,
101
+ body="Top-level discussion",
102
+ user=GitHubUserSchema(id=9, login="charlie"),
103
+ )
104
+
105
+ result = get_review_comment_from_github_issue_comment(comment)
106
+
107
+ assert isinstance(result, ReviewCommentSchema)
108
+ assert result.id == 555
109
+ assert result.body == "Top-level discussion"
110
+ assert result.thread_id == 555
111
+ assert isinstance(result.author, UserSchema)
112
+
113
+
114
+ def test_maps_issue_comment_with_empty_body():
115
+ """Should default empty body to empty string."""
116
+ comment = GitHubIssueCommentSchema(id=666, body="", user=None)
117
+
118
+ result = get_review_comment_from_github_issue_comment(comment)
119
+
120
+ assert isinstance(result, ReviewCommentSchema)
121
+ assert result.id == 666
122
+ assert result.body == ""
123
+ assert result.thread_id == 666
124
+
125
+
126
+ def test_issue_comment_without_user_is_handled():
127
+ """Should create empty UserSchema when GitHub issue comment has no user."""
128
+ comment = GitHubIssueCommentSchema(
129
+ id=777,
130
+ body="General feedback",
131
+ user=None,
132
+ )
133
+
134
+ result = get_review_comment_from_github_issue_comment(comment)
135
+
136
+ assert isinstance(result.author, UserSchema)
137
+ assert result.author.id is None
138
+ assert result.author.name == ""
139
+ assert result.author.username == ""
140
+ assert result.body == "General feedback"
141
+ assert result.thread_id == 777
142
+
143
+
144
+ def test_pr_comment_with_parent_and_missing_file_line():
145
+ """Should handle replies without path/line gracefully."""
146
+ comment = GitHubPRCommentSchema(
147
+ id=999,
148
+ body="Follow-up question",
149
+ path=None,
150
+ line=None,
151
+ user=GitHubUserSchema(id=10, login="eve"),
152
+ in_reply_to_id=101,
153
+ )
154
+
155
+ result = get_review_comment_from_github_pr_comment(comment)
156
+
157
+ assert result.parent_id == 101
158
+ assert result.thread_id == 101
159
+ assert result.file is None
160
+ assert result.line is None
161
+ assert result.body == "Follow-up question"
162
+ assert result.author.username == "eve"
@@ -1,7 +1,13 @@
1
1
  import pytest
2
2
 
3
3
  from ai_review.services.vcs.github.client import GitHubVCSClient
4
- from ai_review.services.vcs.types import ReviewInfoSchema, ReviewCommentSchema
4
+ from ai_review.services.vcs.types import (
5
+ ThreadKind,
6
+ UserSchema,
7
+ ReviewInfoSchema,
8
+ ReviewThreadSchema,
9
+ ReviewCommentSchema,
10
+ )
5
11
  from ai_review.tests.fixtures.clients.github import FakeGitHubPullRequestsHTTPClient
6
12
 
7
13
 
@@ -111,4 +117,98 @@ async def test_create_inline_comment_posts_comment(
111
117
  assert call_args["path"] == "file.py"
112
118
  assert call_args["line"] == 10
113
119
  assert call_args["body"] == "Looks good"
114
- assert call_args["commit_id"] == "def456" # from fake PR head
120
+ assert call_args["commit_id"] == "def456"
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ @pytest.mark.usefixtures("github_http_client_config")
125
+ async def test_create_inline_reply_posts_comment(
126
+ github_vcs_client: GitHubVCSClient,
127
+ fake_github_pull_requests_http_client: FakeGitHubPullRequestsHTTPClient,
128
+ ):
129
+ """Should post a reply to an existing inline comment."""
130
+ thread_id = 3
131
+ message = "I agree with this suggestion."
132
+
133
+ await github_vcs_client.create_inline_reply(thread_id, message)
134
+
135
+ calls = [args for name, args in fake_github_pull_requests_http_client.calls if name == "create_review_reply"]
136
+ assert len(calls) == 1
137
+
138
+ call_args = calls[0]
139
+ assert call_args["in_reply_to"] == thread_id
140
+ assert call_args["body"] == message
141
+ assert call_args["repo"] == "repo"
142
+ assert call_args["owner"] == "owner"
143
+ assert call_args["pull_number"] == "pull_number"
144
+
145
+
146
+ @pytest.mark.asyncio
147
+ @pytest.mark.usefixtures("github_http_client_config")
148
+ async def test_create_summary_reply_reuses_general_comment_method(
149
+ github_vcs_client: GitHubVCSClient,
150
+ fake_github_pull_requests_http_client: FakeGitHubPullRequestsHTTPClient,
151
+ ):
152
+ """Should call create_issue_comment internally (since GitHub summary comments are flat)."""
153
+ thread_id = 11
154
+ message = "Thanks for clarifying."
155
+
156
+ await github_vcs_client.create_summary_reply(thread_id, message)
157
+
158
+ calls = [args for name, args in fake_github_pull_requests_http_client.calls if name == "create_issue_comment"]
159
+ assert len(calls) == 1
160
+
161
+ call_args = calls[0]
162
+ assert call_args["body"] == message
163
+ assert call_args["repo"] == "repo"
164
+ assert call_args["owner"] == "owner"
165
+
166
+
167
+ @pytest.mark.asyncio
168
+ @pytest.mark.usefixtures("github_http_client_config")
169
+ async def test_get_inline_threads_returns_grouped_threads(
170
+ github_vcs_client: GitHubVCSClient,
171
+ fake_github_pull_requests_http_client: FakeGitHubPullRequestsHTTPClient,
172
+ ):
173
+ """Should group inline review comments into threads by file and line."""
174
+ threads = await github_vcs_client.get_inline_threads()
175
+
176
+ assert all(isinstance(t, ReviewThreadSchema) for t in threads)
177
+ assert len(threads) == 2 # 2 comments with unique IDs
178
+
179
+ first = threads[0]
180
+ assert first.kind == ThreadKind.INLINE
181
+ assert isinstance(first.comments[0], ReviewCommentSchema)
182
+ assert first.file == "file.py"
183
+ assert first.line == 5
184
+
185
+ called_methods = [name for name, _ in fake_github_pull_requests_http_client.calls]
186
+ assert "get_review_comments" in called_methods
187
+
188
+
189
+ @pytest.mark.asyncio
190
+ @pytest.mark.usefixtures("github_http_client_config")
191
+ async def test_get_general_threads_wraps_comments_in_threads(
192
+ github_vcs_client: GitHubVCSClient,
193
+ fake_github_pull_requests_http_client: FakeGitHubPullRequestsHTTPClient,
194
+ ):
195
+ """Should wrap each general comment as a separate SUMMARY thread."""
196
+ threads = await github_vcs_client.get_general_threads()
197
+
198
+ assert all(isinstance(thread, ReviewThreadSchema) for thread in threads)
199
+ assert all(thread.kind == ThreadKind.SUMMARY for thread in threads)
200
+ assert len(threads) == 2
201
+
202
+ authors = {t.comments[0].author.username for t in threads}
203
+ assert authors == {"alice", "bob"}
204
+
205
+ for thread in threads:
206
+ comment = thread.comments[0]
207
+ assert isinstance(comment, ReviewCommentSchema)
208
+ assert isinstance(comment.author, UserSchema)
209
+ assert comment.author.id is not None
210
+ assert comment.author.username != ""
211
+ assert comment.thread_id == comment.id
212
+
213
+ called_methods = [name for name, _ in fake_github_pull_requests_http_client.calls]
214
+ assert "get_issue_comments" in called_methods
@@ -0,0 +1,105 @@
1
+ from ai_review.clients.gitlab.mr.schema.discussions import (
2
+ GitLabDiscussionSchema,
3
+ GitLabDiscussionPositionSchema,
4
+ )
5
+ from ai_review.clients.gitlab.mr.schema.notes import GitLabNoteSchema
6
+ from ai_review.clients.gitlab.mr.schema.user import GitLabUserSchema
7
+ from ai_review.services.vcs.gitlab.adapter import get_review_comment_from_gitlab_note
8
+ from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
9
+
10
+
11
+ def test_maps_all_fields_correctly():
12
+ """Should map GitLab note and discussion into ReviewCommentSchema."""
13
+ note = GitLabNoteSchema(
14
+ id=123,
15
+ body="Looks good!",
16
+ author=GitLabUserSchema(id=10, name="Alice", username="alice"),
17
+ )
18
+ discussion = GitLabDiscussionSchema(
19
+ id="42",
20
+ notes=[note],
21
+ position=GitLabDiscussionPositionSchema(
22
+ base_sha="AAA000",
23
+ head_sha="BBB111",
24
+ start_sha="CCC222",
25
+ new_path="src/app.py",
26
+ new_line=15,
27
+ )
28
+ )
29
+
30
+ result = get_review_comment_from_gitlab_note(note, discussion)
31
+
32
+ assert isinstance(result, ReviewCommentSchema)
33
+ assert result.id == 123
34
+ assert result.body == "Looks good!"
35
+ assert result.file == "src/app.py"
36
+ assert result.line == 15
37
+ assert result.thread_id == "42"
38
+
39
+ assert isinstance(result.author, UserSchema)
40
+ assert result.author.id == 10
41
+ assert result.author.name == "Alice"
42
+ assert result.author.username == "alice"
43
+
44
+
45
+ def test_maps_with_missing_author():
46
+ """Should handle note without author gracefully (default empty UserSchema)."""
47
+ note = GitLabNoteSchema(id=1, body="Anonymous comment", author=None)
48
+ discussion = GitLabDiscussionSchema(
49
+ id="7",
50
+ notes=[note],
51
+ position=GitLabDiscussionPositionSchema(
52
+ base_sha="AAA000",
53
+ head_sha="BBB111",
54
+ start_sha="CCC222",
55
+ new_path="main.py",
56
+ new_line=3,
57
+ ),
58
+ )
59
+
60
+ result = get_review_comment_from_gitlab_note(note, discussion)
61
+
62
+ assert isinstance(result.author, UserSchema)
63
+ assert result.author.id is None
64
+ assert result.author.name == ""
65
+ assert result.author.username == ""
66
+
67
+
68
+ def test_maps_with_missing_position():
69
+ """Should handle discussion without position gracefully (file/line become None)."""
70
+ note = GitLabNoteSchema(
71
+ id=55,
72
+ body="General comment",
73
+ author=GitLabUserSchema(id=9, name="Bob", username="bob"),
74
+ )
75
+ discussion = GitLabDiscussionSchema(
76
+ id="999",
77
+ notes=[note],
78
+ position=None,
79
+ )
80
+
81
+ result = get_review_comment_from_gitlab_note(note, discussion)
82
+
83
+ assert isinstance(result, ReviewCommentSchema)
84
+ assert result.file is None
85
+ assert result.line is None
86
+ assert result.thread_id == "999"
87
+
88
+
89
+ def test_maps_with_empty_body_and_defaults():
90
+ """Should default body to empty string when note.body is empty string."""
91
+ note = GitLabNoteSchema(id=12, body="", author=None)
92
+ discussion = GitLabDiscussionSchema(
93
+ id="100",
94
+ notes=[note],
95
+ position=None,
96
+ )
97
+
98
+ result = get_review_comment_from_gitlab_note(note, discussion)
99
+
100
+ assert isinstance(result, ReviewCommentSchema)
101
+ assert result.body == ""
102
+ assert result.file is None
103
+ assert result.line is None
104
+ assert result.thread_id == "100"
105
+ assert isinstance(result.author, UserSchema)
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
 
3
3
  from ai_review.services.vcs.gitlab.client import GitLabVCSClient
4
- from ai_review.services.vcs.types import ReviewInfoSchema, ReviewCommentSchema
4
+ from ai_review.services.vcs.types import ReviewInfoSchema, ReviewCommentSchema, ReviewThreadSchema, ThreadKind
5
5
  from ai_review.tests.fixtures.clients.gitlab import FakeGitLabMergeRequestsHTTPClient
6
6
 
7
7
 
@@ -52,6 +52,15 @@ async def test_get_general_comments_returns_expected_list(
52
52
  assert "General comment" in bodies
53
53
  assert "Another note" in bodies
54
54
 
55
+ authors = {comment.author.username for comment in comments}
56
+ assert authors == {"charlie", "diana"}
57
+
58
+ for comment in comments:
59
+ assert comment.thread_id == comment.id
60
+ assert comment.author.id is not None
61
+ assert comment.author.name != ""
62
+ assert comment.author.username != ""
63
+
55
64
  called_methods = [name for name, _ in fake_gitlab_merge_requests_http_client.calls]
56
65
  assert called_methods == ["get_notes"]
57
66
 
@@ -121,3 +130,92 @@ async def test_create_inline_comment_posts_comment(
121
130
  assert call_args["body"] == "Looks good!"
122
131
  assert call_args["project_id"] == "project-id"
123
132
  assert call_args["merge_request_id"] == "merge-request-id"
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ @pytest.mark.usefixtures("gitlab_http_client_config")
137
+ async def test_create_inline_reply_posts_comment(
138
+ gitlab_vcs_client: GitLabVCSClient,
139
+ fake_gitlab_merge_requests_http_client: FakeGitLabMergeRequestsHTTPClient,
140
+ ):
141
+ """Should reply to an existing inline discussion."""
142
+ thread_id = "discussion-1"
143
+ message = "I agree with this point."
144
+
145
+ await gitlab_vcs_client.create_inline_reply(thread_id, message)
146
+
147
+ call = next(
148
+ args for name, args in fake_gitlab_merge_requests_http_client.calls
149
+ if name == "create_discussion_reply"
150
+ )
151
+
152
+ assert call["discussion_id"] == thread_id
153
+ assert call["body"] == message
154
+ assert call["project_id"] == "project-id"
155
+ assert call["merge_request_id"] == "merge-request-id"
156
+
157
+
158
+ @pytest.mark.asyncio
159
+ @pytest.mark.usefixtures("gitlab_http_client_config")
160
+ async def test_create_summary_reply_uses_general_comment_method(
161
+ gitlab_vcs_client: GitLabVCSClient,
162
+ fake_gitlab_merge_requests_http_client: FakeGitLabMergeRequestsHTTPClient,
163
+ ):
164
+ """Should reuse create_general_comment when replying to summary thread."""
165
+ thread_id = "summary-1"
166
+ message = "Thanks for clarifying."
167
+
168
+ await gitlab_vcs_client.create_summary_reply(thread_id, message)
169
+
170
+ call = next(
171
+ args for name, args in fake_gitlab_merge_requests_http_client.calls
172
+ if name == "create_note"
173
+ )
174
+
175
+ assert call["body"] == message
176
+ assert call["project_id"] == "project-id"
177
+ assert call["merge_request_id"] == "merge-request-id"
178
+
179
+
180
+ @pytest.mark.asyncio
181
+ @pytest.mark.usefixtures("gitlab_http_client_config")
182
+ async def test_get_inline_threads_returns_valid_schema(
183
+ gitlab_vcs_client: GitLabVCSClient,
184
+ fake_gitlab_merge_requests_http_client: FakeGitLabMergeRequestsHTTPClient,
185
+ ):
186
+ """Should transform GitLab discussions into inline threads with proper fields."""
187
+ threads = await gitlab_vcs_client.get_inline_threads()
188
+
189
+ assert all(isinstance(t, ReviewThreadSchema) for t in threads)
190
+ assert len(threads) == 1
191
+
192
+ thread = threads[0]
193
+ assert thread.id == "discussion-1"
194
+ assert thread.kind == ThreadKind.INLINE
195
+ assert thread.file == "src/app.py"
196
+ assert thread.line == 12
197
+ assert len(thread.comments) == 2
198
+ assert isinstance(thread.comments[0], ReviewCommentSchema)
199
+
200
+ called = [name for name, _ in fake_gitlab_merge_requests_http_client.calls]
201
+ assert "get_discussions" in called
202
+
203
+
204
+ @pytest.mark.asyncio
205
+ @pytest.mark.usefixtures("gitlab_http_client_config")
206
+ async def test_get_general_threads_wraps_comments_in_threads(
207
+ gitlab_vcs_client: GitLabVCSClient,
208
+ fake_gitlab_merge_requests_http_client: FakeGitLabMergeRequestsHTTPClient,
209
+ ):
210
+ """Should wrap each general MR note into its own SUMMARY thread."""
211
+ threads = await gitlab_vcs_client.get_general_threads()
212
+
213
+ assert len(threads) == 2
214
+ for thread in threads:
215
+ assert isinstance(thread, ReviewThreadSchema)
216
+ assert thread.kind == ThreadKind.SUMMARY
217
+ assert len(thread.comments) == 1
218
+ assert isinstance(thread.comments[0], ReviewCommentSchema)
219
+
220
+ called = [name for name, _ in fake_gitlab_merge_requests_http_client.calls]
221
+ assert "get_notes" in called
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xai-review
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: AI-powered code review tool
5
5
  Author-email: Nikita Filonov <nikita.filonov@example.com>
6
6
  Maintainer-email: Nikita Filonov <nikita.filonov@example.com>
@@ -69,12 +69,14 @@ improve code quality, enforce consistency, and speed up the review process.
69
69
  - **Multiple LLM providers** — choose between **OpenAI**, **Claude**, **Gemini**, or **Ollama**, and switch anytime.
70
70
  - **VCS integration** — works out of the box with **GitLab**, **GitHub**, and **Bitbucket**.
71
71
  - **Customizable prompts** — adapt inline, context, and summary reviews to match your team’s coding guidelines.
72
+ - **Reply modes** — AI can now **participate in existing review threads**, adding follow-up replies in both inline and
73
+ summary discussions.
72
74
  - **Flexible configuration** — supports `YAML`, `JSON`, and `ENV`, with seamless overrides in CI/CD pipelines.
73
75
  - **AI Review runs fully client-side** — it never proxies or inspects your requests.
74
76
 
75
- AI Review runs automatically in your CI/CD pipeline and posts both **inline comments** and **summary reviews** right
76
- inside your merge requests. This makes reviews faster, more consistent, and less error-prone — while still leaving the
77
- final decision to human reviewers.
77
+ AI Review runs automatically in your CI/CD pipeline and posts both **inline comments**, **summary reviews**, and now *
78
+ *AI-generated replies** directly inside your merge requests. This makes reviews faster, more conversational, and still
79
+ fully under human control.
78
80
 
79
81
  ---
80
82
 
@@ -209,7 +211,7 @@ jobs:
209
211
  runs-on: ubuntu-latest
210
212
  steps:
211
213
  - uses: actions/checkout@v4
212
- - uses: Nikita-Filonov/ai-review@v0.27.0
214
+ - uses: Nikita-Filonov/ai-review@v0.28.0
213
215
  with:
214
216
  review-command: ${{ inputs.review-command }}
215
217
  env:
@@ -274,6 +276,7 @@ ai-review:
274
276
  See these folders for reference templates and full configuration options:
275
277
 
276
278
  - [./docs/ci](./docs/ci) — CI/CD integration templates (GitHub Actions, GitLab CI)
279
+ - [./docs/cli](./docs/cli) — CLI command reference and usage examples
277
280
  - [./docs/hooks](./docs/hooks) — hook reference and lifecycle events
278
281
  - [./docs/configs](./docs/configs) — full configuration examples (`.yaml`, `.json`, `.env`)
279
282
  - [./docs/prompts](./docs/prompts) — prompt templates for Python/Go (light & strict modes)