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.
- ai_review/cli/commands/run_inline_reply_review.py +7 -0
- ai_review/cli/commands/run_summary_reply_review.py +7 -0
- ai_review/cli/main.py +17 -0
- ai_review/clients/bitbucket/pr/schema/comments.py +14 -0
- ai_review/clients/bitbucket/pr/schema/pull_request.py +1 -5
- ai_review/clients/bitbucket/pr/schema/user.py +7 -0
- ai_review/clients/github/pr/client.py +35 -4
- ai_review/clients/github/pr/schema/comments.py +21 -0
- ai_review/clients/github/pr/schema/pull_request.py +1 -4
- ai_review/clients/github/pr/schema/user.py +6 -0
- ai_review/clients/github/pr/types.py +11 -1
- ai_review/clients/gitlab/mr/client.py +32 -1
- ai_review/clients/gitlab/mr/schema/changes.py +1 -5
- ai_review/clients/gitlab/mr/schema/discussions.py +14 -12
- ai_review/clients/gitlab/mr/schema/notes.py +5 -0
- ai_review/clients/gitlab/mr/schema/position.py +13 -0
- ai_review/clients/gitlab/mr/schema/user.py +7 -0
- ai_review/clients/gitlab/mr/types.py +16 -7
- ai_review/libs/asynchronous/gather.py +8 -1
- ai_review/libs/config/prompt.py +96 -64
- ai_review/libs/config/review.py +2 -0
- ai_review/libs/llm/output_json_parser.py +60 -0
- ai_review/prompts/default_inline_reply.md +10 -0
- ai_review/prompts/default_summary_reply.md +14 -0
- ai_review/prompts/default_system_inline_reply.md +31 -0
- ai_review/prompts/default_system_summary_reply.md +13 -0
- ai_review/services/artifacts/schema.py +2 -2
- ai_review/services/git/service.py +42 -11
- ai_review/services/hook/constants.py +14 -0
- ai_review/services/hook/service.py +95 -4
- ai_review/services/hook/types.py +18 -2
- ai_review/services/prompt/adapter.py +1 -1
- ai_review/services/prompt/service.py +49 -3
- ai_review/services/prompt/tools.py +21 -0
- ai_review/services/prompt/types.py +23 -0
- ai_review/services/review/gateway/comment.py +45 -6
- ai_review/services/review/gateway/llm.py +2 -1
- ai_review/services/review/gateway/types.py +50 -0
- ai_review/services/review/internal/inline/service.py +40 -0
- ai_review/services/review/internal/inline/types.py +8 -0
- ai_review/services/review/internal/inline_reply/schema.py +23 -0
- ai_review/services/review/internal/inline_reply/service.py +20 -0
- ai_review/services/review/internal/inline_reply/types.py +8 -0
- ai_review/services/review/{policy → internal/policy}/service.py +2 -1
- ai_review/services/review/internal/policy/types.py +15 -0
- ai_review/services/review/{summary → internal/summary}/service.py +2 -2
- ai_review/services/review/{summary → internal/summary}/types.py +1 -1
- ai_review/services/review/internal/summary_reply/__init__.py +0 -0
- ai_review/services/review/internal/summary_reply/schema.py +8 -0
- ai_review/services/review/internal/summary_reply/service.py +15 -0
- ai_review/services/review/internal/summary_reply/types.py +8 -0
- ai_review/services/review/runner/__init__.py +0 -0
- ai_review/services/review/runner/context.py +72 -0
- ai_review/services/review/runner/inline.py +80 -0
- ai_review/services/review/runner/inline_reply.py +80 -0
- ai_review/services/review/runner/summary.py +71 -0
- ai_review/services/review/runner/summary_reply.py +79 -0
- ai_review/services/review/runner/types.py +6 -0
- ai_review/services/review/service.py +78 -110
- ai_review/services/vcs/bitbucket/adapter.py +27 -0
- ai_review/services/vcs/bitbucket/client.py +118 -42
- ai_review/services/vcs/github/adapter.py +35 -0
- ai_review/services/vcs/github/client.py +105 -44
- ai_review/services/vcs/gitlab/adapter.py +28 -0
- ai_review/services/vcs/gitlab/client.py +103 -43
- ai_review/services/vcs/types.py +34 -0
- ai_review/tests/fixtures/clients/bitbucket.py +2 -2
- ai_review/tests/fixtures/clients/github.py +35 -6
- ai_review/tests/fixtures/clients/gitlab.py +71 -6
- ai_review/tests/fixtures/libs/__init__.py +0 -0
- ai_review/tests/fixtures/libs/llm/__init__.py +0 -0
- ai_review/tests/fixtures/libs/llm/output_json_parser.py +13 -0
- ai_review/tests/fixtures/services/hook.py +8 -0
- ai_review/tests/fixtures/services/llm.py +8 -5
- ai_review/tests/fixtures/services/prompt.py +70 -0
- ai_review/tests/fixtures/services/review/base.py +41 -0
- ai_review/tests/fixtures/services/review/gateway/__init__.py +0 -0
- ai_review/tests/fixtures/services/review/gateway/comment.py +98 -0
- ai_review/tests/fixtures/services/review/gateway/llm.py +17 -0
- ai_review/tests/fixtures/services/review/internal/__init__.py +0 -0
- ai_review/tests/fixtures/services/review/{inline.py → internal/inline.py} +8 -6
- ai_review/tests/fixtures/services/review/internal/inline_reply.py +25 -0
- ai_review/tests/fixtures/services/review/internal/policy.py +28 -0
- ai_review/tests/fixtures/services/review/internal/summary.py +21 -0
- ai_review/tests/fixtures/services/review/internal/summary_reply.py +19 -0
- ai_review/tests/fixtures/services/review/runner/__init__.py +0 -0
- ai_review/tests/fixtures/services/review/runner/context.py +50 -0
- ai_review/tests/fixtures/services/review/runner/inline.py +50 -0
- ai_review/tests/fixtures/services/review/runner/inline_reply.py +50 -0
- ai_review/tests/fixtures/services/review/runner/summary.py +50 -0
- ai_review/tests/fixtures/services/review/runner/summary_reply.py +50 -0
- ai_review/tests/fixtures/services/vcs.py +23 -0
- ai_review/tests/suites/cli/__init__.py +0 -0
- ai_review/tests/suites/cli/test_main.py +54 -0
- ai_review/tests/suites/libs/config/test_prompt.py +108 -28
- ai_review/tests/suites/libs/llm/__init__.py +0 -0
- ai_review/tests/suites/libs/llm/test_output_json_parser.py +155 -0
- ai_review/tests/suites/services/hook/test_service.py +88 -4
- ai_review/tests/suites/services/prompt/test_adapter.py +3 -3
- ai_review/tests/suites/services/prompt/test_service.py +102 -58
- ai_review/tests/suites/services/prompt/test_tools.py +86 -1
- ai_review/tests/suites/services/review/gateway/__init__.py +0 -0
- ai_review/tests/suites/services/review/gateway/test_comment.py +253 -0
- ai_review/tests/suites/services/review/gateway/test_llm.py +82 -0
- ai_review/tests/suites/services/review/internal/__init__.py +0 -0
- ai_review/tests/suites/services/review/internal/inline/__init__.py +0 -0
- ai_review/tests/suites/services/review/{inline → internal/inline}/test_schema.py +1 -1
- ai_review/tests/suites/services/review/internal/inline/test_service.py +81 -0
- ai_review/tests/suites/services/review/internal/inline_reply/__init__.py +0 -0
- ai_review/tests/suites/services/review/internal/inline_reply/test_schema.py +57 -0
- ai_review/tests/suites/services/review/internal/inline_reply/test_service.py +72 -0
- ai_review/tests/suites/services/review/internal/policy/__init__.py +0 -0
- ai_review/tests/suites/services/review/{policy → internal/policy}/test_service.py +1 -1
- ai_review/tests/suites/services/review/internal/summary/__init__.py +0 -0
- ai_review/tests/suites/services/review/{summary → internal/summary}/test_schema.py +1 -1
- ai_review/tests/suites/services/review/{summary → internal/summary}/test_service.py +2 -2
- ai_review/tests/suites/services/review/internal/summary_reply/__init__.py +0 -0
- ai_review/tests/suites/services/review/internal/summary_reply/test_schema.py +19 -0
- ai_review/tests/suites/services/review/internal/summary_reply/test_service.py +21 -0
- ai_review/tests/suites/services/review/runner/__init__.py +0 -0
- ai_review/tests/suites/services/review/runner/test_context.py +89 -0
- ai_review/tests/suites/services/review/runner/test_inline.py +100 -0
- ai_review/tests/suites/services/review/runner/test_inline_reply.py +109 -0
- ai_review/tests/suites/services/review/runner/test_summary.py +87 -0
- ai_review/tests/suites/services/review/runner/test_summary_reply.py +97 -0
- ai_review/tests/suites/services/review/test_service.py +64 -97
- ai_review/tests/suites/services/vcs/bitbucket/test_adapter.py +109 -0
- ai_review/tests/suites/services/vcs/bitbucket/{test_service.py → test_client.py} +88 -1
- ai_review/tests/suites/services/vcs/github/test_adapter.py +162 -0
- ai_review/tests/suites/services/vcs/github/{test_service.py → test_client.py} +102 -2
- ai_review/tests/suites/services/vcs/gitlab/test_adapter.py +134 -0
- ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +113 -3
- {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/METADATA +8 -5
- {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/RECORD +146 -72
- ai_review/services/review/inline/service.py +0 -54
- ai_review/services/review/inline/types.py +0 -11
- ai_review/tests/fixtures/services/review/summary.py +0 -19
- ai_review/tests/suites/services/review/inline/test_service.py +0 -107
- /ai_review/{services/review/inline → libs/llm}/__init__.py +0 -0
- /ai_review/services/review/{policy → internal}/__init__.py +0 -0
- /ai_review/services/review/{summary → internal/inline}/__init__.py +0 -0
- /ai_review/services/review/{inline → internal/inline}/schema.py +0 -0
- /ai_review/{tests/suites/services/review/inline → services/review/internal/inline_reply}/__init__.py +0 -0
- /ai_review/{tests/suites/services/review → services/review/internal}/policy/__init__.py +0 -0
- /ai_review/{tests/suites/services/review → services/review/internal}/summary/__init__.py +0 -0
- /ai_review/services/review/{summary → internal/summary}/schema.py +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/WHEEL +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/entry_points.txt +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.29.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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 []
|
ai_review/services/vcs/types.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
85
|
+
return GitHubGetIssueCommentsResponseSchema(
|
|
79
86
|
root=[
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
64
|
-
|
|
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(
|
|
76
|
-
|
|
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(
|
|
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)
|
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
)
|