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.
- ai_review/clients/claude/client.py +1 -1
- ai_review/clients/gemini/client.py +1 -1
- ai_review/clients/github/client.py +1 -1
- ai_review/clients/github/pr/client.py +64 -16
- ai_review/clients/github/pr/schema/comments.py +4 -0
- ai_review/clients/github/pr/schema/files.py +4 -0
- ai_review/clients/github/pr/schema/reviews.py +4 -0
- ai_review/clients/github/pr/types.py +49 -0
- ai_review/clients/gitlab/client.py +1 -1
- ai_review/clients/gitlab/mr/client.py +25 -8
- ai_review/clients/gitlab/mr/schema/discussions.py +4 -0
- ai_review/clients/gitlab/mr/schema/notes.py +4 -0
- ai_review/clients/gitlab/mr/types.py +35 -0
- ai_review/clients/openai/client.py +1 -1
- ai_review/config.py +2 -0
- ai_review/libs/asynchronous/gather.py +6 -3
- ai_review/libs/config/core.py +5 -0
- ai_review/libs/http/event_hooks/logger.py +5 -2
- ai_review/libs/http/transports/retry.py +23 -6
- ai_review/services/artifacts/service.py +2 -1
- ai_review/services/artifacts/types.py +20 -0
- ai_review/services/cost/service.py +2 -1
- ai_review/services/cost/types.py +12 -0
- ai_review/services/diff/service.py +2 -1
- ai_review/services/diff/types.py +28 -0
- ai_review/services/hook/__init__.py +5 -0
- ai_review/services/hook/constants.py +24 -0
- ai_review/services/hook/service.py +162 -0
- ai_review/services/hook/types.py +28 -0
- ai_review/services/llm/claude/client.py +2 -2
- ai_review/services/llm/factory.py +2 -2
- ai_review/services/llm/gemini/client.py +2 -2
- ai_review/services/llm/openai/client.py +2 -2
- ai_review/services/llm/types.py +1 -1
- ai_review/services/prompt/service.py +2 -1
- ai_review/services/prompt/types.py +27 -0
- ai_review/services/review/gateway/__init__.py +0 -0
- ai_review/services/review/gateway/comment.py +65 -0
- ai_review/services/review/gateway/llm.py +40 -0
- ai_review/services/review/inline/schema.py +2 -2
- ai_review/services/review/inline/service.py +2 -1
- ai_review/services/review/inline/types.py +11 -0
- ai_review/services/review/service.py +23 -74
- ai_review/services/review/summary/service.py +2 -1
- ai_review/services/review/summary/types.py +8 -0
- ai_review/services/vcs/factory.py +2 -2
- ai_review/services/vcs/github/client.py +4 -2
- ai_review/services/vcs/gitlab/client.py +4 -2
- ai_review/services/vcs/types.py +1 -1
- ai_review/tests/fixtures/clients/__init__.py +0 -0
- ai_review/tests/fixtures/clients/claude.py +22 -0
- ai_review/tests/fixtures/clients/gemini.py +21 -0
- ai_review/tests/fixtures/clients/github.py +181 -0
- ai_review/tests/fixtures/clients/gitlab.py +150 -0
- ai_review/tests/fixtures/clients/openai.py +21 -0
- ai_review/tests/fixtures/services/__init__.py +0 -0
- ai_review/tests/fixtures/services/artifacts.py +51 -0
- ai_review/tests/fixtures/services/cost.py +48 -0
- ai_review/tests/fixtures/services/diff.py +46 -0
- ai_review/tests/fixtures/{git.py → services/git.py} +11 -5
- ai_review/tests/fixtures/services/llm.py +26 -0
- ai_review/tests/fixtures/services/prompt.py +43 -0
- ai_review/tests/fixtures/services/review/__init__.py +0 -0
- ai_review/tests/fixtures/services/review/inline.py +25 -0
- ai_review/tests/fixtures/services/review/summary.py +19 -0
- ai_review/tests/fixtures/services/vcs.py +49 -0
- ai_review/tests/suites/clients/claude/test_client.py +1 -20
- ai_review/tests/suites/clients/gemini/test_client.py +1 -19
- ai_review/tests/suites/clients/github/test_client.py +1 -23
- ai_review/tests/suites/clients/gitlab/test_client.py +1 -22
- ai_review/tests/suites/clients/openai/test_client.py +1 -19
- ai_review/tests/suites/libs/asynchronous/__init__.py +0 -0
- ai_review/tests/suites/libs/asynchronous/test_gather.py +46 -0
- ai_review/tests/suites/services/diff/test_service.py +4 -4
- ai_review/tests/suites/services/diff/test_tools.py +10 -10
- ai_review/tests/suites/services/hook/__init__.py +0 -0
- ai_review/tests/suites/services/hook/test_service.py +93 -0
- ai_review/tests/suites/services/llm/__init__.py +0 -0
- ai_review/tests/suites/services/llm/test_factory.py +30 -0
- ai_review/tests/suites/services/review/inline/test_schema.py +10 -9
- ai_review/tests/suites/services/review/summary/test_schema.py +0 -1
- ai_review/tests/suites/services/review/summary/test_service.py +10 -7
- ai_review/tests/suites/services/review/test_service.py +126 -0
- ai_review/tests/suites/services/vcs/__init__.py +0 -0
- ai_review/tests/suites/services/vcs/github/__init__.py +0 -0
- ai_review/tests/suites/services/vcs/github/test_service.py +114 -0
- ai_review/tests/suites/services/vcs/gitlab/__init__.py +0 -0
- ai_review/tests/suites/services/vcs/gitlab/test_service.py +123 -0
- ai_review/tests/suites/services/vcs/test_factory.py +23 -0
- {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/METADATA +5 -2
- {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/RECORD +95 -50
- {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/WHEEL +0 -0
- {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/entry_points.txt +0 -0
- {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {xai_review-0.20.0.dist-info → xai_review-0.22.0.dist-info}/top_level.txt +0 -0
|
@@ -2,10 +2,10 @@ from ai_review.config import settings
|
|
|
2
2
|
from ai_review.libs.constants.vcs_provider import VCSProvider
|
|
3
3
|
from ai_review.services.vcs.github.client import GitHubVCSClient
|
|
4
4
|
from ai_review.services.vcs.gitlab.client import GitLabVCSClient
|
|
5
|
-
from ai_review.services.vcs.types import
|
|
5
|
+
from ai_review.services.vcs.types import VCSClientProtocol
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def get_vcs_client() ->
|
|
8
|
+
def get_vcs_client() -> VCSClientProtocol:
|
|
9
9
|
match settings.vcs.provider:
|
|
10
10
|
case VCSProvider.GITLAB:
|
|
11
11
|
return GitLabVCSClient()
|
|
@@ -3,7 +3,7 @@ from ai_review.clients.github.pr.schema.comments import GitHubCreateReviewCommen
|
|
|
3
3
|
from ai_review.config import settings
|
|
4
4
|
from ai_review.libs.logger import get_logger
|
|
5
5
|
from ai_review.services.vcs.types import (
|
|
6
|
-
|
|
6
|
+
VCSClientProtocol,
|
|
7
7
|
UserSchema,
|
|
8
8
|
BranchRefSchema,
|
|
9
9
|
ReviewInfoSchema,
|
|
@@ -13,7 +13,7 @@ from ai_review.services.vcs.types import (
|
|
|
13
13
|
logger = get_logger("GITHUB_VCS_CLIENT")
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class GitHubVCSClient(
|
|
16
|
+
class GitHubVCSClient(VCSClientProtocol):
|
|
17
17
|
def __init__(self):
|
|
18
18
|
self.http_client = get_github_http_client()
|
|
19
19
|
self.owner = settings.vcs.pipeline.owner
|
|
@@ -135,6 +135,7 @@ class GitHubVCSClient(VCSClient):
|
|
|
135
135
|
logger.exception(
|
|
136
136
|
f"Failed to create general comment in PR {self.owner}/{self.repo}#{self.pull_number}: {error}"
|
|
137
137
|
)
|
|
138
|
+
raise
|
|
138
139
|
|
|
139
140
|
async def create_inline_comment(self, file: str, line: int, message: str) -> None:
|
|
140
141
|
try:
|
|
@@ -167,3 +168,4 @@ class GitHubVCSClient(VCSClient):
|
|
|
167
168
|
f"Failed to create inline comment in {self.owner}/{self.repo}#{self.pull_number} "
|
|
168
169
|
f"at {file}:{line}: {error}"
|
|
169
170
|
)
|
|
171
|
+
raise
|
|
@@ -6,7 +6,7 @@ from ai_review.clients.gitlab.mr.schema.discussions import (
|
|
|
6
6
|
from ai_review.config import settings
|
|
7
7
|
from ai_review.libs.logger import get_logger
|
|
8
8
|
from ai_review.services.vcs.types import (
|
|
9
|
-
|
|
9
|
+
VCSClientProtocol,
|
|
10
10
|
UserSchema,
|
|
11
11
|
BranchRefSchema,
|
|
12
12
|
ReviewInfoSchema,
|
|
@@ -16,7 +16,7 @@ from ai_review.services.vcs.types import (
|
|
|
16
16
|
logger = get_logger("GITLAB_VCS_CLIENT")
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class GitLabVCSClient(
|
|
19
|
+
class GitLabVCSClient(VCSClientProtocol):
|
|
20
20
|
def __init__(self):
|
|
21
21
|
self.http_client = get_gitlab_http_client()
|
|
22
22
|
self.project_id = settings.vcs.pipeline.project_id
|
|
@@ -132,6 +132,7 @@ class GitLabVCSClient(VCSClient):
|
|
|
132
132
|
logger.exception(
|
|
133
133
|
f"Failed to create general comment in merge_request_id={self.merge_request_id}: {error}"
|
|
134
134
|
)
|
|
135
|
+
raise
|
|
135
136
|
|
|
136
137
|
async def create_inline_comment(self, file: str, line: int, message: str) -> None:
|
|
137
138
|
try:
|
|
@@ -168,3 +169,4 @@ class GitLabVCSClient(VCSClient):
|
|
|
168
169
|
f"Failed to create inline comment in merge_request_id={self.merge_request_id} "
|
|
169
170
|
f"at {file}:{line}: {error}"
|
|
170
171
|
)
|
|
172
|
+
raise
|
ai_review/services/vcs/types.py
CHANGED
|
@@ -42,7 +42,7 @@ class ReviewThreadSchema(BaseModel):
|
|
|
42
42
|
comments: list[ReviewCommentSchema]
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
class
|
|
45
|
+
class VCSClientProtocol(Protocol):
|
|
46
46
|
"""
|
|
47
47
|
Unified interface for version control system integrations (GitHub, GitLab, Bitbucket, etc.).
|
|
48
48
|
Designed for code review automation: fetching review info, comments, and posting feedback.
|
|
File without changes
|
|
@@ -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)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import HttpUrl, SecretStr
|
|
3
|
+
|
|
4
|
+
from ai_review.clients.github.pr.schema.comments import (
|
|
5
|
+
GitHubPRCommentSchema,
|
|
6
|
+
GitHubGetPRCommentsResponseSchema,
|
|
7
|
+
GitHubCreateIssueCommentResponseSchema,
|
|
8
|
+
GitHubCreateReviewCommentRequestSchema,
|
|
9
|
+
GitHubCreateReviewCommentResponseSchema,
|
|
10
|
+
)
|
|
11
|
+
from ai_review.clients.github.pr.schema.files import GitHubGetPRFilesResponseSchema, GitHubPRFileSchema
|
|
12
|
+
from ai_review.clients.github.pr.schema.pull_request import (
|
|
13
|
+
GitHubUserSchema,
|
|
14
|
+
GitHubLabelSchema,
|
|
15
|
+
GitHubBranchSchema,
|
|
16
|
+
GitHubGetPRResponseSchema,
|
|
17
|
+
)
|
|
18
|
+
from ai_review.clients.github.pr.schema.reviews import GitHubGetPRReviewsResponseSchema, GitHubPRReviewSchema
|
|
19
|
+
from ai_review.clients.github.pr.types import GitHubPullRequestsHTTPClientProtocol
|
|
20
|
+
from ai_review.config import settings
|
|
21
|
+
from ai_review.libs.config.github import GitHubPipelineConfig, GitHubHTTPClientConfig
|
|
22
|
+
from ai_review.libs.config.vcs import GitHubVCSConfig
|
|
23
|
+
from ai_review.libs.constants.vcs_provider import VCSProvider
|
|
24
|
+
from ai_review.services.vcs.github.client import GitHubVCSClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeGitHubPullRequestsHTTPClient(GitHubPullRequestsHTTPClientProtocol):
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.calls: list[tuple[str, dict]] = []
|
|
30
|
+
|
|
31
|
+
async def get_pull_request(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRResponseSchema:
|
|
32
|
+
self.calls.append(("get_pull_request", {"owner": owner, "repo": repo, "pull_number": pull_number}))
|
|
33
|
+
|
|
34
|
+
return GitHubGetPRResponseSchema(
|
|
35
|
+
id=1,
|
|
36
|
+
number=1,
|
|
37
|
+
title="Fake Pull Request",
|
|
38
|
+
body="This is a fake PR for testing",
|
|
39
|
+
user=GitHubUserSchema(id=101, login="tester"),
|
|
40
|
+
labels=[
|
|
41
|
+
GitHubLabelSchema(id=1, name="bugfix"),
|
|
42
|
+
GitHubLabelSchema(id=2, name="backend"),
|
|
43
|
+
],
|
|
44
|
+
assignees=[
|
|
45
|
+
GitHubUserSchema(id=102, login="dev1"),
|
|
46
|
+
GitHubUserSchema(id=103, login="dev2"),
|
|
47
|
+
],
|
|
48
|
+
requested_reviewers=[
|
|
49
|
+
GitHubUserSchema(id=104, login="reviewer"),
|
|
50
|
+
],
|
|
51
|
+
base=GitHubBranchSchema(ref="main", sha="abc123"),
|
|
52
|
+
head=GitHubBranchSchema(ref="feature/test", sha="def456"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def get_files(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRFilesResponseSchema:
|
|
56
|
+
self.calls.append(("get_files", {"owner": owner, "repo": repo, "pull_number": pull_number}))
|
|
57
|
+
|
|
58
|
+
return GitHubGetPRFilesResponseSchema(
|
|
59
|
+
root=[
|
|
60
|
+
GitHubPRFileSchema(
|
|
61
|
+
sha="abc",
|
|
62
|
+
status="modified",
|
|
63
|
+
filename="app/main.py",
|
|
64
|
+
patch="@@ -1,2 +1,2 @@\n- old\n+ new",
|
|
65
|
+
),
|
|
66
|
+
GitHubPRFileSchema(
|
|
67
|
+
sha="def",
|
|
68
|
+
status="added",
|
|
69
|
+
filename="utils/helper.py",
|
|
70
|
+
patch="+ print('Hello')",
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def get_issue_comments(self, owner: str, repo: str, issue_number: str) -> GitHubGetPRCommentsResponseSchema:
|
|
76
|
+
self.calls.append(("get_issue_comments", {"owner": owner, "repo": repo, "issue_number": issue_number}))
|
|
77
|
+
|
|
78
|
+
return GitHubGetPRCommentsResponseSchema(
|
|
79
|
+
root=[
|
|
80
|
+
GitHubPRCommentSchema(id=1, body="General comment"),
|
|
81
|
+
GitHubPRCommentSchema(id=2, body="Another general comment"),
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def get_review_comments(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRCommentsResponseSchema:
|
|
86
|
+
self.calls.append(("get_review_comments", {"owner": owner, "repo": repo, "pull_number": pull_number}))
|
|
87
|
+
|
|
88
|
+
return GitHubGetPRCommentsResponseSchema(
|
|
89
|
+
root=[
|
|
90
|
+
GitHubPRCommentSchema(id=3, body="Inline comment", path="file.py", line=5),
|
|
91
|
+
GitHubPRCommentSchema(id=4, body="Another inline comment", path="utils.py", line=10),
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def get_reviews(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRReviewsResponseSchema:
|
|
96
|
+
self.calls.append(("get_reviews", {"owner": owner, "repo": repo, "pull_number": pull_number}))
|
|
97
|
+
|
|
98
|
+
return GitHubGetPRReviewsResponseSchema(
|
|
99
|
+
root=[
|
|
100
|
+
GitHubPRReviewSchema(id=1, body="Looks good", state="APPROVED"),
|
|
101
|
+
GitHubPRReviewSchema(id=2, body="Needs changes", state="CHANGES_REQUESTED"),
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def create_review_comment(
|
|
106
|
+
self,
|
|
107
|
+
owner: str,
|
|
108
|
+
repo: str,
|
|
109
|
+
pull_number: str,
|
|
110
|
+
request: GitHubCreateReviewCommentRequestSchema,
|
|
111
|
+
) -> GitHubCreateReviewCommentResponseSchema:
|
|
112
|
+
self.calls.append(
|
|
113
|
+
(
|
|
114
|
+
"create_review_comment",
|
|
115
|
+
{"owner": owner, "repo": repo, "pull_number": pull_number, **request.model_dump()}
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return GitHubCreateReviewCommentResponseSchema(id=10, body=request.body)
|
|
119
|
+
|
|
120
|
+
async def create_issue_comment(
|
|
121
|
+
self,
|
|
122
|
+
owner: str,
|
|
123
|
+
repo: str,
|
|
124
|
+
issue_number: str,
|
|
125
|
+
body: str,
|
|
126
|
+
) -> GitHubCreateIssueCommentResponseSchema:
|
|
127
|
+
self.calls.append(
|
|
128
|
+
(
|
|
129
|
+
"create_issue_comment",
|
|
130
|
+
{"owner": owner, "repo": repo, "issue_number": issue_number, "body": body}
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return GitHubCreateIssueCommentResponseSchema(id=11, body=body)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class FakeGitHubHTTPClient:
|
|
137
|
+
def __init__(self, pull_requests_client: GitHubPullRequestsHTTPClientProtocol):
|
|
138
|
+
self.pr = pull_requests_client
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture
|
|
142
|
+
def fake_github_pull_requests_http_client() -> FakeGitHubPullRequestsHTTPClient:
|
|
143
|
+
return FakeGitHubPullRequestsHTTPClient()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.fixture
|
|
147
|
+
def fake_github_http_client(
|
|
148
|
+
fake_github_pull_requests_http_client: FakeGitHubPullRequestsHTTPClient
|
|
149
|
+
) -> FakeGitHubHTTPClient:
|
|
150
|
+
return FakeGitHubHTTPClient(pull_requests_client=fake_github_pull_requests_http_client)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.fixture
|
|
154
|
+
def github_vcs_client(
|
|
155
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
156
|
+
fake_github_http_client: FakeGitHubHTTPClient
|
|
157
|
+
) -> GitHubVCSClient:
|
|
158
|
+
monkeypatch.setattr(
|
|
159
|
+
"ai_review.services.vcs.github.client.get_github_http_client",
|
|
160
|
+
lambda: fake_github_http_client,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return GitHubVCSClient()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.fixture
|
|
167
|
+
def github_http_client_config(monkeypatch: pytest.MonkeyPatch):
|
|
168
|
+
fake_config = GitHubVCSConfig(
|
|
169
|
+
provider=VCSProvider.GITHUB,
|
|
170
|
+
pipeline=GitHubPipelineConfig(
|
|
171
|
+
repo="repo",
|
|
172
|
+
owner="owner",
|
|
173
|
+
pull_number="pull_number"
|
|
174
|
+
),
|
|
175
|
+
http_client=GitHubHTTPClientConfig(
|
|
176
|
+
timeout=10,
|
|
177
|
+
api_url=HttpUrl("https://github.com"),
|
|
178
|
+
api_token=SecretStr("fake-token"),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
monkeypatch.setattr(settings, "vcs", fake_config)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import HttpUrl, SecretStr
|
|
3
|
+
|
|
4
|
+
from ai_review.clients.gitlab.mr.schema.changes import (
|
|
5
|
+
GitLabUserSchema,
|
|
6
|
+
GitLabMRChangeSchema,
|
|
7
|
+
GitLabDiffRefsSchema,
|
|
8
|
+
GitLabGetMRChangesResponseSchema,
|
|
9
|
+
)
|
|
10
|
+
from ai_review.clients.gitlab.mr.schema.discussions import (
|
|
11
|
+
GitLabDiscussionSchema,
|
|
12
|
+
GitLabGetMRDiscussionsResponseSchema,
|
|
13
|
+
GitLabCreateMRDiscussionRequestSchema,
|
|
14
|
+
GitLabCreateMRDiscussionResponseSchema,
|
|
15
|
+
)
|
|
16
|
+
from ai_review.clients.gitlab.mr.schema.notes import (
|
|
17
|
+
GitLabNoteSchema,
|
|
18
|
+
GitLabGetMRNotesResponseSchema,
|
|
19
|
+
GitLabCreateMRNoteResponseSchema,
|
|
20
|
+
)
|
|
21
|
+
from ai_review.clients.gitlab.mr.types import GitLabMergeRequestsHTTPClientProtocol
|
|
22
|
+
from ai_review.config import settings
|
|
23
|
+
from ai_review.libs.config.gitlab import GitLabPipelineConfig, GitLabHTTPClientConfig
|
|
24
|
+
from ai_review.libs.config.vcs import GitLabVCSConfig
|
|
25
|
+
from ai_review.libs.constants.vcs_provider import VCSProvider
|
|
26
|
+
from ai_review.services.vcs.gitlab.client import GitLabVCSClient
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FakeGitLabMergeRequestsHTTPClient(GitLabMergeRequestsHTTPClientProtocol):
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.calls: list[tuple[str, dict]] = []
|
|
32
|
+
|
|
33
|
+
async def get_changes(self, project_id: str, merge_request_id: str) -> GitLabGetMRChangesResponseSchema:
|
|
34
|
+
self.calls.append(("get_changes", {"project_id": project_id, "merge_request_id": merge_request_id}))
|
|
35
|
+
return GitLabGetMRChangesResponseSchema(
|
|
36
|
+
id=1,
|
|
37
|
+
iid=1,
|
|
38
|
+
title="Fake Merge Request",
|
|
39
|
+
author=GitLabUserSchema(id=42, name="Tester", username="tester"),
|
|
40
|
+
labels=["bugfix", "backend"],
|
|
41
|
+
description="This is a fake MR for testing",
|
|
42
|
+
project_id=1,
|
|
43
|
+
diff_refs=GitLabDiffRefsSchema(
|
|
44
|
+
base_sha="abc123",
|
|
45
|
+
head_sha="def456",
|
|
46
|
+
start_sha="ghi789",
|
|
47
|
+
),
|
|
48
|
+
source_branch="feature/test",
|
|
49
|
+
target_branch="main",
|
|
50
|
+
changes=[
|
|
51
|
+
GitLabMRChangeSchema(
|
|
52
|
+
diff="@@ -1,2 +1,2 @@\n- old\n+ new",
|
|
53
|
+
old_path="main.py",
|
|
54
|
+
new_path="main.py",
|
|
55
|
+
)
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def get_notes(self, project_id: str, merge_request_id: str) -> GitLabGetMRNotesResponseSchema:
|
|
60
|
+
self.calls.append(("get_notes", {"project_id": project_id, "merge_request_id": merge_request_id}))
|
|
61
|
+
return GitLabGetMRNotesResponseSchema(
|
|
62
|
+
root=[
|
|
63
|
+
GitLabNoteSchema(id=1, body="General comment"),
|
|
64
|
+
GitLabNoteSchema(id=2, body="Another note"),
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def get_discussions(self, project_id: str, merge_request_id: str) -> GitLabGetMRDiscussionsResponseSchema:
|
|
69
|
+
self.calls.append(("get_discussions", {"project_id": project_id, "merge_request_id": merge_request_id}))
|
|
70
|
+
return GitLabGetMRDiscussionsResponseSchema(
|
|
71
|
+
root=[
|
|
72
|
+
GitLabDiscussionSchema(
|
|
73
|
+
id="discussion-1",
|
|
74
|
+
notes=[
|
|
75
|
+
GitLabNoteSchema(id=10, body="Inline comment A"),
|
|
76
|
+
GitLabNoteSchema(id=11, body="Inline comment B"),
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def create_note(self, body: str, project_id: str, merge_request_id: str) -> GitLabCreateMRNoteResponseSchema:
|
|
83
|
+
self.calls.append(
|
|
84
|
+
(
|
|
85
|
+
"create_note",
|
|
86
|
+
{"body": body, "project_id": project_id, "merge_request_id": merge_request_id}
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return GitLabCreateMRNoteResponseSchema(id=99, body=body)
|
|
90
|
+
|
|
91
|
+
async def create_discussion(
|
|
92
|
+
self,
|
|
93
|
+
project_id: str,
|
|
94
|
+
merge_request_id: str,
|
|
95
|
+
request: GitLabCreateMRDiscussionRequestSchema,
|
|
96
|
+
) -> GitLabCreateMRDiscussionResponseSchema:
|
|
97
|
+
self.calls.append(
|
|
98
|
+
(
|
|
99
|
+
"create_discussion",
|
|
100
|
+
{"project_id": project_id, "merge_request_id": merge_request_id, "body": request.body}
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return GitLabCreateMRDiscussionResponseSchema(id="discussion-new", body=request.body)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class FakeGitLabHTTPClient:
|
|
107
|
+
def __init__(self, merge_requests_client: FakeGitLabMergeRequestsHTTPClient):
|
|
108
|
+
self.mr = merge_requests_client
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.fixture
|
|
112
|
+
def fake_gitlab_merge_requests_http_client() -> FakeGitLabMergeRequestsHTTPClient:
|
|
113
|
+
return FakeGitLabMergeRequestsHTTPClient()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.fixture
|
|
117
|
+
def fake_gitlab_http_client(
|
|
118
|
+
fake_gitlab_merge_requests_http_client: FakeGitLabMergeRequestsHTTPClient
|
|
119
|
+
) -> FakeGitLabHTTPClient:
|
|
120
|
+
return FakeGitLabHTTPClient(merge_requests_client=fake_gitlab_merge_requests_http_client)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.fixture
|
|
124
|
+
def gitlab_vcs_client(
|
|
125
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
126
|
+
fake_gitlab_http_client: FakeGitLabHTTPClient
|
|
127
|
+
) -> GitLabVCSClient:
|
|
128
|
+
monkeypatch.setattr(
|
|
129
|
+
"ai_review.services.vcs.gitlab.client.get_gitlab_http_client",
|
|
130
|
+
lambda: fake_gitlab_http_client,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return GitLabVCSClient()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.fixture
|
|
137
|
+
def gitlab_http_client_config(monkeypatch: pytest.MonkeyPatch):
|
|
138
|
+
fake_config = GitLabVCSConfig(
|
|
139
|
+
provider=VCSProvider.GITLAB,
|
|
140
|
+
pipeline=GitLabPipelineConfig(
|
|
141
|
+
project_id="project-id",
|
|
142
|
+
merge_request_id="merge-request-id"
|
|
143
|
+
),
|
|
144
|
+
http_client=GitLabHTTPClientConfig(
|
|
145
|
+
timeout=10,
|
|
146
|
+
api_url=HttpUrl("https://gitlab.com"),
|
|
147
|
+
api_token=SecretStr("fake-token"),
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
monkeypatch.setattr(settings, "vcs", 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.llm import OpenAILLMConfig
|
|
6
|
+
from ai_review.libs.config.openai import OpenAIMetaConfig, OpenAIHTTPClientConfig
|
|
7
|
+
from ai_review.libs.constants.llm_provider import LLMProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def openai_http_client_config(monkeypatch: pytest.MonkeyPatch):
|
|
12
|
+
fake_config = OpenAILLMConfig(
|
|
13
|
+
meta=OpenAIMetaConfig(),
|
|
14
|
+
provider=LLMProvider.OPENAI,
|
|
15
|
+
http_client=OpenAIHTTPClientConfig(
|
|
16
|
+
timeout=10,
|
|
17
|
+
api_url=HttpUrl("https://api.openai.com/v1"),
|
|
18
|
+
api_token=SecretStr("fake-token"),
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
monkeypatch.setattr(settings, "llm", fake_config)
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ai_review.services.artifacts.types import ArtifactsServiceProtocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeArtifactsService(ArtifactsServiceProtocol):
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.calls: list[tuple[str, dict]] = []
|
|
11
|
+
self.saved_artifacts: list[tuple[Path, str, str]] = []
|
|
12
|
+
self.saved_llm_interactions: list[dict[str, str | None]] = []
|
|
13
|
+
|
|
14
|
+
async def save_llm_interaction(
|
|
15
|
+
self,
|
|
16
|
+
prompt: str,
|
|
17
|
+
prompt_system: str,
|
|
18
|
+
response: str | None = None
|
|
19
|
+
) -> str:
|
|
20
|
+
self.calls.append((
|
|
21
|
+
"save_llm_interaction",
|
|
22
|
+
{"prompt": prompt, "prompt_system": prompt_system, "response": response},
|
|
23
|
+
))
|
|
24
|
+
|
|
25
|
+
artifact_id = f"fake-{len(self.saved_llm_interactions) + 1}"
|
|
26
|
+
self.saved_llm_interactions.append({
|
|
27
|
+
"id": artifact_id,
|
|
28
|
+
"prompt": prompt,
|
|
29
|
+
"prompt_system": prompt_system,
|
|
30
|
+
"response": response,
|
|
31
|
+
})
|
|
32
|
+
return artifact_id
|
|
33
|
+
|
|
34
|
+
async def save_artifact(
|
|
35
|
+
self,
|
|
36
|
+
file: Path,
|
|
37
|
+
content: str,
|
|
38
|
+
kind: str = "artifact"
|
|
39
|
+
) -> Path:
|
|
40
|
+
self.calls.append((
|
|
41
|
+
"save_artifact",
|
|
42
|
+
{"file": str(file), "content": content, "kind": kind},
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
self.saved_artifacts.append((file, content, kind))
|
|
46
|
+
return file
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def fake_artifacts_service() -> FakeArtifactsService:
|
|
51
|
+
return FakeArtifactsService()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from ai_review.services.cost.schema import CostReportSchema
|
|
4
|
+
from ai_review.services.cost.types import CostServiceProtocol
|
|
5
|
+
from ai_review.services.llm.types import ChatResultSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeCostService(CostServiceProtocol):
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.calls: list[tuple[str, dict]] = []
|
|
11
|
+
self.reports: list[CostReportSchema] = []
|
|
12
|
+
self.calculated_results: list[ChatResultSchema] = []
|
|
13
|
+
|
|
14
|
+
def calculate(self, result: ChatResultSchema) -> CostReportSchema:
|
|
15
|
+
self.calls.append(("calculate", {"result": result}))
|
|
16
|
+
self.calculated_results.append(result)
|
|
17
|
+
|
|
18
|
+
report = CostReportSchema(
|
|
19
|
+
model="fake-model",
|
|
20
|
+
prompt_tokens=result.prompt_tokens or 10,
|
|
21
|
+
completion_tokens=result.completion_tokens or 5,
|
|
22
|
+
input_cost=0.001,
|
|
23
|
+
output_cost=0.002,
|
|
24
|
+
total_cost=0.003,
|
|
25
|
+
)
|
|
26
|
+
self.reports.append(report)
|
|
27
|
+
return report
|
|
28
|
+
|
|
29
|
+
def aggregate(self) -> CostReportSchema | None:
|
|
30
|
+
self.calls.append(("aggregate", {}))
|
|
31
|
+
|
|
32
|
+
if not self.reports:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
total_cost = sum(r.total_cost for r in self.reports)
|
|
36
|
+
return CostReportSchema(
|
|
37
|
+
model="fake-model",
|
|
38
|
+
total_cost=total_cost,
|
|
39
|
+
input_cost=0.001 * len(self.reports),
|
|
40
|
+
output_cost=0.002 * len(self.reports),
|
|
41
|
+
prompt_tokens=sum(r.prompt_tokens for r in self.reports),
|
|
42
|
+
completion_tokens=sum(r.completion_tokens for r in self.reports),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def fake_cost_service() -> "FakeCostService":
|
|
48
|
+
return FakeCostService()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from ai_review.libs.diff.models import Diff
|
|
4
|
+
from ai_review.services.diff.schema import DiffFileSchema
|
|
5
|
+
from ai_review.services.diff.types import DiffServiceProtocol
|
|
6
|
+
from ai_review.services.git.types import GitServiceProtocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeDiffService(DiffServiceProtocol):
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.calls: list[tuple[str, dict]] = []
|
|
12
|
+
|
|
13
|
+
def parse(self, raw_diff: str) -> Diff:
|
|
14
|
+
self.calls.append(("parse", {"raw_diff": raw_diff}))
|
|
15
|
+
return Diff(files=[], raw=raw_diff)
|
|
16
|
+
|
|
17
|
+
def render_file(
|
|
18
|
+
self,
|
|
19
|
+
file: str,
|
|
20
|
+
raw_diff: str,
|
|
21
|
+
base_sha: str | None = None,
|
|
22
|
+
head_sha: str | None = None,
|
|
23
|
+
) -> DiffFileSchema:
|
|
24
|
+
self.calls.append((
|
|
25
|
+
"render_file",
|
|
26
|
+
{"file": file, "raw_diff": raw_diff, "base_sha": base_sha, "head_sha": head_sha},
|
|
27
|
+
))
|
|
28
|
+
return DiffFileSchema(file=file, diff=f"FAKE_DIFF_CONTENT for {file}")
|
|
29
|
+
|
|
30
|
+
def render_files(
|
|
31
|
+
self,
|
|
32
|
+
git: GitServiceProtocol,
|
|
33
|
+
files: list[str],
|
|
34
|
+
base_sha: str,
|
|
35
|
+
head_sha: str,
|
|
36
|
+
) -> list[DiffFileSchema]:
|
|
37
|
+
self.calls.append((
|
|
38
|
+
"render_files",
|
|
39
|
+
{"git": git, "files": files, "base_sha": base_sha, "head_sha": head_sha},
|
|
40
|
+
))
|
|
41
|
+
return [DiffFileSchema(file=file, diff=f"FAKE_DIFF for {file}") for file in files]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def fake_diff_service() -> FakeDiffService:
|
|
46
|
+
return FakeDiffService()
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# ai_review/tests/conftest.py
|
|
2
1
|
from typing import Any
|
|
3
2
|
|
|
4
3
|
import pytest
|
|
@@ -7,25 +6,32 @@ from ai_review.services.git.types import GitServiceProtocol
|
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
class FakeGitService(GitServiceProtocol):
|
|
10
|
-
"""Simple fake for GitService used in tests."""
|
|
11
|
-
|
|
12
9
|
def __init__(self, responses: dict[str, Any] | None = None) -> None:
|
|
10
|
+
self.calls: list[tuple[str, dict]] = []
|
|
13
11
|
self.responses = responses or {}
|
|
14
12
|
|
|
15
13
|
def get_diff(self, base_sha: str, head_sha: str, unified: int = 3) -> str:
|
|
14
|
+
self.calls.append(("get_diff", {"base_sha": base_sha, "head_sha": head_sha, "unified": unified}))
|
|
16
15
|
return self.responses.get("get_diff", "")
|
|
17
16
|
|
|
18
17
|
def get_diff_for_file(self, base_sha: str, head_sha: str, file: str, unified: int = 3) -> str:
|
|
18
|
+
self.calls.append(
|
|
19
|
+
(
|
|
20
|
+
"get_diff_for_file",
|
|
21
|
+
{"base_sha": base_sha, "head_sha": head_sha, "file": file, "unified": unified}
|
|
22
|
+
)
|
|
23
|
+
)
|
|
19
24
|
return self.responses.get("get_diff_for_file", "")
|
|
20
25
|
|
|
21
26
|
def get_changed_files(self, base_sha: str, head_sha: str) -> list[str]:
|
|
27
|
+
self.calls.append(("get_changed_files", {"base_sha": base_sha, "head_sha": head_sha}))
|
|
22
28
|
return self.responses.get("get_changed_files", [])
|
|
23
29
|
|
|
24
30
|
def get_file_at_commit(self, file_path: str, sha: str) -> str | None:
|
|
31
|
+
self.calls.append(("get_file_at_commit", {"file_path": file_path, "sha": sha}))
|
|
25
32
|
return self.responses.get("get_file_at_commit", None)
|
|
26
33
|
|
|
27
34
|
|
|
28
35
|
@pytest.fixture
|
|
29
|
-
def
|
|
30
|
-
"""Default fake GitService with empty responses."""
|
|
36
|
+
def fake_git_service() -> FakeGitService:
|
|
31
37
|
return FakeGitService()
|