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,15 +1,16 @@
1
1
  from ai_review.clients.gitlab.client import get_gitlab_http_client
2
- from ai_review.clients.gitlab.mr.schema.discussions import (
3
- GitLabDiscussionPositionSchema,
4
- GitLabCreateMRDiscussionRequestSchema,
5
- )
2
+ from ai_review.clients.gitlab.mr.schema.discussions import GitLabCreateMRDiscussionRequestSchema
3
+ from ai_review.clients.gitlab.mr.schema.position import GitLabPositionSchema
6
4
  from ai_review.config import settings
7
5
  from ai_review.libs.logger import get_logger
6
+ from ai_review.services.vcs.gitlab.adapter import get_user_from_gitlab_user, get_review_comment_from_gitlab_note
8
7
  from ai_review.services.vcs.types import (
9
8
  VCSClientProtocol,
10
9
  UserSchema,
10
+ ThreadKind,
11
11
  BranchRefSchema,
12
12
  ReviewInfoSchema,
13
+ ReviewThreadSchema,
13
14
  ReviewCommentSchema,
14
15
  )
15
16
 
@@ -21,16 +22,16 @@ class GitLabVCSClient(VCSClientProtocol):
21
22
  self.http_client = get_gitlab_http_client()
22
23
  self.project_id = settings.vcs.pipeline.project_id
23
24
  self.merge_request_id = settings.vcs.pipeline.merge_request_id
25
+ self.merge_request_ref = f"project_id={self.project_id} merge_request_id={self.merge_request_id}"
24
26
 
27
+ # --- Review info ---
25
28
  async def get_review_info(self) -> ReviewInfoSchema:
26
29
  try:
27
30
  response = await self.http_client.mr.get_changes(
28
31
  project_id=self.project_id,
29
32
  merge_request_id=self.merge_request_id,
30
33
  )
31
- logger.info(
32
- f"Fetched MR info for project_id={self.project_id} merge_request_id={self.merge_request_id}"
33
- )
34
+ logger.info(f"Fetched MR info for {self.merge_request_ref}")
34
35
 
35
36
  return ReviewInfoSchema(
36
37
  id=response.iid,
@@ -66,32 +67,29 @@ class GitLabVCSClient(VCSClientProtocol):
66
67
  ],
67
68
  )
68
69
  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
- )
70
+ logger.exception(f"Failed to fetch MR info for {self.merge_request_ref}: {error}")
73
71
  return ReviewInfoSchema()
74
72
 
73
+ # --- Comments ---
75
74
  async def get_general_comments(self) -> list[ReviewCommentSchema]:
76
75
  try:
77
76
  response = await self.http_client.mr.get_notes(
78
77
  project_id=self.project_id,
79
78
  merge_request_id=self.merge_request_id,
80
79
  )
81
- logger.info(
82
- f"Fetched general comments for project_id={self.project_id} "
83
- f"merge_request_id={self.merge_request_id}"
84
- )
80
+ logger.info(f"Fetched general comments for {self.merge_request_ref}")
85
81
 
86
82
  return [
87
- ReviewCommentSchema(id=note.id, body=note.body or "")
83
+ ReviewCommentSchema(
84
+ id=note.id,
85
+ body=note.body or "",
86
+ author=get_user_from_gitlab_user(note.author),
87
+ thread_id=note.id
88
+ )
88
89
  for note in response.root
89
90
  ]
90
91
  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
- )
92
+ logger.exception(f"Failed to fetch general comments {self.merge_request_ref}: {error}")
95
93
  return []
96
94
 
97
95
  async def get_inline_comments(self) -> list[ReviewCommentSchema]:
@@ -100,45 +98,33 @@ class GitLabVCSClient(VCSClientProtocol):
100
98
  project_id=self.project_id,
101
99
  merge_request_id=self.merge_request_id,
102
100
  )
103
- logger.info(
104
- f"Fetched inline discussions for project_id={self.project_id} "
105
- f"merge_request_id={self.merge_request_id}"
106
- )
101
+ logger.info(f"Fetched inline discussions for {self.merge_request_ref}")
107
102
 
108
103
  return [
109
- ReviewCommentSchema(id=note.id, body=note.body or "")
104
+ get_review_comment_from_gitlab_note(note, discussion)
110
105
  for discussion in response.root
111
106
  for note in discussion.notes
112
107
  ]
113
108
  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
- )
109
+ logger.exception(f"Failed to fetch inline discussions {self.merge_request_ref}: {error}")
118
110
  return []
119
111
 
120
112
  async def create_general_comment(self, message: str) -> None:
121
113
  try:
122
- logger.info(
123
- f"Posting general comment to merge_request_id={self.merge_request_id}: {message}"
124
- )
114
+ logger.info(f"Posting general comment to {self.merge_request_ref}: {message}")
125
115
  await self.http_client.mr.create_note(
126
116
  body=message,
127
117
  project_id=self.project_id,
128
118
  merge_request_id=self.merge_request_id,
129
119
  )
130
- logger.info(f"Created general comment in merge_request_id={self.merge_request_id}")
120
+ logger.info(f"Created general comment in {self.merge_request_ref}")
131
121
  except Exception as error:
132
- logger.exception(
133
- f"Failed to create general comment in merge_request_id={self.merge_request_id}: {error}"
134
- )
122
+ logger.exception(f"Failed to create general comment in {self.merge_request_ref}: {error}")
135
123
  raise
136
124
 
137
125
  async def create_inline_comment(self, file: str, line: int, message: str) -> None:
138
126
  try:
139
- logger.info(
140
- f"Posting inline comment in merge_request_id={self.merge_request_id} at {file}:{line}: {message}"
141
- )
127
+ logger.info(f"Posting inline comment in {self.merge_request_ref} at {file}:{line}: {message}")
142
128
 
143
129
  response = await self.http_client.mr.get_changes(
144
130
  project_id=self.project_id,
@@ -147,7 +133,7 @@ class GitLabVCSClient(VCSClientProtocol):
147
133
 
148
134
  request = GitLabCreateMRDiscussionRequestSchema(
149
135
  body=message,
150
- position=GitLabDiscussionPositionSchema(
136
+ position=GitLabPositionSchema(
151
137
  position_type="text",
152
138
  base_sha=response.diff_refs.base_sha,
153
139
  head_sha=response.diff_refs.head_sha,
@@ -161,12 +147,86 @@ class GitLabVCSClient(VCSClientProtocol):
161
147
  project_id=self.project_id,
162
148
  merge_request_id=self.merge_request_id,
163
149
  )
150
+ logger.info(f"Created inline comment in {self.merge_request_ref} at {file}:{line}")
151
+ except Exception as error:
152
+ logger.exception(f"Failed to create inline comment in {self.merge_request_ref} at {file}:{line}: {error}")
153
+ raise
154
+
155
+ # --- Replies ---
156
+ async def create_inline_reply(self, thread_id: str | int, message: str) -> None:
157
+ try:
158
+ logger.info(f"Replying to discussion {thread_id=} in MR {self.merge_request_ref}")
159
+ await self.http_client.mr.create_discussion_reply(
160
+ project_id=self.project_id,
161
+ merge_request_id=self.merge_request_id,
162
+ discussion_id=str(thread_id),
163
+ body=message,
164
+ )
164
165
  logger.info(
165
- f"Created inline comment in merge_request_id={self.merge_request_id} at {file}:{line}"
166
+ f"Created inline reply to discussion {thread_id=} in MR {self.merge_request_ref}"
167
+ )
168
+ except Exception as error:
169
+ logger.exception(
170
+ f"Failed to create inline reply to discussion {thread_id=} in MR {self.merge_request_ref}: {error}"
166
171
  )
172
+ raise
173
+
174
+ async def create_summary_reply(self, thread_id: int | str, message: str) -> None:
175
+ try:
176
+ logger.info(f"Replying to general comment {thread_id=} in MR {self.merge_request_ref}")
177
+ await self.create_general_comment(message)
167
178
  except Exception as error:
168
179
  logger.exception(
169
- f"Failed to create inline comment in merge_request_id={self.merge_request_id} "
170
- f"at {file}:{line}: {error}"
180
+ f"Failed to reply to general comment {thread_id=} in MR {self.merge_request_ref}: {error}"
171
181
  )
172
182
  raise
183
+
184
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
185
+ try:
186
+ response = await self.http_client.mr.get_discussions(
187
+ project_id=self.project_id,
188
+ merge_request_id=self.merge_request_id,
189
+ )
190
+ logger.info(f"Fetched inline threads for MR {self.merge_request_ref}")
191
+
192
+ threads: list[ReviewThreadSchema] = []
193
+ for discussion in response.root:
194
+ if not discussion.notes:
195
+ continue
196
+
197
+ position = discussion.position or (
198
+ discussion.notes[0].position if discussion.notes else None
199
+ )
200
+
201
+ threads.append(
202
+ ReviewThreadSchema(
203
+ id=discussion.id,
204
+ kind=ThreadKind.INLINE,
205
+ file=position.new_path if position else None,
206
+ line=position.new_line if position else None,
207
+ comments=[
208
+ get_review_comment_from_gitlab_note(note, discussion)
209
+ for note in discussion.notes
210
+ ],
211
+ )
212
+ )
213
+
214
+ logger.info(f"Built {len(threads)} inline threads for MR {self.merge_request_ref}")
215
+ return threads
216
+ except Exception as error:
217
+ logger.exception(f"Failed to fetch inline threads for MR {self.merge_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.id, kind=ThreadKind.SUMMARY, comments=[comment])
226
+ for comment in comments
227
+ ]
228
+ logger.info(f"Built {len(threads)} general threads for MR {self.merge_request_ref}")
229
+ return threads
230
+ except Exception as error:
231
+ logger.exception(f"Failed to build general threads for MR {self.merge_request_ref}: {error}")
232
+ 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,
@@ -12,12 +12,14 @@ from ai_review.clients.gitlab.mr.schema.discussions import (
12
12
  GitLabGetMRDiscussionsResponseSchema,
13
13
  GitLabCreateMRDiscussionRequestSchema,
14
14
  GitLabCreateMRDiscussionResponseSchema,
15
+ GitLabCreateMRDiscussionReplyResponseSchema,
15
16
  )
16
17
  from ai_review.clients.gitlab.mr.schema.notes import (
17
18
  GitLabNoteSchema,
18
19
  GitLabGetMRNotesResponseSchema,
19
20
  GitLabCreateMRNoteResponseSchema,
20
21
  )
22
+ from ai_review.clients.gitlab.mr.schema.position import GitLabPositionSchema
21
23
  from ai_review.clients.gitlab.mr.types import GitLabMergeRequestsHTTPClientProtocol
22
24
  from ai_review.config import settings
23
25
  from ai_review.libs.config.vcs.base import GitLabVCSConfig
@@ -60,8 +62,16 @@ class FakeGitLabMergeRequestsHTTPClient(GitLabMergeRequestsHTTPClientProtocol):
60
62
  self.calls.append(("get_notes", {"project_id": project_id, "merge_request_id": merge_request_id}))
61
63
  return GitLabGetMRNotesResponseSchema(
62
64
  root=[
63
- GitLabNoteSchema(id=1, body="General comment"),
64
- GitLabNoteSchema(id=2, body="Another note"),
65
+ GitLabNoteSchema(
66
+ id=1,
67
+ body="General comment",
68
+ author=GitLabUserSchema(id=301, name="Charlie", username="charlie"),
69
+ ),
70
+ GitLabNoteSchema(
71
+ id=2,
72
+ body="Another note",
73
+ author=GitLabUserSchema(id=302, name="Diana", username="diana"),
74
+ ),
65
75
  ]
66
76
  )
67
77
 
@@ -72,10 +82,42 @@ class FakeGitLabMergeRequestsHTTPClient(GitLabMergeRequestsHTTPClientProtocol):
72
82
  GitLabDiscussionSchema(
73
83
  id="discussion-1",
74
84
  notes=[
75
- GitLabNoteSchema(id=10, body="Inline comment A"),
76
- GitLabNoteSchema(id=11, body="Inline comment B"),
85
+ GitLabNoteSchema(
86
+ id=10,
87
+ body="Inline comment A",
88
+ position=GitLabPositionSchema(
89
+ base_sha="abc123",
90
+ head_sha="def456",
91
+ start_sha="ghi789",
92
+ new_path="src/app.py",
93
+ new_line=12,
94
+ ),
95
+ ),
96
+ GitLabNoteSchema(
97
+ id=11,
98
+ body="Inline comment B",
99
+ position=GitLabPositionSchema(
100
+ base_sha="abc123",
101
+ head_sha="def456",
102
+ start_sha="ghi789",
103
+ new_path="src/app.py",
104
+ new_line=14,
105
+ ),
106
+ ),
77
107
  ],
78
- )
108
+ position=GitLabPositionSchema(
109
+ base_sha="abc123",
110
+ head_sha="def456",
111
+ start_sha="ghi789",
112
+ new_path="src/app.py",
113
+ new_line=12,
114
+ ),
115
+ ),
116
+ GitLabDiscussionSchema(
117
+ id="discussion-2",
118
+ notes=[GitLabNoteSchema(id=20, body="Outdated diff comment", position=None)],
119
+ position=None,
120
+ ),
79
121
  ]
80
122
  )
81
123
 
@@ -100,7 +142,30 @@ class FakeGitLabMergeRequestsHTTPClient(GitLabMergeRequestsHTTPClientProtocol):
100
142
  {"project_id": project_id, "merge_request_id": merge_request_id, "body": request.body}
101
143
  )
102
144
  )
103
- return GitLabCreateMRDiscussionResponseSchema(id="discussion-new", body=request.body)
145
+ return GitLabCreateMRDiscussionResponseSchema(
146
+ id="discussion-new",
147
+ notes=[GitLabNoteSchema(id=1, body=request.body)]
148
+ )
149
+
150
+ async def create_discussion_reply(
151
+ self,
152
+ project_id: str,
153
+ merge_request_id: str,
154
+ discussion_id: str,
155
+ body: str,
156
+ ) -> GitLabCreateMRDiscussionReplyResponseSchema:
157
+ self.calls.append(
158
+ (
159
+ "create_discussion_reply",
160
+ {
161
+ "project_id": project_id,
162
+ "merge_request_id": merge_request_id,
163
+ "discussion_id": discussion_id,
164
+ "body": body,
165
+ },
166
+ )
167
+ )
168
+ return GitLabCreateMRDiscussionReplyResponseSchema(id=100, body=body)
104
169
 
105
170
 
106
171
  class FakeGitLabHTTPClient:
File without changes
File without changes
@@ -0,0 +1,13 @@
1
+ import pytest
2
+ from pydantic import BaseModel
3
+
4
+ from ai_review.libs.llm.output_json_parser import LLMOutputJSONParser
5
+
6
+
7
+ class DummyModel(BaseModel):
8
+ text: str
9
+
10
+
11
+ @pytest.fixture
12
+ def llm_output_json_parser() -> LLMOutputJSONParser:
13
+ return LLMOutputJSONParser(DummyModel)
@@ -0,0 +1,8 @@
1
+ import pytest
2
+
3
+ from ai_review.services.hook import HookService
4
+
5
+
6
+ @pytest.fixture
7
+ def hook_service() -> HookService:
8
+ return HookService()
@@ -13,11 +13,14 @@ class FakeLLMClient(LLMClientProtocol):
13
13
  async def chat(self, prompt: str, prompt_system: str) -> ChatResultSchema:
14
14
  self.calls.append(("chat", {"prompt": prompt, "prompt_system": prompt_system}))
15
15
 
16
- return ChatResultSchema(
17
- text=self.responses.get("text", "FAKE_RESPONSE"),
18
- total_tokens=self.responses.get("total_tokens", 42),
19
- prompt_tokens=self.responses.get("prompt_tokens", 21),
20
- completion_tokens=self.responses.get("completion_tokens", 21),
16
+ return self.responses.get(
17
+ "chat",
18
+ ChatResultSchema(
19
+ text="FAKE_RESPONSE",
20
+ total_tokens=42,
21
+ prompt_tokens=21,
22
+ completion_tokens=21,
23
+ )
21
24
  )
22
25
 
23
26
 
@@ -1,8 +1,10 @@
1
1
  import pytest
2
2
 
3
+ from ai_review.libs.config.prompt import PromptConfig
3
4
  from ai_review.services.diff.schema import DiffFileSchema
4
5
  from ai_review.services.prompt.schema import PromptContextSchema
5
6
  from ai_review.services.prompt.types import PromptServiceProtocol
7
+ from ai_review.services.vcs.types import ReviewThreadSchema
6
8
 
7
9
 
8
10
  class FakePromptService(PromptServiceProtocol):
@@ -25,6 +27,24 @@ class FakePromptService(PromptServiceProtocol):
25
27
  self.calls.append(("build_context_request", {"diffs": diffs, "context": context}))
26
28
  return "CONTEXT_PROMPT"
27
29
 
30
+ def build_inline_reply_request(
31
+ self,
32
+ diff: DiffFileSchema,
33
+ thread: ReviewThreadSchema,
34
+ context: PromptContextSchema
35
+ ) -> str:
36
+ self.calls.append(("build_inline_reply_request", {"diff": diff, "thread": thread, "context": context}))
37
+ return f"INLINE_REPLY_PROMPT_FOR_{diff.file}"
38
+
39
+ def build_summary_reply_request(
40
+ self,
41
+ diffs: list[DiffFileSchema],
42
+ thread: ReviewThreadSchema,
43
+ context: PromptContextSchema
44
+ ) -> str:
45
+ self.calls.append(("build_summary_reply_request", {"diffs": diffs, "thread": thread, "context": context}))
46
+ return "SUMMARY_REPLY_PROMPT"
47
+
28
48
  def build_system_inline_request(self, context: PromptContextSchema) -> str:
29
49
  self.calls.append(("build_system_inline_request", {"context": context}))
30
50
  return "SYSTEM_INLINE_PROMPT"
@@ -37,7 +57,57 @@ class FakePromptService(PromptServiceProtocol):
37
57
  self.calls.append(("build_system_summary_request", {"context": context}))
38
58
  return "SYSTEM_SUMMARY_PROMPT"
39
59
 
60
+ def build_system_inline_reply_request(self, context: PromptContextSchema) -> str:
61
+ self.calls.append(("build_system_inline_reply_request", {"context": context}))
62
+ return "SYSTEM_INLINE_REPLY_PROMPT"
63
+
64
+ def build_system_summary_reply_request(self, context: PromptContextSchema) -> str:
65
+ self.calls.append(("build_system_summary_reply_request", {"context": context}))
66
+ return "SYSTEM_SUMMARY_REPLY_PROMPT"
67
+
40
68
 
41
69
  @pytest.fixture
42
70
  def fake_prompt_service() -> FakePromptService:
43
71
  return FakePromptService()
72
+
73
+
74
+ @pytest.fixture
75
+ def fake_prompts(monkeypatch: pytest.MonkeyPatch) -> None:
76
+ """Patch methods of settings.prompt to return dummy values."""
77
+ monkeypatch.setattr(PromptConfig, "load_inline", lambda self: ["GLOBAL_INLINE", "INLINE_PROMPT"])
78
+ monkeypatch.setattr(PromptConfig, "load_context", lambda self: ["GLOBAL_CONTEXT", "CONTEXT_PROMPT"])
79
+ monkeypatch.setattr(PromptConfig, "load_summary", lambda self: ["GLOBAL_SUMMARY", "SUMMARY_PROMPT"])
80
+ monkeypatch.setattr(PromptConfig, "load_system_inline", lambda self: ["SYS_INLINE_A", "SYS_INLINE_B"])
81
+ monkeypatch.setattr(PromptConfig, "load_system_context", lambda self: ["SYS_CONTEXT_A", "SYS_CONTEXT_B"])
82
+ monkeypatch.setattr(PromptConfig, "load_system_summary", lambda self: ["SYS_SUMMARY_A", "SYS_SUMMARY_B"])
83
+ monkeypatch.setattr(PromptConfig, "load_inline_reply", lambda self: ["INLINE_REPLY_A", "INLINE_REPLY_B"])
84
+ monkeypatch.setattr(PromptConfig, "load_summary_reply", lambda self: ["SUMMARY_REPLY_A", "SUMMARY_REPLY_B"])
85
+ monkeypatch.setattr(
86
+ PromptConfig,
87
+ "load_system_inline_reply",
88
+ lambda self: ["SYS_INLINE_REPLY_A", "SYS_INLINE_REPLY_B"]
89
+ )
90
+ monkeypatch.setattr(
91
+ PromptConfig,
92
+ "load_system_summary_reply",
93
+ lambda self: ["SYS_SUMMARY_REPLY_A", "SYS_SUMMARY_REPLY_B"]
94
+ )
95
+
96
+
97
+ @pytest.fixture
98
+ def fake_prompt_context() -> PromptContextSchema:
99
+ """Builds a context object that reflects the new unified review schema."""
100
+ return PromptContextSchema(
101
+ review_title="Fix login bug",
102
+ review_description="Some description",
103
+ review_author_name="Nikita",
104
+ review_author_username="nikita.filonov",
105
+ review_reviewers=["Alice", "Bob"],
106
+ review_reviewers_usernames=["alice", "bob"],
107
+ review_assignees=["Charlie"],
108
+ review_assignees_usernames=["charlie"],
109
+ source_branch="feature/login-fix",
110
+ target_branch="main",
111
+ labels=["bug", "critical"],
112
+ changed_files=["foo.py", "bar.py"],
113
+ )