xai-review 0.20.0__py3-none-any.whl → 0.22.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 (95) 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/artifacts/service.py +2 -1
  21. ai_review/services/artifacts/types.py +20 -0
  22. ai_review/services/cost/service.py +2 -1
  23. ai_review/services/cost/types.py +12 -0
  24. ai_review/services/diff/service.py +2 -1
  25. ai_review/services/diff/types.py +28 -0
  26. ai_review/services/hook/__init__.py +5 -0
  27. ai_review/services/hook/constants.py +24 -0
  28. ai_review/services/hook/service.py +162 -0
  29. ai_review/services/hook/types.py +28 -0
  30. ai_review/services/llm/claude/client.py +2 -2
  31. ai_review/services/llm/factory.py +2 -2
  32. ai_review/services/llm/gemini/client.py +2 -2
  33. ai_review/services/llm/openai/client.py +2 -2
  34. ai_review/services/llm/types.py +1 -1
  35. ai_review/services/prompt/service.py +2 -1
  36. ai_review/services/prompt/types.py +27 -0
  37. ai_review/services/review/gateway/__init__.py +0 -0
  38. ai_review/services/review/gateway/comment.py +65 -0
  39. ai_review/services/review/gateway/llm.py +40 -0
  40. ai_review/services/review/inline/schema.py +2 -2
  41. ai_review/services/review/inline/service.py +2 -1
  42. ai_review/services/review/inline/types.py +11 -0
  43. ai_review/services/review/service.py +23 -74
  44. ai_review/services/review/summary/service.py +2 -1
  45. ai_review/services/review/summary/types.py +8 -0
  46. ai_review/services/vcs/factory.py +2 -2
  47. ai_review/services/vcs/github/client.py +4 -2
  48. ai_review/services/vcs/gitlab/client.py +4 -2
  49. ai_review/services/vcs/types.py +1 -1
  50. ai_review/tests/fixtures/clients/__init__.py +0 -0
  51. ai_review/tests/fixtures/clients/claude.py +22 -0
  52. ai_review/tests/fixtures/clients/gemini.py +21 -0
  53. ai_review/tests/fixtures/clients/github.py +181 -0
  54. ai_review/tests/fixtures/clients/gitlab.py +150 -0
  55. ai_review/tests/fixtures/clients/openai.py +21 -0
  56. ai_review/tests/fixtures/services/__init__.py +0 -0
  57. ai_review/tests/fixtures/services/artifacts.py +51 -0
  58. ai_review/tests/fixtures/services/cost.py +48 -0
  59. ai_review/tests/fixtures/services/diff.py +46 -0
  60. ai_review/tests/fixtures/{git.py → services/git.py} +11 -5
  61. ai_review/tests/fixtures/services/llm.py +26 -0
  62. ai_review/tests/fixtures/services/prompt.py +43 -0
  63. ai_review/tests/fixtures/services/review/__init__.py +0 -0
  64. ai_review/tests/fixtures/services/review/inline.py +25 -0
  65. ai_review/tests/fixtures/services/review/summary.py +19 -0
  66. ai_review/tests/fixtures/services/vcs.py +49 -0
  67. ai_review/tests/suites/clients/claude/test_client.py +1 -20
  68. ai_review/tests/suites/clients/gemini/test_client.py +1 -19
  69. ai_review/tests/suites/clients/github/test_client.py +1 -23
  70. ai_review/tests/suites/clients/gitlab/test_client.py +1 -22
  71. ai_review/tests/suites/clients/openai/test_client.py +1 -19
  72. ai_review/tests/suites/libs/asynchronous/__init__.py +0 -0
  73. ai_review/tests/suites/libs/asynchronous/test_gather.py +46 -0
  74. ai_review/tests/suites/services/diff/test_service.py +4 -4
  75. ai_review/tests/suites/services/diff/test_tools.py +10 -10
  76. ai_review/tests/suites/services/hook/__init__.py +0 -0
  77. ai_review/tests/suites/services/hook/test_service.py +93 -0
  78. ai_review/tests/suites/services/llm/__init__.py +0 -0
  79. ai_review/tests/suites/services/llm/test_factory.py +30 -0
  80. ai_review/tests/suites/services/review/inline/test_schema.py +10 -9
  81. ai_review/tests/suites/services/review/summary/test_schema.py +0 -1
  82. ai_review/tests/suites/services/review/summary/test_service.py +10 -7
  83. ai_review/tests/suites/services/review/test_service.py +126 -0
  84. ai_review/tests/suites/services/vcs/__init__.py +0 -0
  85. ai_review/tests/suites/services/vcs/github/__init__.py +0 -0
  86. ai_review/tests/suites/services/vcs/github/test_service.py +114 -0
  87. ai_review/tests/suites/services/vcs/gitlab/__init__.py +0 -0
  88. ai_review/tests/suites/services/vcs/gitlab/test_service.py +123 -0
  89. ai_review/tests/suites/services/vcs/test_factory.py +23 -0
  90. {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/METADATA +5 -2
  91. {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/RECORD +95 -50
  92. {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/WHEEL +0 -0
  93. {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/entry_points.txt +0 -0
  94. {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/licenses/LICENSE +0 -0
  95. {xai_review-0.20.0.dist-info → xai_review-0.22.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
@@ -6,11 +6,12 @@ from ai_review.config import settings
6
6
  from ai_review.libs.logger import get_logger
7
7
  from ai_review.services.artifacts.schema import LLMArtifactSchema
8
8
  from ai_review.services.artifacts.tools import make_artifact_id
9
+ from ai_review.services.artifacts.types import ArtifactsServiceProtocol
9
10
 
10
11
  logger = get_logger("ARTIFACTS_SERVICE")
11
12
 
12
13
 
13
- class ArtifactsService:
14
+ class ArtifactsService(ArtifactsServiceProtocol):
14
15
  @classmethod
15
16
  async def save_llm_interaction(cls, prompt: str, prompt_system: str, response: str | None = None) -> str | None:
16
17
  if not settings.artifacts.llm_enabled:
@@ -0,0 +1,20 @@
1
+ from pathlib import Path
2
+ from typing import Protocol
3
+
4
+
5
+ class ArtifactsServiceProtocol(Protocol):
6
+ async def save_llm_interaction(
7
+ self,
8
+ prompt: str,
9
+ prompt_system: str,
10
+ response: str | None = None
11
+ ) -> str | None:
12
+ ...
13
+
14
+ async def save_artifact(
15
+ self,
16
+ file: Path,
17
+ content: str,
18
+ kind: str = "artifact"
19
+ ) -> Path | None:
20
+ ...
@@ -1,12 +1,13 @@
1
1
  from ai_review.config import settings
2
2
  from ai_review.libs.logger import get_logger
3
3
  from ai_review.services.cost.schema import CostReportSchema
4
+ from ai_review.services.cost.types import CostServiceProtocol
4
5
  from ai_review.services.llm.types import ChatResultSchema
5
6
 
6
7
  logger = get_logger("COST_SERVICE")
7
8
 
8
9
 
9
- class CostService:
10
+ class CostService(CostServiceProtocol):
10
11
  def __init__(self):
11
12
  self.pricing = settings.llm.load_pricing()
12
13
  self.reports: list[CostReportSchema] = []
@@ -0,0 +1,12 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.services.cost.schema import CostReportSchema
4
+ from ai_review.services.llm.types import ChatResultSchema
5
+
6
+
7
+ class CostServiceProtocol(Protocol):
8
+ def calculate(self, result: ChatResultSchema) -> CostReportSchema | None:
9
+ ...
10
+
11
+ def aggregate(self) -> CostReportSchema | None:
12
+ ...
@@ -16,12 +16,13 @@ from ai_review.services.diff.renderers import (
16
16
  )
17
17
  from ai_review.services.diff.schema import DiffFileSchema
18
18
  from ai_review.services.diff.tools import find_diff_file
19
+ from ai_review.services.diff.types import DiffServiceProtocol
19
20
  from ai_review.services.git.types import GitServiceProtocol
20
21
 
21
22
  logger = get_logger("DIFF_SERVICE")
22
23
 
23
24
 
24
- class DiffService:
25
+ class DiffService(DiffServiceProtocol):
25
26
  @classmethod
26
27
  def parse(cls, raw_diff: str) -> Diff:
27
28
  if not raw_diff.strip():
@@ -0,0 +1,28 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.libs.diff.models import Diff
4
+ from ai_review.services.diff.schema import DiffFileSchema
5
+ from ai_review.services.git.types import GitServiceProtocol
6
+
7
+
8
+ class DiffServiceProtocol(Protocol):
9
+ def parse(self, raw_diff: str) -> Diff:
10
+ ...
11
+
12
+ def render_file(
13
+ self,
14
+ file: str,
15
+ raw_diff: str,
16
+ base_sha: str | None = None,
17
+ head_sha: str | None = None,
18
+ ) -> DiffFileSchema:
19
+ ...
20
+
21
+ def render_files(
22
+ self,
23
+ git: GitServiceProtocol,
24
+ files: list[str],
25
+ base_sha: str,
26
+ head_sha: str,
27
+ ) -> list[DiffFileSchema]:
28
+ ...
@@ -0,0 +1,5 @@
1
+ from ai_review.services.hook.service import HookService
2
+
3
+ hook = HookService()
4
+
5
+ __all__ = ["hook"]