xai-review 0.27.0__py3-none-any.whl → 0.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of xai-review might be problematic. Click here for more details.
- 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 +17 -7
- ai_review/clients/gitlab/mr/schema/notes.py +3 -0
- ai_review/clients/gitlab/mr/schema/user.py +7 -0
- ai_review/clients/gitlab/mr/types.py +16 -7
- 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/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 +24 -0
- ai_review/services/vcs/bitbucket/client.py +107 -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 +26 -0
- ai_review/services/vcs/gitlab/client.py +91 -38
- 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 +42 -3
- 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 +105 -0
- ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +99 -1
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/METADATA +8 -5
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/RECORD +143 -70
- 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.28.0.dist-info}/WHEEL +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/entry_points.txt +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/licenses/LICENSE +0 -0
- {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from ai_review.clients.github.pr.schema.comments import GitHubPRCommentSchema, GitHubIssueCommentSchema
|
|
2
|
+
from ai_review.clients.github.pr.schema.user import GitHubUserSchema
|
|
3
|
+
from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_user_from_github_user(user: GitHubUserSchema | None) -> UserSchema:
|
|
7
|
+
return UserSchema(
|
|
8
|
+
id=user.id if user else None,
|
|
9
|
+
name=user.login if user else "",
|
|
10
|
+
username=user.login if user else "",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_review_comment_from_github_pr_comment(comment: GitHubPRCommentSchema) -> ReviewCommentSchema:
|
|
15
|
+
parent_id = comment.in_reply_to_id
|
|
16
|
+
thread_id = parent_id or comment.id
|
|
17
|
+
|
|
18
|
+
return ReviewCommentSchema(
|
|
19
|
+
id=comment.id,
|
|
20
|
+
body=comment.body or "",
|
|
21
|
+
file=comment.path,
|
|
22
|
+
line=comment.line,
|
|
23
|
+
author=get_user_from_github_user(comment.user),
|
|
24
|
+
parent_id=parent_id,
|
|
25
|
+
thread_id=thread_id,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_review_comment_from_github_issue_comment(comment: GitHubIssueCommentSchema) -> ReviewCommentSchema:
|
|
30
|
+
return ReviewCommentSchema(
|
|
31
|
+
id=comment.id,
|
|
32
|
+
body=comment.body or "",
|
|
33
|
+
author=get_user_from_github_user(comment.user),
|
|
34
|
+
thread_id=comment.id
|
|
35
|
+
)
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
1
3
|
from ai_review.clients.github.client import get_github_http_client
|
|
2
|
-
from ai_review.clients.github.pr.schema.comments import
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
169
|
-
f"at {file}:{line}: {error}"
|
|
184
|
+
f"Failed to create summary reply to comment {thread_id=} in {self.pull_request_ref}: {error}"
|
|
170
185
|
)
|
|
171
186
|
raise
|
|
187
|
+
|
|
188
|
+
# --- Threads ---
|
|
189
|
+
async def get_inline_threads(self) -> list[ReviewThreadSchema]:
|
|
190
|
+
try:
|
|
191
|
+
response = await self.http_client.pr.get_review_comments(
|
|
192
|
+
owner=self.owner,
|
|
193
|
+
repo=self.repo,
|
|
194
|
+
pull_number=self.pull_number,
|
|
195
|
+
)
|
|
196
|
+
comments = response.root
|
|
197
|
+
logger.info(f"Fetched inline comment threads for {self.pull_request_ref}")
|
|
198
|
+
|
|
199
|
+
threads: dict[str | int, list[ReviewCommentSchema]] = defaultdict(list)
|
|
200
|
+
for comment in comments:
|
|
201
|
+
review_comment = get_review_comment_from_github_pr_comment(comment)
|
|
202
|
+
threads[review_comment.thread_id].append(review_comment)
|
|
203
|
+
|
|
204
|
+
logger.info(f"Built {len(threads)} inline threads for {self.pull_request_ref}")
|
|
205
|
+
|
|
206
|
+
return [
|
|
207
|
+
ReviewThreadSchema(
|
|
208
|
+
id=thread_id,
|
|
209
|
+
kind=ThreadKind.INLINE,
|
|
210
|
+
file=thread[0].file,
|
|
211
|
+
line=thread[0].line,
|
|
212
|
+
comments=sorted(thread, key=lambda t: int(t.id)),
|
|
213
|
+
)
|
|
214
|
+
for thread_id, thread in threads.items()
|
|
215
|
+
]
|
|
216
|
+
except Exception as error:
|
|
217
|
+
logger.exception(f"Failed to fetch inline threads for {self.pull_request_ref}: {error}")
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
async def get_general_threads(self) -> list[ReviewThreadSchema]:
|
|
221
|
+
try:
|
|
222
|
+
comments = await self.get_general_comments()
|
|
223
|
+
|
|
224
|
+
threads = [
|
|
225
|
+
ReviewThreadSchema(id=comment.thread_id, kind=ThreadKind.SUMMARY, comments=[comment])
|
|
226
|
+
for comment in comments
|
|
227
|
+
]
|
|
228
|
+
logger.info(f"Built {len(threads)} general threads for {self.pull_request_ref}")
|
|
229
|
+
return threads
|
|
230
|
+
except Exception as error:
|
|
231
|
+
logger.exception(f"Failed to build general threads for {self.pull_request_ref}: {error}")
|
|
232
|
+
return []
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from ai_review.clients.gitlab.mr.schema.discussions import GitLabDiscussionSchema
|
|
2
|
+
from ai_review.clients.gitlab.mr.schema.notes import GitLabNoteSchema
|
|
3
|
+
from ai_review.clients.gitlab.mr.schema.user import GitLabUserSchema
|
|
4
|
+
from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_user_from_gitlab_user(user: GitLabUserSchema | None) -> UserSchema:
|
|
8
|
+
return UserSchema(
|
|
9
|
+
id=user.id if user else None,
|
|
10
|
+
name=user.name if user else "",
|
|
11
|
+
username=user.username if user else "",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_review_comment_from_gitlab_note(
|
|
16
|
+
note: GitLabNoteSchema,
|
|
17
|
+
discussion: GitLabDiscussionSchema
|
|
18
|
+
) -> ReviewCommentSchema:
|
|
19
|
+
return ReviewCommentSchema(
|
|
20
|
+
id=note.id,
|
|
21
|
+
body=note.body or "",
|
|
22
|
+
file=discussion.position.new_path if discussion.position else None,
|
|
23
|
+
line=discussion.position.new_line if discussion.position else None,
|
|
24
|
+
author=get_user_from_gitlab_user(note.author),
|
|
25
|
+
thread_id=discussion.id,
|
|
26
|
+
)
|
|
@@ -5,11 +5,14 @@ from ai_review.clients.gitlab.mr.schema.discussions import (
|
|
|
5
5
|
)
|
|
6
6
|
from ai_review.config import settings
|
|
7
7
|
from ai_review.libs.logger import get_logger
|
|
8
|
+
from ai_review.services.vcs.gitlab.adapter import get_user_from_gitlab_user, get_review_comment_from_gitlab_note
|
|
8
9
|
from ai_review.services.vcs.types import (
|
|
9
10
|
VCSClientProtocol,
|
|
10
11
|
UserSchema,
|
|
12
|
+
ThreadKind,
|
|
11
13
|
BranchRefSchema,
|
|
12
14
|
ReviewInfoSchema,
|
|
15
|
+
ReviewThreadSchema,
|
|
13
16
|
ReviewCommentSchema,
|
|
14
17
|
)
|
|
15
18
|
|
|
@@ -21,16 +24,16 @@ class GitLabVCSClient(VCSClientProtocol):
|
|
|
21
24
|
self.http_client = get_gitlab_http_client()
|
|
22
25
|
self.project_id = settings.vcs.pipeline.project_id
|
|
23
26
|
self.merge_request_id = settings.vcs.pipeline.merge_request_id
|
|
27
|
+
self.merge_request_ref = f"project_id={self.project_id} merge_request_id={self.merge_request_id}"
|
|
24
28
|
|
|
29
|
+
# --- Review info ---
|
|
25
30
|
async def get_review_info(self) -> ReviewInfoSchema:
|
|
26
31
|
try:
|
|
27
32
|
response = await self.http_client.mr.get_changes(
|
|
28
33
|
project_id=self.project_id,
|
|
29
34
|
merge_request_id=self.merge_request_id,
|
|
30
35
|
)
|
|
31
|
-
logger.info(
|
|
32
|
-
f"Fetched MR info for project_id={self.project_id} merge_request_id={self.merge_request_id}"
|
|
33
|
-
)
|
|
36
|
+
logger.info(f"Fetched MR info for {self.merge_request_ref}")
|
|
34
37
|
|
|
35
38
|
return ReviewInfoSchema(
|
|
36
39
|
id=response.iid,
|
|
@@ -66,32 +69,29 @@ class GitLabVCSClient(VCSClientProtocol):
|
|
|
66
69
|
],
|
|
67
70
|
)
|
|
68
71
|
except Exception as error:
|
|
69
|
-
logger.exception(
|
|
70
|
-
f"Failed to fetch MR info for project_id={self.project_id} "
|
|
71
|
-
f"merge_request_id={self.merge_request_id}: {error}"
|
|
72
|
-
)
|
|
72
|
+
logger.exception(f"Failed to fetch MR info for {self.merge_request_ref}: {error}")
|
|
73
73
|
return ReviewInfoSchema()
|
|
74
74
|
|
|
75
|
+
# --- Comments ---
|
|
75
76
|
async def get_general_comments(self) -> list[ReviewCommentSchema]:
|
|
76
77
|
try:
|
|
77
78
|
response = await self.http_client.mr.get_notes(
|
|
78
79
|
project_id=self.project_id,
|
|
79
80
|
merge_request_id=self.merge_request_id,
|
|
80
81
|
)
|
|
81
|
-
logger.info(
|
|
82
|
-
f"Fetched general comments for project_id={self.project_id} "
|
|
83
|
-
f"merge_request_id={self.merge_request_id}"
|
|
84
|
-
)
|
|
82
|
+
logger.info(f"Fetched general comments for {self.merge_request_ref}")
|
|
85
83
|
|
|
86
84
|
return [
|
|
87
|
-
ReviewCommentSchema(
|
|
85
|
+
ReviewCommentSchema(
|
|
86
|
+
id=note.id,
|
|
87
|
+
body=note.body or "",
|
|
88
|
+
author=get_user_from_gitlab_user(note.author),
|
|
89
|
+
thread_id=note.id
|
|
90
|
+
)
|
|
88
91
|
for note in response.root
|
|
89
92
|
]
|
|
90
93
|
except Exception as error:
|
|
91
|
-
logger.exception(
|
|
92
|
-
f"Failed to fetch general comments project_id={self.project_id} "
|
|
93
|
-
f"merge_request_id={self.merge_request_id}: {error}"
|
|
94
|
-
)
|
|
94
|
+
logger.exception(f"Failed to fetch general comments {self.merge_request_ref}: {error}")
|
|
95
95
|
return []
|
|
96
96
|
|
|
97
97
|
async def get_inline_comments(self) -> list[ReviewCommentSchema]:
|
|
@@ -100,45 +100,33 @@ class GitLabVCSClient(VCSClientProtocol):
|
|
|
100
100
|
project_id=self.project_id,
|
|
101
101
|
merge_request_id=self.merge_request_id,
|
|
102
102
|
)
|
|
103
|
-
logger.info(
|
|
104
|
-
f"Fetched inline discussions for project_id={self.project_id} "
|
|
105
|
-
f"merge_request_id={self.merge_request_id}"
|
|
106
|
-
)
|
|
103
|
+
logger.info(f"Fetched inline discussions for {self.merge_request_ref}")
|
|
107
104
|
|
|
108
105
|
return [
|
|
109
|
-
|
|
106
|
+
get_review_comment_from_gitlab_note(note, discussion)
|
|
110
107
|
for discussion in response.root
|
|
111
108
|
for note in discussion.notes
|
|
112
109
|
]
|
|
113
110
|
except Exception as error:
|
|
114
|
-
logger.exception(
|
|
115
|
-
f"Failed to fetch inline discussions project_id={self.project_id} "
|
|
116
|
-
f"merge_request_id={self.merge_request_id}: {error}"
|
|
117
|
-
)
|
|
111
|
+
logger.exception(f"Failed to fetch inline discussions {self.merge_request_ref}: {error}")
|
|
118
112
|
return []
|
|
119
113
|
|
|
120
114
|
async def create_general_comment(self, message: str) -> None:
|
|
121
115
|
try:
|
|
122
|
-
logger.info(
|
|
123
|
-
f"Posting general comment to merge_request_id={self.merge_request_id}: {message}"
|
|
124
|
-
)
|
|
116
|
+
logger.info(f"Posting general comment to {self.merge_request_ref}: {message}")
|
|
125
117
|
await self.http_client.mr.create_note(
|
|
126
118
|
body=message,
|
|
127
119
|
project_id=self.project_id,
|
|
128
120
|
merge_request_id=self.merge_request_id,
|
|
129
121
|
)
|
|
130
|
-
logger.info(f"Created general comment in
|
|
122
|
+
logger.info(f"Created general comment in {self.merge_request_ref}")
|
|
131
123
|
except Exception as error:
|
|
132
|
-
logger.exception(
|
|
133
|
-
f"Failed to create general comment in merge_request_id={self.merge_request_id}: {error}"
|
|
134
|
-
)
|
|
124
|
+
logger.exception(f"Failed to create general comment in {self.merge_request_ref}: {error}")
|
|
135
125
|
raise
|
|
136
126
|
|
|
137
127
|
async def create_inline_comment(self, file: str, line: int, message: str) -> None:
|
|
138
128
|
try:
|
|
139
|
-
logger.info(
|
|
140
|
-
f"Posting inline comment in merge_request_id={self.merge_request_id} at {file}:{line}: {message}"
|
|
141
|
-
)
|
|
129
|
+
logger.info(f"Posting inline comment in {self.merge_request_ref} at {file}:{line}: {message}")
|
|
142
130
|
|
|
143
131
|
response = await self.http_client.mr.get_changes(
|
|
144
132
|
project_id=self.project_id,
|
|
@@ -161,12 +149,77 @@ class GitLabVCSClient(VCSClientProtocol):
|
|
|
161
149
|
project_id=self.project_id,
|
|
162
150
|
merge_request_id=self.merge_request_id,
|
|
163
151
|
)
|
|
152
|
+
logger.info(f"Created inline comment in {self.merge_request_ref} at {file}:{line}")
|
|
153
|
+
except Exception as error:
|
|
154
|
+
logger.exception(f"Failed to create inline comment in {self.merge_request_ref} at {file}:{line}: {error}")
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
# --- Replies ---
|
|
158
|
+
async def create_inline_reply(self, thread_id: str | int, message: str) -> None:
|
|
159
|
+
try:
|
|
160
|
+
logger.info(f"Replying to discussion {thread_id=} in MR {self.merge_request_ref}")
|
|
161
|
+
await self.http_client.mr.create_discussion_reply(
|
|
162
|
+
project_id=self.project_id,
|
|
163
|
+
merge_request_id=self.merge_request_id,
|
|
164
|
+
discussion_id=str(thread_id),
|
|
165
|
+
body=message,
|
|
166
|
+
)
|
|
164
167
|
logger.info(
|
|
165
|
-
f"Created inline
|
|
168
|
+
f"Created inline reply to discussion {thread_id=} in MR {self.merge_request_ref}"
|
|
169
|
+
)
|
|
170
|
+
except Exception as error:
|
|
171
|
+
logger.exception(
|
|
172
|
+
f"Failed to create inline reply to discussion {thread_id=} in MR {self.merge_request_ref}: {error}"
|
|
166
173
|
)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
async def create_summary_reply(self, thread_id: int | str, message: str) -> None:
|
|
177
|
+
try:
|
|
178
|
+
logger.info(f"Replying to general comment {thread_id=} in MR {self.merge_request_ref}")
|
|
179
|
+
await self.create_general_comment(message)
|
|
167
180
|
except Exception as error:
|
|
168
181
|
logger.exception(
|
|
169
|
-
f"Failed to
|
|
170
|
-
f"at {file}:{line}: {error}"
|
|
182
|
+
f"Failed to reply to general comment {thread_id=} in MR {self.merge_request_ref}: {error}"
|
|
171
183
|
)
|
|
172
184
|
raise
|
|
185
|
+
|
|
186
|
+
async def get_inline_threads(self) -> list[ReviewThreadSchema]:
|
|
187
|
+
try:
|
|
188
|
+
response = await self.http_client.mr.get_discussions(
|
|
189
|
+
project_id=self.project_id,
|
|
190
|
+
merge_request_id=self.merge_request_id,
|
|
191
|
+
)
|
|
192
|
+
logger.info(f"Fetched inline threads for MR {self.merge_request_ref}")
|
|
193
|
+
|
|
194
|
+
threads = [
|
|
195
|
+
ReviewThreadSchema(
|
|
196
|
+
id=discussion.id,
|
|
197
|
+
kind=ThreadKind.INLINE,
|
|
198
|
+
file=discussion.position.new_path if discussion.position else None,
|
|
199
|
+
line=discussion.position.new_line if discussion.position else None,
|
|
200
|
+
comments=[
|
|
201
|
+
get_review_comment_from_gitlab_note(note, discussion)
|
|
202
|
+
for note in discussion.notes
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
for discussion in response.root if discussion.notes
|
|
206
|
+
]
|
|
207
|
+
logger.info(f"Built {len(threads)} inline threads for MR {self.merge_request_ref}")
|
|
208
|
+
return threads
|
|
209
|
+
except Exception as error:
|
|
210
|
+
logger.exception(f"Failed to fetch inline threads for MR {self.merge_request_ref}: {error}")
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
async def get_general_threads(self) -> list[ReviewThreadSchema]:
|
|
214
|
+
try:
|
|
215
|
+
comments = await self.get_general_comments()
|
|
216
|
+
|
|
217
|
+
threads = [
|
|
218
|
+
ReviewThreadSchema(id=comment.id, kind=ThreadKind.SUMMARY, comments=[comment])
|
|
219
|
+
for comment in comments
|
|
220
|
+
]
|
|
221
|
+
logger.info(f"Built {len(threads)} general threads for MR {self.merge_request_ref}")
|
|
222
|
+
return threads
|
|
223
|
+
except Exception as error:
|
|
224
|
+
logger.exception(f"Failed to build general threads for MR {self.merge_request_ref}: {error}")
|
|
225
|
+
return []
|
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,
|