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
@@ -1,17 +1,20 @@
1
+ from collections import defaultdict
2
+
1
3
  from ai_review.clients.bitbucket.client import get_bitbucket_http_client
2
4
  from ai_review.clients.bitbucket.pr.schema.comments import (
3
5
  BitbucketCommentInlineSchema,
4
6
  BitbucketCommentContentSchema,
5
- BitbucketCreatePRCommentRequestSchema,
7
+ BitbucketCreatePRCommentRequestSchema, BitbucketParentSchema,
6
8
  )
7
9
  from ai_review.config import settings
8
10
  from ai_review.libs.logger import get_logger
11
+ from ai_review.services.vcs.bitbucket.adapter import get_review_comment_from_bitbucket_pr_comment
9
12
  from ai_review.services.vcs.types import (
10
13
  VCSClientProtocol,
11
14
  UserSchema,
12
15
  BranchRefSchema,
13
16
  ReviewInfoSchema,
14
- ReviewCommentSchema,
17
+ ReviewCommentSchema, ReviewThreadSchema, ThreadKind,
15
18
  )
16
19
 
17
20
  logger = get_logger("BITBUCKET_VCS_CLIENT")
@@ -23,7 +26,9 @@ class BitbucketVCSClient(VCSClientProtocol):
23
26
  self.workspace = settings.vcs.pipeline.workspace
24
27
  self.repo_slug = settings.vcs.pipeline.repo_slug
25
28
  self.pull_request_id = settings.vcs.pipeline.pull_request_id
29
+ self.pull_request_ref = f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}"
26
30
 
31
+ # --- Review info ---
27
32
  async def get_review_info(self) -> ReviewInfoSchema:
28
33
  try:
29
34
  pr = await self.http_client.pr.get_pull_request(
@@ -37,7 +42,7 @@ class BitbucketVCSClient(VCSClientProtocol):
37
42
  pull_request_id=self.pull_request_id,
38
43
  )
39
44
 
40
- logger.info(f"Fetched PR info for {self.workspace}/{self.repo_slug}#{self.pull_request_id}")
45
+ logger.info(f"Fetched PR info for {self.pull_request_ref}")
41
46
 
42
47
  return ReviewInfoSchema(
43
48
  id=pr.id,
@@ -81,11 +86,10 @@ class BitbucketVCSClient(VCSClientProtocol):
81
86
  ],
82
87
  )
83
88
  except Exception as error:
84
- logger.exception(
85
- f"Failed to fetch PR info {self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
86
- )
89
+ logger.exception(f"Failed to fetch PR info {self.pull_request_ref}: {error}")
87
90
  return ReviewInfoSchema()
88
91
 
92
+ # --- Comments ---
89
93
  async def get_general_comments(self) -> list[ReviewCommentSchema]:
90
94
  try:
91
95
  response = await self.http_client.pr.get_comments(
@@ -93,18 +97,15 @@ class BitbucketVCSClient(VCSClientProtocol):
93
97
  repo_slug=self.repo_slug,
94
98
  pull_request_id=self.pull_request_id,
95
99
  )
96
- logger.info(f"Fetched general comments for {self.workspace}/{self.repo_slug}#{self.pull_request_id}")
100
+ logger.info(f"Fetched general comments for {self.pull_request_ref}")
97
101
 
98
102
  return [
99
- ReviewCommentSchema(id=comment.id, body=comment.content.raw)
103
+ get_review_comment_from_bitbucket_pr_comment(comment)
100
104
  for comment in response.values
101
105
  if comment.inline is None
102
106
  ]
103
107
  except Exception as error:
104
- logger.exception(
105
- f"Failed to fetch general comments for "
106
- f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
107
- )
108
+ logger.exception(f"Failed to fetch general comments for {self.pull_request_ref}: {error}")
108
109
  return []
109
110
 
110
111
  async def get_inline_comments(self) -> list[ReviewCommentSchema]:
@@ -114,30 +115,20 @@ class BitbucketVCSClient(VCSClientProtocol):
114
115
  repo_slug=self.repo_slug,
115
116
  pull_request_id=self.pull_request_id,
116
117
  )
117
- logger.info(f"Fetched inline comments for {self.workspace}/{self.repo_slug}#{self.pull_request_id}")
118
+ logger.info(f"Fetched inline comments for {self.pull_request_ref}")
118
119
 
119
120
  return [
120
- ReviewCommentSchema(
121
- id=comment.id,
122
- body=comment.content.raw,
123
- file=comment.inline.path,
124
- line=comment.inline.to_line,
125
- )
121
+ get_review_comment_from_bitbucket_pr_comment(comment)
126
122
  for comment in response.values
127
123
  if comment.inline is not None
128
124
  ]
129
125
  except Exception as error:
130
- logger.exception(
131
- f"Failed to fetch inline comments for "
132
- f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
133
- )
126
+ logger.exception(f"Failed to fetch inline comments for {self.pull_request_ref}: {error}")
134
127
  return []
135
128
 
136
129
  async def create_general_comment(self, message: str) -> None:
137
130
  try:
138
- logger.info(
139
- f"Posting general comment to PR {self.workspace}/{self.repo_slug}#{self.pull_request_id}: {message}"
140
- )
131
+ logger.info(f"Posting general comment to PR {self.pull_request_ref}: {message}")
141
132
  request = BitbucketCreatePRCommentRequestSchema(
142
133
  content=BitbucketCommentContentSchema(raw=message)
143
134
  )
@@ -147,22 +138,14 @@ class BitbucketVCSClient(VCSClientProtocol):
147
138
  pull_request_id=self.pull_request_id,
148
139
  request=request,
149
140
  )
150
- logger.info(
151
- f"Created general comment in PR {self.workspace}/{self.repo_slug}#{self.pull_request_id}"
152
- )
141
+ logger.info(f"Created general comment in PR {self.pull_request_ref}")
153
142
  except Exception as error:
154
- logger.exception(
155
- f"Failed to create general comment in PR "
156
- f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
157
- )
143
+ logger.exception(f"Failed to create general comment in PR {self.pull_request_ref}: {error}")
158
144
  raise
159
145
 
160
146
  async def create_inline_comment(self, file: str, line: int, message: str) -> None:
161
147
  try:
162
- logger.info(
163
- f"Posting inline comment in {self.workspace}/{self.repo_slug}#{self.pull_request_id} "
164
- f"at {file}:{line}: {message}"
165
- )
148
+ logger.info(f"Posting inline comment in {self.pull_request_ref} at {file}:{line}: {message}")
166
149
  request = BitbucketCreatePRCommentRequestSchema(
167
150
  content=BitbucketCommentContentSchema(raw=message),
168
151
  inline=BitbucketCommentInlineSchema(path=file, to_line=line),
@@ -173,13 +156,106 @@ class BitbucketVCSClient(VCSClientProtocol):
173
156
  pull_request_id=self.pull_request_id,
174
157
  request=request,
175
158
  )
176
- logger.info(
177
- f"Created inline comment in {self.workspace}/{self.repo_slug}#{self.pull_request_id} "
178
- f"at {file}:{line}"
159
+ logger.info(f"Created inline comment in {self.pull_request_ref} at {file}:{line}")
160
+ except Exception as error:
161
+ logger.exception(f"Failed to create inline comment in {self.pull_request_ref} at {file}:{line}: {error}")
162
+ raise
163
+
164
+ # --- Replies ---
165
+ async def create_inline_reply(self, thread_id: int | str, message: str) -> None:
166
+ try:
167
+ logger.info(f"Replying to inline thread {thread_id=} in PR {self.pull_request_ref}")
168
+ request = BitbucketCreatePRCommentRequestSchema(
169
+ parent=BitbucketParentSchema(id=int(thread_id)),
170
+ content=BitbucketCommentContentSchema(raw=message),
171
+ )
172
+ await self.http_client.pr.create_comment(
173
+ workspace=self.workspace,
174
+ repo_slug=self.repo_slug,
175
+ pull_request_id=self.pull_request_id,
176
+ request=request,
177
+ )
178
+ logger.info(f"Created inline reply to thread {thread_id=} in PR {self.pull_request_ref}")
179
+ except Exception as error:
180
+ logger.exception(
181
+ f"Failed to create inline reply to thread {thread_id=} in PR {self.pull_request_ref}: {error}"
182
+ )
183
+ raise
184
+
185
+ async def create_summary_reply(self, thread_id: int | str, message: str) -> None:
186
+ try:
187
+ logger.info(f"Replying to summary thread {thread_id=} in PR {self.pull_request_ref}")
188
+ request = BitbucketCreatePRCommentRequestSchema(
189
+ content=BitbucketCommentContentSchema(raw=message),
190
+ parent=BitbucketParentSchema(id=int(thread_id)),
191
+ )
192
+ await self.http_client.pr.create_comment(
193
+ workspace=self.workspace,
194
+ repo_slug=self.repo_slug,
195
+ pull_request_id=self.pull_request_id,
196
+ request=request,
179
197
  )
198
+ logger.info(f"Created summary reply to thread {thread_id=} in PR {self.pull_request_ref}")
180
199
  except Exception as error:
181
200
  logger.exception(
182
- f"Failed to create inline comment in {self.workspace}/{self.repo_slug}#{self.pull_request_id} "
183
- f"at {file}:{line}: {error}"
201
+ f"Failed to create summary reply to thread {thread_id=} in PR {self.pull_request_ref}: {error}"
184
202
  )
185
203
  raise
204
+
205
+ # --- Threads ---
206
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
207
+ try:
208
+ comments = await self.get_inline_comments()
209
+
210
+ threads_by_id: dict[str | int, list[ReviewCommentSchema]] = defaultdict(list)
211
+ for comment in comments:
212
+ if not comment.file:
213
+ continue
214
+
215
+ threads_by_id[comment.thread_id].append(comment)
216
+
217
+ logger.info(f"Built {len(threads_by_id)} inline threads for {self.pull_request_ref}")
218
+
219
+ threads: list[ReviewThreadSchema] = []
220
+ for thread_id, thread in threads_by_id.items():
221
+ file = thread[0].file
222
+ line = thread[0].line
223
+ if not file:
224
+ continue
225
+
226
+ threads.append(
227
+ ReviewThreadSchema(
228
+ id=thread_id,
229
+ kind=ThreadKind.INLINE,
230
+ file=file,
231
+ line=line,
232
+ comments=sorted(thread, key=lambda t: int(t.id)),
233
+ )
234
+ )
235
+
236
+ return threads
237
+ except Exception as error:
238
+ logger.exception(f"Failed to fetch inline threads for {self.pull_request_ref}: {error}")
239
+ return []
240
+
241
+ async def get_general_threads(self) -> list[ReviewThreadSchema]:
242
+ try:
243
+ comments = await self.get_general_comments()
244
+
245
+ threads: dict[str | int, list[ReviewCommentSchema]] = defaultdict(list)
246
+ for comment in comments:
247
+ threads[comment.thread_id].append(comment)
248
+
249
+ logger.info(f"Built {len(threads)} general threads for {self.pull_request_ref}")
250
+
251
+ return [
252
+ ReviewThreadSchema(
253
+ id=thread_id,
254
+ kind=ThreadKind.SUMMARY,
255
+ comments=sorted(thread, key=lambda c: int(c.id)),
256
+ )
257
+ for thread_id, thread in threads.items()
258
+ ]
259
+ except Exception as error:
260
+ logger.exception(f"Failed to fetch general threads for {self.pull_request_ref}: {error}")
261
+ return []
@@ -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,28 @@
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
+ position = note.position or discussion.position
20
+
21
+ return ReviewCommentSchema(
22
+ id=note.id,
23
+ body=note.body or "",
24
+ file=position.new_path if position else None,
25
+ line=position.new_line if position else None,
26
+ author=get_user_from_gitlab_user(note.author),
27
+ thread_id=discussion.id,
28
+ )