xai-review 0.21.0__py3-none-any.whl → 0.23.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 (63) hide show
  1. ai_review/clients/claude/client.py +1 -1
  2. ai_review/clients/gemini/client.py +1 -1
  3. ai_review/clients/github/client.py +1 -1
  4. ai_review/clients/github/pr/client.py +64 -16
  5. ai_review/clients/github/pr/schema/comments.py +4 -0
  6. ai_review/clients/github/pr/schema/files.py +4 -0
  7. ai_review/clients/github/pr/schema/reviews.py +4 -0
  8. ai_review/clients/github/pr/types.py +49 -0
  9. ai_review/clients/gitlab/client.py +1 -1
  10. ai_review/clients/gitlab/mr/client.py +25 -8
  11. ai_review/clients/gitlab/mr/schema/discussions.py +4 -0
  12. ai_review/clients/gitlab/mr/schema/notes.py +4 -0
  13. ai_review/clients/gitlab/mr/types.py +35 -0
  14. ai_review/clients/openai/client.py +1 -1
  15. ai_review/config.py +2 -0
  16. ai_review/libs/asynchronous/gather.py +6 -3
  17. ai_review/libs/config/core.py +5 -0
  18. ai_review/libs/http/event_hooks/logger.py +5 -2
  19. ai_review/libs/http/transports/retry.py +23 -6
  20. ai_review/services/diff/renderers.py +3 -3
  21. ai_review/services/prompt/schema.py +13 -24
  22. ai_review/tests/fixtures/clients/claude.py +22 -0
  23. ai_review/tests/fixtures/clients/gemini.py +21 -0
  24. ai_review/tests/fixtures/clients/github.py +181 -0
  25. ai_review/tests/fixtures/clients/gitlab.py +150 -0
  26. ai_review/tests/fixtures/clients/openai.py +21 -0
  27. ai_review/tests/fixtures/services/__init__.py +0 -0
  28. ai_review/tests/fixtures/services/review/__init__.py +0 -0
  29. ai_review/tests/suites/clients/claude/test_client.py +1 -20
  30. ai_review/tests/suites/clients/gemini/test_client.py +1 -19
  31. ai_review/tests/suites/clients/github/test_client.py +1 -23
  32. ai_review/tests/suites/clients/gitlab/test_client.py +1 -22
  33. ai_review/tests/suites/clients/openai/test_client.py +1 -19
  34. ai_review/tests/suites/libs/asynchronous/__init__.py +0 -0
  35. ai_review/tests/suites/libs/asynchronous/test_gather.py +46 -0
  36. ai_review/tests/suites/services/diff/test_service.py +1 -1
  37. ai_review/tests/suites/services/diff/test_tools.py +1 -1
  38. ai_review/tests/suites/services/llm/__init__.py +0 -0
  39. ai_review/tests/suites/services/llm/test_factory.py +30 -0
  40. ai_review/tests/suites/services/prompt/test_schema.py +65 -0
  41. ai_review/tests/suites/services/review/test_service.py +9 -9
  42. ai_review/tests/suites/services/vcs/__init__.py +0 -0
  43. ai_review/tests/suites/services/vcs/github/__init__.py +0 -0
  44. ai_review/tests/suites/services/vcs/github/test_service.py +114 -0
  45. ai_review/tests/suites/services/vcs/gitlab/__init__.py +0 -0
  46. ai_review/tests/suites/services/vcs/gitlab/test_service.py +123 -0
  47. ai_review/tests/suites/services/vcs/test_factory.py +23 -0
  48. {xai_review-0.21.0.dist-info → xai_review-0.23.0.dist-info}/METADATA +2 -2
  49. {xai_review-0.21.0.dist-info → xai_review-0.23.0.dist-info}/RECORD +63 -43
  50. /ai_review/tests/fixtures/{review → clients}/__init__.py +0 -0
  51. /ai_review/tests/fixtures/{artifacts.py → services/artifacts.py} +0 -0
  52. /ai_review/tests/fixtures/{cost.py → services/cost.py} +0 -0
  53. /ai_review/tests/fixtures/{diff.py → services/diff.py} +0 -0
  54. /ai_review/tests/fixtures/{git.py → services/git.py} +0 -0
  55. /ai_review/tests/fixtures/{llm.py → services/llm.py} +0 -0
  56. /ai_review/tests/fixtures/{prompt.py → services/prompt.py} +0 -0
  57. /ai_review/tests/fixtures/{review → services/review}/inline.py +0 -0
  58. /ai_review/tests/fixtures/{review → services/review}/summary.py +0 -0
  59. /ai_review/tests/fixtures/{vcs.py → services/vcs.py} +0 -0
  60. {xai_review-0.21.0.dist-info → xai_review-0.23.0.dist-info}/WHEEL +0 -0
  61. {xai_review-0.21.0.dist-info → xai_review-0.23.0.dist-info}/entry_points.txt +0 -0
  62. {xai_review-0.21.0.dist-info → xai_review-0.23.0.dist-info}/licenses/LICENSE +0 -0
  63. {xai_review-0.21.0.dist-info → xai_review-0.23.0.dist-info}/top_level.txt +0 -0
@@ -26,7 +26,7 @@ class ClaudeHTTPClient(HTTPClient):
26
26
  def get_claude_http_client() -> ClaudeHTTPClient:
27
27
  logger = get_logger("CLAUDE_HTTP_CLIENT")
28
28
  logger_event_hook = LoggerEventHook(logger=logger)
29
- retry_transport = RetryTransport(transport=AsyncHTTPTransport())
29
+ retry_transport = RetryTransport(logger=logger, transport=AsyncHTTPTransport())
30
30
 
31
31
  client = AsyncClient(
32
32
  timeout=settings.llm.http_client.timeout,
@@ -29,7 +29,7 @@ class GeminiHTTPClient(HTTPClient):
29
29
  def get_gemini_http_client() -> GeminiHTTPClient:
30
30
  logger = get_logger("GEMINI_HTTP_CLIENT")
31
31
  logger_event_hook = LoggerEventHook(logger=logger)
32
- retry_transport = RetryTransport(transport=AsyncHTTPTransport())
32
+ retry_transport = RetryTransport(logger=logger, transport=AsyncHTTPTransport())
33
33
 
34
34
  client = AsyncClient(
35
35
  timeout=settings.llm.http_client.timeout,
@@ -15,7 +15,7 @@ class GitHubHTTPClient:
15
15
  def get_github_http_client() -> GitHubHTTPClient:
16
16
  logger = get_logger("GITHUB_HTTP_CLIENT")
17
17
  logger_event_hook = LoggerEventHook(logger=logger)
18
- retry_transport = RetryTransport(transport=AsyncHTTPTransport())
18
+ retry_transport = RetryTransport(logger=logger, transport=AsyncHTTPTransport())
19
19
 
20
20
  client = AsyncClient(
21
21
  timeout=settings.llm.http_client.timeout,
@@ -1,15 +1,23 @@
1
- from httpx import Response
1
+ from httpx import Response, QueryParams
2
2
 
3
3
  from ai_review.clients.github.pr.schema.comments import (
4
+ GitHubGetPRCommentsQuerySchema,
4
5
  GitHubGetPRCommentsResponseSchema,
5
6
  GitHubCreateIssueCommentRequestSchema,
6
7
  GitHubCreateIssueCommentResponseSchema,
7
8
  GitHubCreateReviewCommentRequestSchema,
8
9
  GitHubCreateReviewCommentResponseSchema
9
10
  )
10
- from ai_review.clients.github.pr.schema.files import GitHubGetPRFilesResponseSchema
11
+ from ai_review.clients.github.pr.schema.files import (
12
+ GitHubGetPRFilesQuerySchema,
13
+ GitHubGetPRFilesResponseSchema
14
+ )
11
15
  from ai_review.clients.github.pr.schema.pull_request import GitHubGetPRResponseSchema
12
- from ai_review.clients.github.pr.schema.reviews import GitHubGetPRReviewsResponseSchema
16
+ from ai_review.clients.github.pr.schema.reviews import (
17
+ GitHubGetPRReviewsQuerySchema,
18
+ GitHubGetPRReviewsResponseSchema
19
+ )
20
+ from ai_review.clients.github.pr.types import GitHubPullRequestsHTTPClientProtocol
13
21
  from ai_review.libs.http.client import HTTPClient
14
22
  from ai_review.libs.http.handlers import HTTPClientError, handle_http_error
15
23
 
@@ -18,22 +26,49 @@ class GitHubPullRequestsHTTPClientError(HTTPClientError):
18
26
  pass
19
27
 
20
28
 
21
- class GitHubPullRequestsHTTPClient(HTTPClient):
29
+ class GitHubPullRequestsHTTPClient(HTTPClient, GitHubPullRequestsHTTPClientProtocol):
22
30
  @handle_http_error(client="GitHubPullRequestsHTTPClient", exception=GitHubPullRequestsHTTPClientError)
23
31
  async def get_pull_request_api(self, owner: str, repo: str, pull_number: str) -> Response:
24
32
  return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}")
25
33
 
26
34
  @handle_http_error(client="GitHubPullRequestsHTTPClient", exception=GitHubPullRequestsHTTPClientError)
27
- async def get_files_api(self, owner: str, repo: str, pull_number: str) -> Response:
28
- return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}/files")
35
+ async def get_files_api(
36
+ self,
37
+ owner: str,
38
+ repo: str,
39
+ pull_number: str,
40
+ query: GitHubGetPRFilesQuerySchema
41
+ ) -> Response:
42
+ return await self.get(
43
+ f"/repos/{owner}/{repo}/pulls/{pull_number}/files",
44
+ query=QueryParams(**query.model_dump())
45
+ )
29
46
 
30
47
  @handle_http_error(client="GitHubPullRequestsHTTPClient", exception=GitHubPullRequestsHTTPClientError)
31
- async def get_issue_comments_api(self, owner: str, repo: str, issue_number: str) -> Response:
32
- return await self.get(f"/repos/{owner}/{repo}/issues/{issue_number}/comments")
48
+ async def get_issue_comments_api(
49
+ self,
50
+ owner: str,
51
+ repo: str,
52
+ issue_number: str,
53
+ query: GitHubGetPRCommentsQuerySchema,
54
+ ) -> Response:
55
+ return await self.get(
56
+ f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
57
+ query=QueryParams(**query.model_dump())
58
+ )
33
59
 
34
60
  @handle_http_error(client="GitHubPullRequestsHTTPClient", exception=GitHubPullRequestsHTTPClientError)
35
- async def get_review_comments_api(self, owner: str, repo: str, pull_number: str) -> Response:
36
- return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}/comments")
61
+ async def get_review_comments_api(
62
+ self,
63
+ owner: str,
64
+ repo: str,
65
+ pull_number: str,
66
+ query: GitHubGetPRCommentsQuerySchema,
67
+ ) -> Response:
68
+ return await self.get(
69
+ f"/repos/{owner}/{repo}/pulls/{pull_number}/comments",
70
+ query=QueryParams(**query.model_dump())
71
+ )
37
72
 
38
73
  @handle_http_error(client="GitHubPullRequestsHTTPClient", exception=GitHubPullRequestsHTTPClientError)
39
74
  async def create_review_comment_api(
@@ -62,27 +97,40 @@ class GitHubPullRequestsHTTPClient(HTTPClient):
62
97
  )
63
98
 
64
99
  @handle_http_error(client="GitHubPullRequestsHTTPClient", exception=GitHubPullRequestsHTTPClientError)
65
- async def get_reviews_api(self, owner: str, repo: str, pull_number: str) -> Response:
66
- return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}/reviews")
100
+ async def get_reviews_api(
101
+ self,
102
+ owner: str,
103
+ repo: str,
104
+ pull_number: str,
105
+ query: GitHubGetPRReviewsQuerySchema
106
+ ) -> Response:
107
+ return await self.get(
108
+ f"/repos/{owner}/{repo}/pulls/{pull_number}/reviews",
109
+ query=QueryParams(**query.model_dump())
110
+ )
67
111
 
68
112
  async def get_pull_request(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRResponseSchema:
69
113
  response = await self.get_pull_request_api(owner, repo, pull_number)
70
114
  return GitHubGetPRResponseSchema.model_validate_json(response.text)
71
115
 
72
116
  async def get_files(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRFilesResponseSchema:
73
- response = await self.get_files_api(owner, repo, pull_number)
117
+ query = GitHubGetPRFilesQuerySchema(per_page=100)
118
+ response = await self.get_files_api(owner, repo, pull_number, query)
74
119
  return GitHubGetPRFilesResponseSchema.model_validate_json(response.text)
75
120
 
76
121
  async def get_issue_comments(self, owner: str, repo: str, issue_number: str) -> GitHubGetPRCommentsResponseSchema:
77
- response = await self.get_issue_comments_api(owner, repo, issue_number)
122
+ query = GitHubGetPRCommentsQuerySchema(per_page=100)
123
+ response = await self.get_issue_comments_api(owner, repo, issue_number, query)
78
124
  return GitHubGetPRCommentsResponseSchema.model_validate_json(response.text)
79
125
 
80
126
  async def get_review_comments(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRCommentsResponseSchema:
81
- response = await self.get_review_comments_api(owner, repo, pull_number)
127
+ query = GitHubGetPRCommentsQuerySchema(per_page=100)
128
+ response = await self.get_review_comments_api(owner, repo, pull_number, query)
82
129
  return GitHubGetPRCommentsResponseSchema.model_validate_json(response.text)
83
130
 
84
131
  async def get_reviews(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRReviewsResponseSchema:
85
- response = await self.get_reviews_api(owner, repo, pull_number)
132
+ query = GitHubGetPRReviewsQuerySchema(per_page=100)
133
+ response = await self.get_reviews_api(owner, repo, pull_number, query)
86
134
  return GitHubGetPRReviewsResponseSchema.model_validate_json(response.text)
87
135
 
88
136
  async def create_review_comment(
@@ -8,6 +8,10 @@ class GitHubPRCommentSchema(BaseModel):
8
8
  line: int | None = None
9
9
 
10
10
 
11
+ class GitHubGetPRCommentsQuerySchema(BaseModel):
12
+ per_page: int
13
+
14
+
11
15
  class GitHubGetPRCommentsResponseSchema(RootModel[list[GitHubPRCommentSchema]]):
12
16
  root: list[GitHubPRCommentSchema]
13
17
 
@@ -8,5 +8,9 @@ class GitHubPRFileSchema(BaseModel):
8
8
  filename: str
9
9
 
10
10
 
11
+ class GitHubGetPRFilesQuerySchema(BaseModel):
12
+ per_page: int
13
+
14
+
11
15
  class GitHubGetPRFilesResponseSchema(RootModel[list[GitHubPRFileSchema]]):
12
16
  root: list[GitHubPRFileSchema]
@@ -9,5 +9,9 @@ class GitHubPRReviewSchema(BaseModel):
9
9
  state: str
10
10
 
11
11
 
12
+ class GitHubGetPRReviewsQuerySchema(BaseModel):
13
+ per_page: int
14
+
15
+
12
16
  class GitHubGetPRReviewsResponseSchema(RootModel[list[GitHubPRReviewSchema]]):
13
17
  root: list[GitHubPRReviewSchema]
@@ -0,0 +1,49 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.clients.github.pr.schema.comments import (
4
+ GitHubGetPRCommentsResponseSchema,
5
+ GitHubCreateIssueCommentResponseSchema,
6
+ GitHubCreateReviewCommentResponseSchema,
7
+ GitHubCreateReviewCommentRequestSchema,
8
+ )
9
+ from ai_review.clients.github.pr.schema.files import GitHubGetPRFilesResponseSchema
10
+ from ai_review.clients.github.pr.schema.pull_request import GitHubGetPRResponseSchema
11
+ from ai_review.clients.github.pr.schema.reviews import GitHubGetPRReviewsResponseSchema
12
+
13
+
14
+ class GitHubPullRequestsHTTPClientProtocol(Protocol):
15
+ async def get_pull_request(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRResponseSchema: ...
16
+
17
+ async def get_files(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRFilesResponseSchema: ...
18
+
19
+ async def get_issue_comments(
20
+ self,
21
+ owner: str,
22
+ repo: str,
23
+ issue_number: str
24
+ ) -> GitHubGetPRCommentsResponseSchema: ...
25
+
26
+ async def get_review_comments(
27
+ self,
28
+ owner: str,
29
+ repo: str,
30
+ pull_number: str
31
+ ) -> GitHubGetPRCommentsResponseSchema: ...
32
+
33
+ async def get_reviews(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRReviewsResponseSchema: ...
34
+
35
+ async def create_review_comment(
36
+ self,
37
+ owner: str,
38
+ repo: str,
39
+ pull_number: str,
40
+ request: GitHubCreateReviewCommentRequestSchema,
41
+ ) -> GitHubCreateReviewCommentResponseSchema: ...
42
+
43
+ async def create_issue_comment(
44
+ self,
45
+ owner: str,
46
+ repo: str,
47
+ issue_number: str,
48
+ body: str,
49
+ ) -> GitHubCreateIssueCommentResponseSchema: ...
@@ -15,7 +15,7 @@ class GitLabHTTPClient:
15
15
  def get_gitlab_http_client() -> GitLabHTTPClient:
16
16
  logger = get_logger("GITLAB_HTTP_CLIENT")
17
17
  logger_event_hook = LoggerEventHook(logger=logger)
18
- retry_transport = RetryTransport(transport=AsyncHTTPTransport())
18
+ retry_transport = RetryTransport(logger=logger, transport=AsyncHTTPTransport())
19
19
 
20
20
  client = AsyncClient(
21
21
  timeout=settings.llm.http_client.timeout,
@@ -1,16 +1,19 @@
1
- from httpx import Response
1
+ from httpx import Response, QueryParams
2
2
 
3
3
  from ai_review.clients.gitlab.mr.schema.changes import GitLabGetMRChangesResponseSchema
4
4
  from ai_review.clients.gitlab.mr.schema.discussions import (
5
+ GitLabGetMRDiscussionsQuerySchema,
5
6
  GitLabGetMRDiscussionsResponseSchema,
6
7
  GitLabCreateMRDiscussionRequestSchema,
7
8
  GitLabCreateMRDiscussionResponseSchema
8
9
  )
9
10
  from ai_review.clients.gitlab.mr.schema.notes import (
11
+ GitLabGetMRNotesQuerySchema,
10
12
  GitLabGetMRNotesResponseSchema,
11
13
  GitLabCreateMRNoteRequestSchema,
12
14
  GitLabCreateMRNoteResponseSchema,
13
15
  )
16
+ from ai_review.clients.gitlab.mr.types import GitLabMergeRequestsHTTPClientProtocol
14
17
  from ai_review.libs.http.client import HTTPClient
15
18
  from ai_review.libs.http.handlers import handle_http_error, HTTPClientError
16
19
 
@@ -19,7 +22,7 @@ class GitLabMergeRequestsHTTPClientError(HTTPClientError):
19
22
  pass
20
23
 
21
24
 
22
- class GitLabMergeRequestsHTTPClient(HTTPClient):
25
+ class GitLabMergeRequestsHTTPClient(HTTPClient, GitLabMergeRequestsHTTPClientProtocol):
23
26
  @handle_http_error(client="GitLabMergeRequestsHTTPClient", exception=GitLabMergeRequestsHTTPClientError)
24
27
  async def get_changes_api(self, project_id: str, merge_request_id: str) -> Response:
25
28
  return await self.get(
@@ -27,15 +30,27 @@ class GitLabMergeRequestsHTTPClient(HTTPClient):
27
30
  )
28
31
 
29
32
  @handle_http_error(client="GitLabMergeRequestsHTTPClient", exception=GitLabMergeRequestsHTTPClientError)
30
- async def get_notes_api(self, project_id: str, merge_request_id: str) -> Response:
33
+ async def get_notes_api(
34
+ self,
35
+ project_id: str,
36
+ merge_request_id: str,
37
+ query: GitLabGetMRNotesQuerySchema
38
+ ) -> Response:
31
39
  return await self.get(
32
- f"/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/notes"
40
+ f"/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/notes",
41
+ query=QueryParams(**query.model_dump())
33
42
  )
34
43
 
35
44
  @handle_http_error(client="GitLabMergeRequestsHTTPClient", exception=GitLabMergeRequestsHTTPClientError)
36
- async def get_discussions_api(self, project_id: str, merge_request_id: str) -> Response:
45
+ async def get_discussions_api(
46
+ self,
47
+ project_id: str,
48
+ merge_request_id: str,
49
+ query: GitLabGetMRDiscussionsQuerySchema
50
+ ) -> Response:
37
51
  return await self.get(
38
- f"/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/discussions"
52
+ f"/api/v4/projects/{project_id}/merge_requests/{merge_request_id}/discussions",
53
+ query=QueryParams(**query.model_dump())
39
54
  )
40
55
 
41
56
  @handle_http_error(client="GitLabMergeRequestsHTTPClient", exception=GitLabMergeRequestsHTTPClientError)
@@ -71,7 +86,8 @@ class GitLabMergeRequestsHTTPClient(HTTPClient):
71
86
  project_id: str,
72
87
  merge_request_id: str
73
88
  ) -> GitLabGetMRNotesResponseSchema:
74
- response = await self.get_notes_api(project_id, merge_request_id)
89
+ query = GitLabGetMRNotesQuerySchema(per_page=100)
90
+ response = await self.get_notes_api(project_id, merge_request_id, query)
75
91
  return GitLabGetMRNotesResponseSchema.model_validate_json(response.text)
76
92
 
77
93
  async def get_discussions(
@@ -79,7 +95,8 @@ class GitLabMergeRequestsHTTPClient(HTTPClient):
79
95
  project_id: str,
80
96
  merge_request_id: str
81
97
  ) -> GitLabGetMRDiscussionsResponseSchema:
82
- response = await self.get_discussions_api(project_id, merge_request_id)
98
+ query = GitLabGetMRDiscussionsQuerySchema(per_page=100)
99
+ response = await self.get_discussions_api(project_id, merge_request_id, query)
83
100
  return GitLabGetMRDiscussionsResponseSchema.model_validate_json(response.text)
84
101
 
85
102
  async def create_note(
@@ -17,6 +17,10 @@ class GitLabDiscussionPositionSchema(BaseModel):
17
17
  new_line: int
18
18
 
19
19
 
20
+ class GitLabGetMRDiscussionsQuerySchema(BaseModel):
21
+ per_page: int
22
+
23
+
20
24
  class GitLabGetMRDiscussionsResponseSchema(RootModel[list[GitLabDiscussionSchema]]):
21
25
  root: list[GitLabDiscussionSchema]
22
26
 
@@ -6,6 +6,10 @@ class GitLabNoteSchema(BaseModel):
6
6
  body: str
7
7
 
8
8
 
9
+ class GitLabGetMRNotesQuerySchema(BaseModel):
10
+ per_page: int
11
+
12
+
9
13
  class GitLabGetMRNotesResponseSchema(RootModel[list[GitLabNoteSchema]]):
10
14
  root: list[GitLabNoteSchema]
11
15
 
@@ -0,0 +1,35 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.clients.gitlab.mr.schema.changes import GitLabGetMRChangesResponseSchema
4
+ from ai_review.clients.gitlab.mr.schema.discussions import (
5
+ GitLabGetMRDiscussionsResponseSchema,
6
+ GitLabCreateMRDiscussionResponseSchema,
7
+ GitLabCreateMRDiscussionRequestSchema,
8
+ )
9
+ from ai_review.clients.gitlab.mr.schema.notes import GitLabGetMRNotesResponseSchema, GitLabCreateMRNoteResponseSchema
10
+
11
+
12
+ class GitLabMergeRequestsHTTPClientProtocol(Protocol):
13
+ async def get_changes(self, project_id: str, merge_request_id: str) -> GitLabGetMRChangesResponseSchema: ...
14
+
15
+ async def get_notes(self, project_id: str, merge_request_id: str) -> GitLabGetMRNotesResponseSchema: ...
16
+
17
+ async def get_discussions(
18
+ self,
19
+ project_id: str,
20
+ merge_request_id: str
21
+ ) -> GitLabGetMRDiscussionsResponseSchema: ...
22
+
23
+ async def create_note(
24
+ self,
25
+ body: str,
26
+ project_id: str,
27
+ merge_request_id: str,
28
+ ) -> GitLabCreateMRNoteResponseSchema: ...
29
+
30
+ async def create_discussion(
31
+ self,
32
+ project_id: str,
33
+ merge_request_id: str,
34
+ request: GitLabCreateMRDiscussionRequestSchema,
35
+ ) -> GitLabCreateMRDiscussionResponseSchema: ...
@@ -26,7 +26,7 @@ class OpenAIHTTPClient(HTTPClient):
26
26
  def get_openai_http_client() -> OpenAIHTTPClient:
27
27
  logger = get_logger("OPENAI_HTTP_CLIENT")
28
28
  logger_event_hook = LoggerEventHook(logger=logger)
29
- retry_transport = RetryTransport(transport=AsyncHTTPTransport())
29
+ retry_transport = RetryTransport(logger=logger, transport=AsyncHTTPTransport())
30
30
 
31
31
  client = AsyncClient(
32
32
  timeout=settings.llm.http_client.timeout,
ai_review/config.py CHANGED
@@ -12,6 +12,7 @@ from ai_review.libs.config.base import (
12
12
  get_yaml_config_file_or_default,
13
13
  get_json_config_file_or_default
14
14
  )
15
+ from ai_review.libs.config.core import CoreConfig
15
16
  from ai_review.libs.config.llm import LLMConfig
16
17
  from ai_review.libs.config.logger import LoggerConfig
17
18
  from ai_review.libs.config.prompt import PromptConfig
@@ -36,6 +37,7 @@ class Settings(BaseSettings):
36
37
 
37
38
  llm: LLMConfig
38
39
  vcs: VCSConfig
40
+ core: CoreConfig = CoreConfig()
39
41
  prompt: PromptConfig = PromptConfig()
40
42
  review: ReviewConfig = ReviewConfig()
41
43
  logger: LoggerConfig = LoggerConfig()
@@ -1,14 +1,17 @@
1
1
  import asyncio
2
2
  from typing import Awaitable, Iterable, TypeVar
3
3
 
4
+ from ai_review.config import settings
5
+
4
6
  T = TypeVar("T")
5
7
 
6
8
 
7
- async def bounded_gather(coroutines: Iterable[Awaitable[T]], concurrency: int = 32) -> tuple[T, ...]:
8
- sem = asyncio.Semaphore(concurrency)
9
+ async def bounded_gather(coroutines: Iterable[Awaitable[T]]) -> tuple[T, ...]:
10
+ sem = asyncio.Semaphore(settings.core.concurrency)
9
11
 
10
12
  async def wrap(coro: Awaitable[T]) -> T:
11
13
  async with sem:
12
14
  return await coro
13
15
 
14
- return await asyncio.gather(*(wrap(coroutine) for coroutine in coroutines), return_exceptions=True)
16
+ results = await asyncio.gather(*(wrap(coroutine) for coroutine in coroutines), return_exceptions=True)
17
+ return tuple(results)
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class CoreConfig(BaseModel):
5
+ concurrency: int = 7
@@ -1,12 +1,15 @@
1
- from logging import Logger
1
+ from typing import TYPE_CHECKING
2
2
 
3
3
  from httpx import Request, Response
4
4
 
5
5
  from ai_review.libs.http.event_hooks.base import BaseEventHook
6
6
 
7
+ if TYPE_CHECKING:
8
+ from loguru import Logger
9
+
7
10
 
8
11
  class LoggerEventHook(BaseEventHook):
9
- def __init__(self, logger: Logger):
12
+ def __init__(self, logger: "Logger"):
10
13
  self.logger = logger
11
14
 
12
15
  async def request(self, request: Request):
@@ -1,12 +1,17 @@
1
1
  import asyncio
2
2
  from http import HTTPStatus
3
+ from typing import TYPE_CHECKING
3
4
 
4
5
  from httpx import Request, Response, AsyncBaseTransport
5
6
 
7
+ if TYPE_CHECKING:
8
+ from loguru import Logger
9
+
6
10
 
7
11
  class RetryTransport(AsyncBaseTransport):
8
12
  def __init__(
9
13
  self,
14
+ logger: "Logger",
10
15
  transport: AsyncBaseTransport,
11
16
  max_retries: int = 5,
12
17
  retry_delay: float = 0.5,
@@ -17,18 +22,30 @@ class RetryTransport(AsyncBaseTransport):
17
22
  HTTPStatus.INTERNAL_SERVER_ERROR,
18
23
  )
19
24
  ):
25
+ self.logger = logger
20
26
  self.transport = transport
21
27
  self.max_retries = max_retries
22
28
  self.retry_delay = retry_delay
23
29
  self.retry_status_codes = retry_status_codes
24
30
 
25
31
  async def handle_async_request(self, request: Request) -> Response:
26
- response: Response | None = None
27
- for _ in range(self.max_retries):
28
- response = await self.transport.handle_async_request(request)
29
- if response.status_code not in self.retry_status_codes:
30
- return response
32
+ last_response: Response | None = None
33
+ for attempt in range(self.max_retries):
34
+ last_response = await self.transport.handle_async_request(request)
35
+ if last_response.status_code not in self.retry_status_codes:
36
+ return last_response
37
+
38
+ self.logger.warning(
39
+ f"Attempt {attempt}/{self.max_retries} failed "
40
+ f"with status={last_response.status_code} for {request.method} {request.url}. "
41
+ f"Retrying in {self.retry_delay:.1f}s..."
42
+ )
31
43
 
32
44
  await asyncio.sleep(self.retry_delay)
33
45
 
34
- return response
46
+ self.logger.error(
47
+ f"All {self.max_retries} attempts failed for "
48
+ f"{request.method} {request.url} (last status={last_response.status_code})"
49
+ )
50
+
51
+ return last_response
@@ -116,14 +116,14 @@ def render_unified(
116
116
  added_new_positions = file.added_line_numbers()
117
117
  removed_old_positions = file.removed_line_numbers()
118
118
 
119
- def in_context(old_no: int | None, new_no: int | None) -> bool:
119
+ def in_context(inner_old_no: int | None, inner_new_no: int | None) -> bool:
120
120
  """Check if an unchanged line falls within context radius."""
121
121
  if context <= 0:
122
122
  return False
123
- if include_added and new_no is not None:
123
+ if include_added and inner_new_no is not None:
124
124
  if any(abs(new_no - a) <= context for a in added_new_positions):
125
125
  return True
126
- if include_removed and old_no is not None:
126
+ if include_removed and inner_old_no is not None:
127
127
  if any(abs(old_no - r) <= context for r in removed_old_positions):
128
128
  return True
129
129
  return False
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel, Field
1
+ from pydantic import BaseModel, Field, field_serializer
2
2
 
3
3
  from ai_review.config import settings
4
4
  from ai_review.libs.template.render import render_template
@@ -24,29 +24,18 @@ class PromptContextSchema(BaseModel):
24
24
  labels: list[str] = Field(default_factory=list)
25
25
  changed_files: list[str] = Field(default_factory=list)
26
26
 
27
- @property
28
- def render_values(self) -> dict[str, str]:
29
- return {
30
- "review_title": self.review_title,
31
- "review_description": self.review_description,
32
-
33
- "review_author_name": self.review_author_name,
34
- "review_author_username": self.review_author_username,
35
-
36
- "review_reviewer": self.review_reviewer,
37
- "review_reviewers": ", ".join(self.review_reviewers),
38
- "review_reviewers_usernames": ", ".join(self.review_reviewers_usernames),
39
-
40
- "review_assignees": ", ".join(self.review_assignees),
41
- "review_assignees_usernames": ", ".join(self.review_assignees_usernames),
42
-
43
- "source_branch": self.source_branch,
44
- "target_branch": self.target_branch,
45
-
46
- "labels": ", ".join(self.labels),
47
- "changed_files": ", ".join(self.changed_files),
48
- }
27
+ @field_serializer(
28
+ "review_reviewers",
29
+ "review_reviewers_usernames",
30
+ "review_assignees",
31
+ "review_assignees_usernames",
32
+ "labels",
33
+ "changed_files",
34
+ when_used="always"
35
+ )
36
+ def list_of_strings_serializer(self, value: list[str]) -> str:
37
+ return ", ".join(value)
49
38
 
50
39
  def apply_format(self, prompt: str) -> str:
51
- values = {**self.render_values, **settings.prompt.context}
40
+ values = {**self.model_dump(), **settings.prompt.context}
52
41
  return render_template(prompt, values, settings.prompt.context_placeholder)
@@ -0,0 +1,22 @@
1
+ import pytest
2
+ from pydantic import HttpUrl, SecretStr
3
+
4
+ from ai_review.config import settings
5
+ from ai_review.libs.config.claude import ClaudeMetaConfig, ClaudeHTTPClientConfig
6
+ from ai_review.libs.config.llm import ClaudeLLMConfig
7
+ from ai_review.libs.constants.llm_provider import LLMProvider
8
+
9
+
10
+ @pytest.fixture
11
+ def claude_http_client_config(monkeypatch: pytest.MonkeyPatch):
12
+ fake_config = ClaudeLLMConfig(
13
+ meta=ClaudeMetaConfig(),
14
+ provider=LLMProvider.CLAUDE,
15
+ http_client=ClaudeHTTPClientConfig(
16
+ timeout=10,
17
+ api_url=HttpUrl("https://api.anthropic.com"),
18
+ api_token=SecretStr("fake-token"),
19
+ api_version="2023-06-01",
20
+ )
21
+ )
22
+ monkeypatch.setattr(settings, "llm", fake_config)
@@ -0,0 +1,21 @@
1
+ import pytest
2
+ from pydantic import HttpUrl, SecretStr
3
+
4
+ from ai_review.config import settings
5
+ from ai_review.libs.config.gemini import GeminiMetaConfig, GeminiHTTPClientConfig
6
+ from ai_review.libs.config.llm import GeminiLLMConfig
7
+ from ai_review.libs.constants.llm_provider import LLMProvider
8
+
9
+
10
+ @pytest.fixture
11
+ def gemini_http_client_config(monkeypatch: pytest.MonkeyPatch):
12
+ fake_config = GeminiLLMConfig(
13
+ meta=GeminiMetaConfig(),
14
+ provider=LLMProvider.GEMINI,
15
+ http_client=GeminiHTTPClientConfig(
16
+ timeout=10,
17
+ api_url=HttpUrl("https://generativelanguage.googleapis.com"),
18
+ api_token=SecretStr("fake-token"),
19
+ )
20
+ )
21
+ monkeypatch.setattr(settings, "llm", fake_config)