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
@@ -0,0 +1,35 @@
1
+ from ai_review.clients.github.pr.schema.comments import GitHubPRCommentSchema, GitHubIssueCommentSchema
2
+ from ai_review.clients.github.pr.schema.user import GitHubUserSchema
3
+ from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
4
+
5
+
6
+ def get_user_from_github_user(user: GitHubUserSchema | None) -> UserSchema:
7
+ return UserSchema(
8
+ id=user.id if user else None,
9
+ name=user.login if user else "",
10
+ username=user.login if user else "",
11
+ )
12
+
13
+
14
+ def get_review_comment_from_github_pr_comment(comment: GitHubPRCommentSchema) -> ReviewCommentSchema:
15
+ parent_id = comment.in_reply_to_id
16
+ thread_id = parent_id or comment.id
17
+
18
+ return ReviewCommentSchema(
19
+ id=comment.id,
20
+ body=comment.body or "",
21
+ file=comment.path,
22
+ line=comment.line,
23
+ author=get_user_from_github_user(comment.user),
24
+ parent_id=parent_id,
25
+ thread_id=thread_id,
26
+ )
27
+
28
+
29
+ def get_review_comment_from_github_issue_comment(comment: GitHubIssueCommentSchema) -> ReviewCommentSchema:
30
+ return ReviewCommentSchema(
31
+ id=comment.id,
32
+ body=comment.body or "",
33
+ author=get_user_from_github_user(comment.user),
34
+ thread_id=comment.id
35
+ )
@@ -1,12 +1,23 @@
1
+ from collections import defaultdict
2
+
1
3
  from ai_review.clients.github.client import get_github_http_client
2
- from ai_review.clients.github.pr.schema.comments import GitHubCreateReviewCommentRequestSchema
4
+ from ai_review.clients.github.pr.schema.comments import (
5
+ GitHubCreateReviewReplyRequestSchema,
6
+ GitHubCreateReviewCommentRequestSchema
7
+ )
3
8
  from ai_review.config import settings
4
9
  from ai_review.libs.logger import get_logger
10
+ from ai_review.services.vcs.github.adapter import (
11
+ get_review_comment_from_github_pr_comment,
12
+ get_review_comment_from_github_issue_comment
13
+ )
5
14
  from ai_review.services.vcs.types import (
6
15
  VCSClientProtocol,
16
+ ThreadKind,
7
17
  UserSchema,
8
18
  BranchRefSchema,
9
19
  ReviewInfoSchema,
20
+ ReviewThreadSchema,
10
21
  ReviewCommentSchema,
11
22
  )
12
23
 
@@ -19,7 +30,9 @@ class GitHubVCSClient(VCSClientProtocol):
19
30
  self.owner = settings.vcs.pipeline.owner
20
31
  self.repo = settings.vcs.pipeline.repo
21
32
  self.pull_number = settings.vcs.pipeline.pull_number
33
+ self.pull_request_ref = f"{self.owner}/{self.repo}#{self.pull_number}"
22
34
 
35
+ # --- Review info ---
23
36
  async def get_review_info(self) -> ReviewInfoSchema:
24
37
  try:
25
38
  pr = await self.http_client.pr.get_pull_request(
@@ -69,7 +82,7 @@ class GitHubVCSClient(VCSClientProtocol):
69
82
  )
70
83
  return ReviewInfoSchema()
71
84
 
72
- # === GENERAL COMMENTS ===
85
+ # --- Comments ---
73
86
  async def get_general_comments(self) -> list[ReviewCommentSchema]:
74
87
  try:
75
88
  response = await self.http_client.pr.get_issue_comments(
@@ -77,18 +90,11 @@ class GitHubVCSClient(VCSClientProtocol):
77
90
  repo=self.repo,
78
91
  issue_number=self.pull_number,
79
92
  )
80
- logger.info(
81
- f"Fetched general comments for {self.owner}/{self.repo}#{self.pull_number}"
82
- )
93
+ logger.info(f"Fetched general comments for {self.pull_request_ref}")
83
94
 
84
- return [
85
- ReviewCommentSchema(id=comment.id, body=comment.body or "")
86
- for comment in response.root
87
- ]
95
+ return [get_review_comment_from_github_issue_comment(comment) for comment in response.root]
88
96
  except Exception as error:
89
- logger.exception(
90
- f"Failed to fetch general comments for {self.owner}/{self.repo}#{self.pull_number}: {error}"
91
- )
97
+ logger.exception(f"Failed to fetch general comments for {self.pull_request_ref}: {error}")
92
98
  return []
93
99
 
94
100
  async def get_inline_comments(self) -> list[ReviewCommentSchema]:
@@ -98,51 +104,30 @@ class GitHubVCSClient(VCSClientProtocol):
98
104
  repo=self.repo,
99
105
  pull_number=self.pull_number,
100
106
  )
101
- logger.info(
102
- f"Fetched inline comments for {self.owner}/{self.repo}#{self.pull_number}"
103
- )
107
+ logger.info(f"Fetched inline comments for {self.pull_request_ref}")
104
108
 
105
- return [
106
- ReviewCommentSchema(
107
- id=comment.id,
108
- body=comment.body or "",
109
- file=comment.path,
110
- line=comment.line,
111
- )
112
- for comment in response.root
113
- ]
109
+ return [get_review_comment_from_github_pr_comment(comment) for comment in response.root]
114
110
  except Exception as error:
115
- logger.exception(
116
- f"Failed to fetch inline comments for {self.owner}/{self.repo}#{self.pull_number}: {error}"
117
- )
111
+ logger.exception(f"Failed to fetch inline comments for {self.pull_request_ref}: {error}")
118
112
  return []
119
113
 
120
114
  async def create_general_comment(self, message: str) -> None:
121
115
  try:
122
- logger.info(
123
- f"Posting general comment to PR {self.owner}/{self.repo}#{self.pull_number}: {message}"
124
- )
116
+ logger.info(f"Posting general comment to PR {self.pull_request_ref}: {message}")
125
117
  await self.http_client.pr.create_issue_comment(
126
118
  owner=self.owner,
127
119
  repo=self.repo,
128
120
  issue_number=self.pull_number,
129
121
  body=message,
130
122
  )
131
- logger.info(
132
- f"Created general comment in PR {self.owner}/{self.repo}#{self.pull_number}"
133
- )
123
+ logger.info(f"Created general comment in PR {self.pull_request_ref}")
134
124
  except Exception as error:
135
- logger.exception(
136
- f"Failed to create general comment in PR {self.owner}/{self.repo}#{self.pull_number}: {error}"
137
- )
125
+ logger.exception(f"Failed to create general comment in PR {self.pull_request_ref}: {error}")
138
126
  raise
139
127
 
140
128
  async def create_inline_comment(self, file: str, line: int, message: str) -> None:
141
129
  try:
142
- logger.info(
143
- f"Posting inline comment in {self.owner}/{self.repo}#{self.pull_number} "
144
- f"at {file}:{line}: {message}"
145
- )
130
+ logger.info(f"Posting inline comment in {self.pull_request_ref} at {file}:{line}: {message}")
146
131
 
147
132
  pr = await self.http_client.pr.get_pull_request(
148
133
  owner=self.owner, repo=self.repo, pull_number=self.pull_number
@@ -160,12 +145,88 @@ class GitHubVCSClient(VCSClientProtocol):
160
145
  pull_number=self.pull_number,
161
146
  request=request,
162
147
  )
163
- logger.info(
164
- f"Created inline comment in {self.owner}/{self.repo}#{self.pull_number} at {file}:{line}"
148
+ logger.info(f"Created inline comment in {self.pull_request_ref} at {file}:{line}")
149
+ except Exception as error:
150
+ logger.exception(f"Failed to create inline comment in {self.pull_request_ref} at {file}:{line}: {error}")
151
+ raise
152
+
153
+ # --- Replies ---
154
+ async def create_inline_reply(self, thread_id: int | str, message: str) -> None:
155
+ try:
156
+ logger.info(f"Replying to inline comment {thread_id=} in PR {self.pull_request_ref}")
157
+ request = GitHubCreateReviewReplyRequestSchema(
158
+ body=message,
159
+ in_reply_to=thread_id
160
+ )
161
+ await self.http_client.pr.create_review_reply(
162
+ owner=self.owner,
163
+ repo=self.repo,
164
+ pull_number=self.pull_number,
165
+ request=request,
165
166
  )
167
+ logger.info(f"Created inline reply to comment {thread_id=} in PR {self.pull_request_ref}")
168
+ except Exception as error:
169
+ logger.exception(
170
+ f"Failed to create inline reply to comment {thread_id=} in {self.pull_request_ref}: {error}"
171
+ )
172
+ raise
173
+
174
+ async def create_summary_reply(self, thread_id: int | str, message: str) -> None:
175
+ """
176
+ GitHub does not support threaded replies for issue-level comments.
177
+ We post a new top-level comment instead.
178
+ """
179
+ try:
180
+ logger.info(f"Replying to general comment {thread_id=} in PR {self.pull_request_ref}")
181
+ await self.create_general_comment(message)
166
182
  except Exception as error:
167
183
  logger.exception(
168
- f"Failed to create inline comment in {self.owner}/{self.repo}#{self.pull_number} "
169
- f"at {file}:{line}: {error}"
184
+ f"Failed to create summary reply to comment {thread_id=} in {self.pull_request_ref}: {error}"
170
185
  )
171
186
  raise
187
+
188
+ # --- Threads ---
189
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
190
+ try:
191
+ response = await self.http_client.pr.get_review_comments(
192
+ owner=self.owner,
193
+ repo=self.repo,
194
+ pull_number=self.pull_number,
195
+ )
196
+ comments = response.root
197
+ logger.info(f"Fetched inline comment threads for {self.pull_request_ref}")
198
+
199
+ threads: dict[str | int, list[ReviewCommentSchema]] = defaultdict(list)
200
+ for comment in comments:
201
+ review_comment = get_review_comment_from_github_pr_comment(comment)
202
+ threads[review_comment.thread_id].append(review_comment)
203
+
204
+ logger.info(f"Built {len(threads)} inline threads for {self.pull_request_ref}")
205
+
206
+ return [
207
+ ReviewThreadSchema(
208
+ id=thread_id,
209
+ kind=ThreadKind.INLINE,
210
+ file=thread[0].file,
211
+ line=thread[0].line,
212
+ comments=sorted(thread, key=lambda t: int(t.id)),
213
+ )
214
+ for thread_id, thread in threads.items()
215
+ ]
216
+ except Exception as error:
217
+ logger.exception(f"Failed to fetch inline threads for {self.pull_request_ref}: {error}")
218
+ return []
219
+
220
+ async def get_general_threads(self) -> list[ReviewThreadSchema]:
221
+ try:
222
+ comments = await self.get_general_comments()
223
+
224
+ threads = [
225
+ ReviewThreadSchema(id=comment.thread_id, kind=ThreadKind.SUMMARY, comments=[comment])
226
+ for comment in comments
227
+ ]
228
+ logger.info(f"Built {len(threads)} general threads for {self.pull_request_ref}")
229
+ return threads
230
+ except Exception as error:
231
+ logger.exception(f"Failed to build general threads for {self.pull_request_ref}: {error}")
232
+ return []
@@ -0,0 +1,26 @@
1
+ from ai_review.clients.gitlab.mr.schema.discussions import GitLabDiscussionSchema
2
+ from ai_review.clients.gitlab.mr.schema.notes import GitLabNoteSchema
3
+ from ai_review.clients.gitlab.mr.schema.user import GitLabUserSchema
4
+ from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
5
+
6
+
7
+ def get_user_from_gitlab_user(user: GitLabUserSchema | None) -> UserSchema:
8
+ return UserSchema(
9
+ id=user.id if user else None,
10
+ name=user.name if user else "",
11
+ username=user.username if user else "",
12
+ )
13
+
14
+
15
+ def get_review_comment_from_gitlab_note(
16
+ note: GitLabNoteSchema,
17
+ discussion: GitLabDiscussionSchema
18
+ ) -> ReviewCommentSchema:
19
+ return ReviewCommentSchema(
20
+ id=note.id,
21
+ body=note.body or "",
22
+ file=discussion.position.new_path if discussion.position else None,
23
+ line=discussion.position.new_line if discussion.position else None,
24
+ author=get_user_from_gitlab_user(note.author),
25
+ thread_id=discussion.id,
26
+ )
@@ -5,11 +5,14 @@ from ai_review.clients.gitlab.mr.schema.discussions import (
5
5
  )
6
6
  from ai_review.config import settings
7
7
  from ai_review.libs.logger import get_logger
8
+ from ai_review.services.vcs.gitlab.adapter import get_user_from_gitlab_user, get_review_comment_from_gitlab_note
8
9
  from ai_review.services.vcs.types import (
9
10
  VCSClientProtocol,
10
11
  UserSchema,
12
+ ThreadKind,
11
13
  BranchRefSchema,
12
14
  ReviewInfoSchema,
15
+ ReviewThreadSchema,
13
16
  ReviewCommentSchema,
14
17
  )
15
18
 
@@ -21,16 +24,16 @@ class GitLabVCSClient(VCSClientProtocol):
21
24
  self.http_client = get_gitlab_http_client()
22
25
  self.project_id = settings.vcs.pipeline.project_id
23
26
  self.merge_request_id = settings.vcs.pipeline.merge_request_id
27
+ self.merge_request_ref = f"project_id={self.project_id} merge_request_id={self.merge_request_id}"
24
28
 
29
+ # --- Review info ---
25
30
  async def get_review_info(self) -> ReviewInfoSchema:
26
31
  try:
27
32
  response = await self.http_client.mr.get_changes(
28
33
  project_id=self.project_id,
29
34
  merge_request_id=self.merge_request_id,
30
35
  )
31
- logger.info(
32
- f"Fetched MR info for project_id={self.project_id} merge_request_id={self.merge_request_id}"
33
- )
36
+ logger.info(f"Fetched MR info for {self.merge_request_ref}")
34
37
 
35
38
  return ReviewInfoSchema(
36
39
  id=response.iid,
@@ -66,32 +69,29 @@ class GitLabVCSClient(VCSClientProtocol):
66
69
  ],
67
70
  )
68
71
  except Exception as error:
69
- logger.exception(
70
- f"Failed to fetch MR info for project_id={self.project_id} "
71
- f"merge_request_id={self.merge_request_id}: {error}"
72
- )
72
+ logger.exception(f"Failed to fetch MR info for {self.merge_request_ref}: {error}")
73
73
  return ReviewInfoSchema()
74
74
 
75
+ # --- Comments ---
75
76
  async def get_general_comments(self) -> list[ReviewCommentSchema]:
76
77
  try:
77
78
  response = await self.http_client.mr.get_notes(
78
79
  project_id=self.project_id,
79
80
  merge_request_id=self.merge_request_id,
80
81
  )
81
- logger.info(
82
- f"Fetched general comments for project_id={self.project_id} "
83
- f"merge_request_id={self.merge_request_id}"
84
- )
82
+ logger.info(f"Fetched general comments for {self.merge_request_ref}")
85
83
 
86
84
  return [
87
- ReviewCommentSchema(id=note.id, body=note.body or "")
85
+ ReviewCommentSchema(
86
+ id=note.id,
87
+ body=note.body or "",
88
+ author=get_user_from_gitlab_user(note.author),
89
+ thread_id=note.id
90
+ )
88
91
  for note in response.root
89
92
  ]
90
93
  except Exception as error:
91
- logger.exception(
92
- f"Failed to fetch general comments project_id={self.project_id} "
93
- f"merge_request_id={self.merge_request_id}: {error}"
94
- )
94
+ logger.exception(f"Failed to fetch general comments {self.merge_request_ref}: {error}")
95
95
  return []
96
96
 
97
97
  async def get_inline_comments(self) -> list[ReviewCommentSchema]:
@@ -100,45 +100,33 @@ class GitLabVCSClient(VCSClientProtocol):
100
100
  project_id=self.project_id,
101
101
  merge_request_id=self.merge_request_id,
102
102
  )
103
- logger.info(
104
- f"Fetched inline discussions for project_id={self.project_id} "
105
- f"merge_request_id={self.merge_request_id}"
106
- )
103
+ logger.info(f"Fetched inline discussions for {self.merge_request_ref}")
107
104
 
108
105
  return [
109
- ReviewCommentSchema(id=note.id, body=note.body or "")
106
+ get_review_comment_from_gitlab_note(note, discussion)
110
107
  for discussion in response.root
111
108
  for note in discussion.notes
112
109
  ]
113
110
  except Exception as error:
114
- logger.exception(
115
- f"Failed to fetch inline discussions project_id={self.project_id} "
116
- f"merge_request_id={self.merge_request_id}: {error}"
117
- )
111
+ logger.exception(f"Failed to fetch inline discussions {self.merge_request_ref}: {error}")
118
112
  return []
119
113
 
120
114
  async def create_general_comment(self, message: str) -> None:
121
115
  try:
122
- logger.info(
123
- f"Posting general comment to merge_request_id={self.merge_request_id}: {message}"
124
- )
116
+ logger.info(f"Posting general comment to {self.merge_request_ref}: {message}")
125
117
  await self.http_client.mr.create_note(
126
118
  body=message,
127
119
  project_id=self.project_id,
128
120
  merge_request_id=self.merge_request_id,
129
121
  )
130
- logger.info(f"Created general comment in merge_request_id={self.merge_request_id}")
122
+ logger.info(f"Created general comment in {self.merge_request_ref}")
131
123
  except Exception as error:
132
- logger.exception(
133
- f"Failed to create general comment in merge_request_id={self.merge_request_id}: {error}"
134
- )
124
+ logger.exception(f"Failed to create general comment in {self.merge_request_ref}: {error}")
135
125
  raise
136
126
 
137
127
  async def create_inline_comment(self, file: str, line: int, message: str) -> None:
138
128
  try:
139
- logger.info(
140
- f"Posting inline comment in merge_request_id={self.merge_request_id} at {file}:{line}: {message}"
141
- )
129
+ logger.info(f"Posting inline comment in {self.merge_request_ref} at {file}:{line}: {message}")
142
130
 
143
131
  response = await self.http_client.mr.get_changes(
144
132
  project_id=self.project_id,
@@ -161,12 +149,77 @@ class GitLabVCSClient(VCSClientProtocol):
161
149
  project_id=self.project_id,
162
150
  merge_request_id=self.merge_request_id,
163
151
  )
152
+ logger.info(f"Created inline comment in {self.merge_request_ref} at {file}:{line}")
153
+ except Exception as error:
154
+ logger.exception(f"Failed to create inline comment in {self.merge_request_ref} at {file}:{line}: {error}")
155
+ raise
156
+
157
+ # --- Replies ---
158
+ async def create_inline_reply(self, thread_id: str | int, message: str) -> None:
159
+ try:
160
+ logger.info(f"Replying to discussion {thread_id=} in MR {self.merge_request_ref}")
161
+ await self.http_client.mr.create_discussion_reply(
162
+ project_id=self.project_id,
163
+ merge_request_id=self.merge_request_id,
164
+ discussion_id=str(thread_id),
165
+ body=message,
166
+ )
164
167
  logger.info(
165
- f"Created inline comment in merge_request_id={self.merge_request_id} at {file}:{line}"
168
+ f"Created inline reply to discussion {thread_id=} in MR {self.merge_request_ref}"
169
+ )
170
+ except Exception as error:
171
+ logger.exception(
172
+ f"Failed to create inline reply to discussion {thread_id=} in MR {self.merge_request_ref}: {error}"
166
173
  )
174
+ raise
175
+
176
+ async def create_summary_reply(self, thread_id: int | str, message: str) -> None:
177
+ try:
178
+ logger.info(f"Replying to general comment {thread_id=} in MR {self.merge_request_ref}")
179
+ await self.create_general_comment(message)
167
180
  except Exception as error:
168
181
  logger.exception(
169
- f"Failed to create inline comment in merge_request_id={self.merge_request_id} "
170
- f"at {file}:{line}: {error}"
182
+ f"Failed to reply to general comment {thread_id=} in MR {self.merge_request_ref}: {error}"
171
183
  )
172
184
  raise
185
+
186
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
187
+ try:
188
+ response = await self.http_client.mr.get_discussions(
189
+ project_id=self.project_id,
190
+ merge_request_id=self.merge_request_id,
191
+ )
192
+ logger.info(f"Fetched inline threads for MR {self.merge_request_ref}")
193
+
194
+ threads = [
195
+ ReviewThreadSchema(
196
+ id=discussion.id,
197
+ kind=ThreadKind.INLINE,
198
+ file=discussion.position.new_path if discussion.position else None,
199
+ line=discussion.position.new_line if discussion.position else None,
200
+ comments=[
201
+ get_review_comment_from_gitlab_note(note, discussion)
202
+ for note in discussion.notes
203
+ ]
204
+ )
205
+ for discussion in response.root if discussion.notes
206
+ ]
207
+ logger.info(f"Built {len(threads)} inline threads for MR {self.merge_request_ref}")
208
+ return threads
209
+ except Exception as error:
210
+ logger.exception(f"Failed to fetch inline threads for MR {self.merge_request_ref}: {error}")
211
+ return []
212
+
213
+ async def get_general_threads(self) -> list[ReviewThreadSchema]:
214
+ try:
215
+ comments = await self.get_general_comments()
216
+
217
+ threads = [
218
+ ReviewThreadSchema(id=comment.id, kind=ThreadKind.SUMMARY, comments=[comment])
219
+ for comment in comments
220
+ ]
221
+ logger.info(f"Built {len(threads)} general threads for MR {self.merge_request_ref}")
222
+ return threads
223
+ except Exception as error:
224
+ logger.exception(f"Failed to build general threads for MR {self.merge_request_ref}: {error}")
225
+ return []
@@ -1,8 +1,14 @@
1
+ from enum import StrEnum
1
2
  from typing import Protocol
2
3
 
3
4
  from pydantic import BaseModel, Field
4
5
 
5
6
 
7
+ class ThreadKind(StrEnum):
8
+ INLINE = "INLINE"
9
+ SUMMARY = "SUMMARY"
10
+
11
+
6
12
  class UserSchema(BaseModel):
7
13
  id: str | int | None = None
8
14
  name: str = ""
@@ -35,10 +41,16 @@ class ReviewCommentSchema(BaseModel):
35
41
  body: str
36
42
  file: str | None = None
37
43
  line: int | None = None
44
+ author: UserSchema = Field(default_factory=UserSchema)
45
+ parent_id: str | int | None = None
46
+ thread_id: str | int | None = None
38
47
 
39
48
 
40
49
  class ReviewThreadSchema(BaseModel):
41
50
  id: str | int
51
+ kind: ThreadKind
52
+ file: str | None = None
53
+ line: int | None = None
42
54
  comments: list[ReviewCommentSchema]
43
55
 
44
56
 
@@ -48,9 +60,11 @@ class VCSClientProtocol(Protocol):
48
60
  Designed for code review automation: fetching review info, comments, and posting feedback.
49
61
  """
50
62
 
63
+ # --- Review info ---
51
64
  async def get_review_info(self) -> ReviewInfoSchema:
52
65
  """Fetch general information about the current review (PR/MR)."""
53
66
 
67
+ # --- Comments ---
54
68
  async def get_general_comments(self) -> list[ReviewCommentSchema]:
55
69
  """Fetch all top-level (non-inline) comments."""
56
70
 
@@ -62,3 +76,23 @@ class VCSClientProtocol(Protocol):
62
76
 
63
77
  async def create_inline_comment(self, file: str, line: int, message: str) -> None:
64
78
  """Post a comment attached to a specific line in file."""
79
+
80
+ # --- Replies ---
81
+ async def create_inline_reply(self, thread_id: int | str, message: str) -> None:
82
+ """Reply to an existing inline comment thread."""
83
+
84
+ async def create_summary_reply(self, thread_id: int | str, message: str) -> None:
85
+ """Reply to a summary/general comment (flat if VCS doesn't support threads)."""
86
+
87
+ # --- Threads ---
88
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
89
+ """
90
+ Fetch grouped inline comment threads.
91
+ If VCS doesn't support threads natively, group by file+line.
92
+ """
93
+
94
+ async def get_general_threads(self) -> list[ReviewThreadSchema]:
95
+ """
96
+ Fetch grouped general (summary-level) comment threads.
97
+ If VCS is flat (e.g. GitHub issues), each comment is a separate thread.
98
+ """
@@ -81,7 +81,7 @@ class FakeBitbucketPullRequestsHTTPClient(BitbucketPullRequestsHTTPClientProtoco
81
81
  return BitbucketGetPRFilesResponseSchema(
82
82
  size=2,
83
83
  page=1,
84
- pagelen=100,
84
+ page_len=100,
85
85
  next=None,
86
86
  values=[
87
87
  BitbucketPRFileSchema(
@@ -129,7 +129,7 @@ class FakeBitbucketPullRequestsHTTPClient(BitbucketPullRequestsHTTPClientProtoco
129
129
  content=BitbucketCommentContentSchema(raw="Inline comment"),
130
130
  ),
131
131
  ],
132
- pagelen=100,
132
+ page_len=100,
133
133
  )
134
134
 
135
135
  async def create_comment(
@@ -3,8 +3,11 @@ from pydantic import HttpUrl, SecretStr
3
3
 
4
4
  from ai_review.clients.github.pr.schema.comments import (
5
5
  GitHubPRCommentSchema,
6
+ GitHubIssueCommentSchema,
6
7
  GitHubGetPRCommentsResponseSchema,
8
+ GitHubGetIssueCommentsResponseSchema,
7
9
  GitHubCreateIssueCommentResponseSchema,
10
+ GitHubCreateReviewReplyRequestSchema,
8
11
  GitHubCreateReviewCommentRequestSchema,
9
12
  GitHubCreateReviewCommentResponseSchema,
10
13
  )
@@ -30,7 +33,6 @@ class FakeGitHubPullRequestsHTTPClient(GitHubPullRequestsHTTPClientProtocol):
30
33
 
31
34
  async def get_pull_request(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRResponseSchema:
32
35
  self.calls.append(("get_pull_request", {"owner": owner, "repo": repo, "pull_number": pull_number}))
33
-
34
36
  return GitHubGetPRResponseSchema(
35
37
  id=1,
36
38
  number=1,
@@ -72,19 +74,31 @@ class FakeGitHubPullRequestsHTTPClient(GitHubPullRequestsHTTPClientProtocol):
72
74
  ]
73
75
  )
74
76
 
75
- async def get_issue_comments(self, owner: str, repo: str, issue_number: str) -> GitHubGetPRCommentsResponseSchema:
77
+ async def get_issue_comments(
78
+ self,
79
+ owner: str,
80
+ repo: str,
81
+ issue_number: str
82
+ ) -> GitHubGetIssueCommentsResponseSchema:
76
83
  self.calls.append(("get_issue_comments", {"owner": owner, "repo": repo, "issue_number": issue_number}))
77
84
 
78
- return GitHubGetPRCommentsResponseSchema(
85
+ return GitHubGetIssueCommentsResponseSchema(
79
86
  root=[
80
- GitHubPRCommentSchema(id=1, body="General comment"),
81
- GitHubPRCommentSchema(id=2, body="Another general comment"),
87
+ GitHubIssueCommentSchema(
88
+ id=1,
89
+ body="General comment",
90
+ user=GitHubUserSchema(id=201, login="alice")
91
+ ),
92
+ GitHubIssueCommentSchema(
93
+ id=2,
94
+ body="Another general comment",
95
+ user=GitHubUserSchema(id=202, login="bob"),
96
+ ),
82
97
  ]
83
98
  )
84
99
 
85
100
  async def get_review_comments(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRCommentsResponseSchema:
86
101
  self.calls.append(("get_review_comments", {"owner": owner, "repo": repo, "pull_number": pull_number}))
87
-
88
102
  return GitHubGetPRCommentsResponseSchema(
89
103
  root=[
90
104
  GitHubPRCommentSchema(id=3, body="Inline comment", path="file.py", line=5),
@@ -102,6 +116,21 @@ class FakeGitHubPullRequestsHTTPClient(GitHubPullRequestsHTTPClientProtocol):
102
116
  ]
103
117
  )
104
118
 
119
+ async def create_review_reply(
120
+ self,
121
+ owner: str,
122
+ repo: str,
123
+ pull_number: str,
124
+ request: GitHubCreateReviewReplyRequestSchema,
125
+ ) -> GitHubCreateReviewCommentResponseSchema:
126
+ self.calls.append(
127
+ (
128
+ "create_review_reply",
129
+ {"owner": owner, "repo": repo, "pull_number": pull_number, **request.model_dump()}
130
+ )
131
+ )
132
+ return GitHubCreateReviewCommentResponseSchema(id=12, body=request.body)
133
+
105
134
  async def create_review_comment(
106
135
  self,
107
136
  owner: str,