xai-review 0.11.0__py3-none-any.whl → 0.13.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 (27) hide show
  1. ai_review/clients/github/__init__.py +0 -0
  2. ai_review/clients/github/client.py +31 -0
  3. ai_review/clients/github/pr/__init__.py +0 -0
  4. ai_review/clients/github/pr/client.py +101 -0
  5. ai_review/clients/github/pr/schema/__init__.py +0 -0
  6. ai_review/clients/github/pr/schema/comments.py +33 -0
  7. ai_review/clients/github/pr/schema/files.py +12 -0
  8. ai_review/clients/github/pr/schema/pull_request.py +30 -0
  9. ai_review/clients/github/pr/schema/reviews.py +13 -0
  10. ai_review/libs/config/github.py +13 -0
  11. ai_review/libs/config/prompt.py +1 -0
  12. ai_review/libs/config/vcs.py +8 -1
  13. ai_review/services/prompt/service.py +19 -18
  14. ai_review/services/prompt/tools.py +24 -0
  15. ai_review/services/vcs/factory.py +3 -0
  16. ai_review/services/vcs/github/__init__.py +0 -0
  17. ai_review/services/vcs/github/client.py +147 -0
  18. ai_review/tests/suites/clients/github/__init__.py +0 -0
  19. ai_review/tests/suites/clients/github/test_client.py +36 -0
  20. ai_review/tests/suites/services/prompt/test_service.py +33 -0
  21. ai_review/tests/suites/services/prompt/test_tools.py +72 -0
  22. {xai_review-0.11.0.dist-info → xai_review-0.13.0.dist-info}/METADATA +1 -1
  23. {xai_review-0.11.0.dist-info → xai_review-0.13.0.dist-info}/RECORD +27 -11
  24. {xai_review-0.11.0.dist-info → xai_review-0.13.0.dist-info}/WHEEL +0 -0
  25. {xai_review-0.11.0.dist-info → xai_review-0.13.0.dist-info}/entry_points.txt +0 -0
  26. {xai_review-0.11.0.dist-info → xai_review-0.13.0.dist-info}/licenses/LICENSE +0 -0
  27. {xai_review-0.11.0.dist-info → xai_review-0.13.0.dist-info}/top_level.txt +0 -0
File without changes
@@ -0,0 +1,31 @@
1
+ from httpx import AsyncClient, AsyncHTTPTransport
2
+
3
+ from ai_review.clients.github.pr.client import GitHubPullRequestsHTTPClient
4
+ from ai_review.config import settings
5
+ from ai_review.libs.http.event_hooks.logger import LoggerEventHook
6
+ from ai_review.libs.http.transports.retry import RetryTransport
7
+ from ai_review.libs.logger import get_logger
8
+
9
+
10
+ class GitHubHTTPClient:
11
+ def __init__(self, client: AsyncClient):
12
+ self.pr = GitHubPullRequestsHTTPClient(client)
13
+
14
+
15
+ def get_github_http_client() -> GitHubHTTPClient:
16
+ logger = get_logger("GITHUB_HTTP_CLIENT")
17
+ logger_event_hook = LoggerEventHook(logger=logger)
18
+ retry_transport = RetryTransport(transport=AsyncHTTPTransport())
19
+
20
+ client = AsyncClient(
21
+ timeout=settings.llm.http_client.timeout,
22
+ headers={"Authorization": f"Bearer {settings.vcs.http_client.api_token_value}"},
23
+ base_url=settings.vcs.http_client.api_url_value,
24
+ transport=retry_transport,
25
+ event_hooks={
26
+ 'request': [logger_event_hook.request],
27
+ 'response': [logger_event_hook.response]
28
+ }
29
+ )
30
+
31
+ return GitHubHTTPClient(client=client)
File without changes
@@ -0,0 +1,101 @@
1
+ from httpx import Response
2
+
3
+ from ai_review.clients.github.pr.schema.comments import (
4
+ GitHubGetPRCommentsResponseSchema,
5
+ GitHubCreateIssueCommentRequestSchema,
6
+ GitHubCreateIssueCommentResponseSchema,
7
+ GitHubCreateReviewCommentRequestSchema,
8
+ GitHubCreateReviewCommentResponseSchema
9
+ )
10
+ from ai_review.clients.github.pr.schema.files import GitHubGetPRFilesResponseSchema
11
+ from ai_review.clients.github.pr.schema.pull_request import GitHubGetPRResponseSchema
12
+ from ai_review.clients.github.pr.schema.reviews import GitHubGetPRReviewsResponseSchema
13
+ from ai_review.libs.http.client import HTTPClient
14
+
15
+
16
+ class GitHubPullRequestsHTTPClient(HTTPClient):
17
+ async def get_pull_request_api(self, owner: str, repo: str, pull_number: str) -> Response:
18
+ return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}")
19
+
20
+ async def get_files_api(self, owner: str, repo: str, pull_number: str) -> Response:
21
+ return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}/files")
22
+
23
+ async def get_issue_comments_api(self, owner: str, repo: str, issue_number: str) -> Response:
24
+ return await self.get(f"/repos/{owner}/{repo}/issues/{issue_number}/comments")
25
+
26
+ async def get_review_comments_api(self, owner: str, repo: str, pull_number: str) -> Response:
27
+ return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}/comments")
28
+
29
+ async def create_review_comment_api(
30
+ self,
31
+ owner: str,
32
+ repo: str,
33
+ pull_number: str,
34
+ request: GitHubCreateReviewCommentRequestSchema,
35
+ ) -> Response:
36
+ return await self.post(
37
+ f"/repos/{owner}/{repo}/pulls/{pull_number}/comments",
38
+ json=request.model_dump(),
39
+ )
40
+
41
+ async def create_issue_comment_api(
42
+ self,
43
+ owner: str,
44
+ repo: str,
45
+ issue_number: str,
46
+ request: GitHubCreateIssueCommentRequestSchema,
47
+ ) -> Response:
48
+ return await self.post(
49
+ f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
50
+ json=request.model_dump(),
51
+ )
52
+
53
+ async def get_reviews_api(self, owner: str, repo: str, pull_number: str) -> Response:
54
+ return await self.get(f"/repos/{owner}/{repo}/pulls/{pull_number}/reviews")
55
+
56
+ async def get_pull_request(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRResponseSchema:
57
+ response = await self.get_pull_request_api(owner, repo, pull_number)
58
+ return GitHubGetPRResponseSchema.model_validate_json(response.text)
59
+
60
+ async def get_files(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRFilesResponseSchema:
61
+ response = await self.get_files_api(owner, repo, pull_number)
62
+ return GitHubGetPRFilesResponseSchema.model_validate_json(response.text)
63
+
64
+ async def get_issue_comments(self, owner: str, repo: str, issue_number: str) -> GitHubGetPRCommentsResponseSchema:
65
+ response = await self.get_issue_comments_api(owner, repo, issue_number)
66
+ return GitHubGetPRCommentsResponseSchema.model_validate_json(response.text)
67
+
68
+ async def get_review_comments(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRCommentsResponseSchema:
69
+ response = await self.get_review_comments_api(owner, repo, pull_number)
70
+ return GitHubGetPRCommentsResponseSchema.model_validate_json(response.text)
71
+
72
+ async def get_reviews(self, owner: str, repo: str, pull_number: str) -> GitHubGetPRReviewsResponseSchema:
73
+ response = await self.get_reviews_api(owner, repo, pull_number)
74
+ return GitHubGetPRReviewsResponseSchema.model_validate_json(response.text)
75
+
76
+ async def create_review_comment(
77
+ self,
78
+ owner: str,
79
+ repo: str,
80
+ pull_number: str,
81
+ body: str,
82
+ commit_id: str,
83
+ path: str,
84
+ line: int,
85
+ ) -> GitHubCreateReviewCommentResponseSchema:
86
+ request = GitHubCreateReviewCommentRequestSchema(
87
+ body=body, commit_id=commit_id, path=path, line=line
88
+ )
89
+ response = await self.create_review_comment_api(owner, repo, pull_number, request)
90
+ return GitHubCreateReviewCommentResponseSchema.model_validate_json(response.text)
91
+
92
+ async def create_issue_comment(
93
+ self,
94
+ owner: str,
95
+ repo: str,
96
+ issue_number: str,
97
+ body: str,
98
+ ) -> GitHubCreateIssueCommentResponseSchema:
99
+ request = GitHubCreateIssueCommentRequestSchema(body=body)
100
+ response = await self.create_issue_comment_api(owner, repo, issue_number, request)
101
+ return GitHubCreateIssueCommentResponseSchema.model_validate_json(response.text)
File without changes
@@ -0,0 +1,33 @@
1
+ from pydantic import BaseModel, RootModel
2
+
3
+
4
+ class GitHubPRCommentSchema(BaseModel):
5
+ id: int
6
+ body: str
7
+ path: str | None = None
8
+ line: int | None = None
9
+
10
+
11
+ class GitHubGetPRCommentsResponseSchema(RootModel[list[GitHubPRCommentSchema]]):
12
+ root: list[GitHubPRCommentSchema]
13
+
14
+
15
+ class GitHubCreateIssueCommentRequestSchema(BaseModel):
16
+ body: str
17
+
18
+
19
+ class GitHubCreateIssueCommentResponseSchema(BaseModel):
20
+ id: int
21
+ body: str
22
+
23
+
24
+ class GitHubCreateReviewCommentRequestSchema(BaseModel):
25
+ body: str
26
+ path: str
27
+ line: int
28
+ commit_id: str
29
+
30
+
31
+ class GitHubCreateReviewCommentResponseSchema(BaseModel):
32
+ id: int
33
+ body: str
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, RootModel
2
+
3
+
4
+ class GitHubPRFileSchema(BaseModel):
5
+ sha: str
6
+ patch: str | None = None
7
+ status: str
8
+ filename: str
9
+
10
+
11
+ class GitHubGetPRFilesResponseSchema(RootModel[list[GitHubPRFileSchema]]):
12
+ root: list[GitHubPRFileSchema]
@@ -0,0 +1,30 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class GitHubUserSchema(BaseModel):
5
+ id: int
6
+ login: str
7
+
8
+
9
+ class GitHubLabelSchema(BaseModel):
10
+ id: int
11
+ name: str
12
+
13
+
14
+ class GitHubBranchSchema(BaseModel):
15
+ ref: str
16
+ sha: str
17
+ label: str
18
+
19
+
20
+ class GitHubGetPRResponseSchema(BaseModel):
21
+ id: int
22
+ number: int
23
+ title: str
24
+ body: str | None = None
25
+ user: GitHubUserSchema
26
+ labels: list[GitHubLabelSchema]
27
+ assignees: list[GitHubUserSchema] = []
28
+ requested_reviewers: list[GitHubUserSchema] = []
29
+ base: GitHubBranchSchema
30
+ head: GitHubBranchSchema
@@ -0,0 +1,13 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel, RootModel
4
+
5
+
6
+ class GitHubPRReviewSchema(BaseModel):
7
+ id: int
8
+ body: Optional[str] = None
9
+ state: str
10
+
11
+
12
+ class GitHubGetPRReviewsResponseSchema(RootModel[list[GitHubPRReviewSchema]]):
13
+ root: list[GitHubPRReviewSchema]
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ai_review.libs.config.http import HTTPClientConfig
4
+
5
+
6
+ class GitHubPipelineConfig(BaseModel):
7
+ repo: str
8
+ owner: str
9
+ pull_number: str
10
+
11
+
12
+ class GitHubHTTPClientConfig(HTTPClientConfig):
13
+ pass
@@ -8,6 +8,7 @@ from ai_review.libs.resources import load_resource
8
8
 
9
9
  class PromptConfig(BaseModel):
10
10
  context: dict[str, str] = Field(default_factory=dict)
11
+ normalize_prompts: bool = True
11
12
  context_placeholder: str = "<<{value}>>"
12
13
  inline_prompt_files: list[FilePath] | None = None
13
14
  context_prompt_files: list[FilePath] | None = None
@@ -2,6 +2,7 @@ from typing import Annotated, Literal
2
2
 
3
3
  from pydantic import BaseModel, Field
4
4
 
5
+ from ai_review.libs.config.github import GitHubPipelineConfig, GitHubHTTPClientConfig
5
6
  from ai_review.libs.config.gitlab import GitLabPipelineConfig, GitLabHTTPClientConfig
6
7
  from ai_review.libs.constants.vcs_provider import VCSProvider
7
8
 
@@ -16,4 +17,10 @@ class GitLabVCSConfig(VCSConfigBase):
16
17
  http_client: GitLabHTTPClientConfig
17
18
 
18
19
 
19
- VCSConfig = Annotated[GitLabVCSConfig, Field(discriminator="provider")]
20
+ class GitHubVCSConfig(VCSConfigBase):
21
+ provider: Literal[VCSProvider.GITHUB]
22
+ pipeline: GitHubPipelineConfig
23
+ http_client: GitHubHTTPClientConfig
24
+
25
+
26
+ VCSConfig = Annotated[GitLabVCSConfig | GitHubVCSConfig, Field(discriminator="provider")]
@@ -1,56 +1,57 @@
1
1
  from ai_review.config import settings
2
2
  from ai_review.services.diff.schema import DiffFileSchema
3
3
  from ai_review.services.prompt.schema import PromptContextSchema
4
+ from ai_review.services.prompt.tools import normalize_prompt, format_file
4
5
 
5
6
 
6
- def format_file(diff: DiffFileSchema) -> str:
7
- return f"# File: {diff.file}\n{diff.diff}\n"
7
+ class PromptService:
8
+ @classmethod
9
+ def prepare_prompt(cls, prompts: list[str], context: PromptContextSchema) -> str:
10
+ prompt = "\n\n".join(prompts)
11
+ prompt = context.apply_format(prompt)
8
12
 
13
+ if settings.prompt.normalize_prompts:
14
+ prompt = normalize_prompt(prompt)
15
+
16
+ return prompt
9
17
 
10
- class PromptService:
11
18
  @classmethod
12
19
  def build_inline_request(cls, diff: DiffFileSchema, context: PromptContextSchema) -> str:
13
- inline_prompts = "\n\n".join(settings.prompt.load_inline())
14
- inline_prompts = context.apply_format(inline_prompts)
20
+ prompt = cls.prepare_prompt(settings.prompt.load_inline(), context)
15
21
  return (
16
- f"{inline_prompts}\n\n"
22
+ f"{prompt}\n\n"
17
23
  f"## Diff\n\n"
18
24
  f"{format_file(diff)}"
19
25
  )
20
26
 
21
27
  @classmethod
22
28
  def build_summary_request(cls, diffs: list[DiffFileSchema], context: PromptContextSchema) -> str:
29
+ prompt = cls.prepare_prompt(settings.prompt.load_summary(), context)
23
30
  changes = "\n\n".join(map(format_file, diffs))
24
- summary_prompts = "\n\n".join(settings.prompt.load_summary())
25
- summary_prompts = context.apply_format(summary_prompts)
26
31
  return (
27
- f"{summary_prompts}\n\n"
32
+ f"{prompt}\n\n"
28
33
  f"## Changes\n\n"
29
34
  f"{changes}\n"
30
35
  )
31
36
 
32
37
  @classmethod
33
38
  def build_context_request(cls, diffs: list[DiffFileSchema], context: PromptContextSchema) -> str:
39
+ prompt = cls.prepare_prompt(settings.prompt.load_context(), context)
34
40
  changes = "\n\n".join(map(format_file, diffs))
35
- inline_prompts = "\n\n".join(settings.prompt.load_context())
36
- inline_prompts = context.apply_format(inline_prompts)
37
41
  return (
38
- f"{inline_prompts}\n\n"
42
+ f"{prompt}\n\n"
39
43
  f"## Diff\n\n"
40
44
  f"{changes}\n"
41
45
  )
42
46
 
43
47
  @classmethod
44
48
  def build_system_inline_request(cls, context: PromptContextSchema) -> str:
45
- prompt = "\n\n".join(settings.prompt.load_system_inline())
46
- return context.apply_format(prompt)
49
+ return cls.prepare_prompt(settings.prompt.load_system_inline(), context)
47
50
 
48
51
  @classmethod
49
52
  def build_system_context_request(cls, context: PromptContextSchema) -> str:
50
- prompt = "\n\n".join(settings.prompt.load_system_context())
51
- return context.apply_format(prompt)
53
+ return cls.prepare_prompt(settings.prompt.load_system_context(), context)
52
54
 
53
55
  @classmethod
54
56
  def build_system_summary_request(cls, context: PromptContextSchema) -> str:
55
- prompt = "\n\n".join(settings.prompt.load_system_summary())
56
- return context.apply_format(prompt)
57
+ return cls.prepare_prompt(settings.prompt.load_system_summary(), context)
@@ -0,0 +1,24 @@
1
+ import re
2
+
3
+ from ai_review.libs.logger import get_logger
4
+ from ai_review.services.diff.schema import DiffFileSchema
5
+
6
+ logger = get_logger("PROMPT_TOOLS")
7
+
8
+
9
+ def format_file(diff: DiffFileSchema) -> str:
10
+ return f"# File: {diff.file}\n{diff.diff}\n"
11
+
12
+
13
+ def normalize_prompt(text: str) -> str:
14
+ tails_stripped = [re.sub(r"[ \t]+$", "", line) for line in text.splitlines()]
15
+ text = "\n".join(tails_stripped)
16
+
17
+ text = re.sub(r"\n{3,}", "\n\n", text)
18
+
19
+ result = text.strip()
20
+ if len(text) > len(result):
21
+ logger.info(f"Prompt has been normalized from {len(text)} to {len(result)}")
22
+ return result
23
+
24
+ return text
@@ -1,5 +1,6 @@
1
1
  from ai_review.config import settings
2
2
  from ai_review.libs.constants.vcs_provider import VCSProvider
3
+ from ai_review.services.vcs.github.client import GitHubVCSClient
3
4
  from ai_review.services.vcs.gitlab.client import GitLabVCSClient
4
5
  from ai_review.services.vcs.types import VCSClient
5
6
 
@@ -8,5 +9,7 @@ def get_vcs_client() -> VCSClient:
8
9
  match settings.vcs.provider:
9
10
  case VCSProvider.GITLAB:
10
11
  return GitLabVCSClient()
12
+ case VCSProvider.GITHUB:
13
+ return GitHubVCSClient()
11
14
  case _:
12
15
  raise ValueError(f"Unsupported provider: {settings.llm.provider}")
File without changes
@@ -0,0 +1,147 @@
1
+ from ai_review.clients.github.client import get_github_http_client
2
+ from ai_review.config import settings
3
+ from ai_review.libs.logger import get_logger
4
+ from ai_review.services.vcs.types import (
5
+ VCSClient,
6
+ MRNoteSchema,
7
+ MRUserSchema,
8
+ MRInfoSchema,
9
+ MRCommentSchema,
10
+ MRDiscussionSchema,
11
+ )
12
+
13
+ logger = get_logger("GITHUB_VCS_CLIENT")
14
+
15
+
16
+ class GitHubVCSClient(VCSClient):
17
+ def __init__(self):
18
+ self.http_client = get_github_http_client()
19
+ self.owner = settings.vcs.pipeline.owner
20
+ self.repo = settings.vcs.pipeline.repo
21
+ self.pull_number = settings.vcs.pipeline.pull_number
22
+
23
+ async def get_mr_info(self) -> MRInfoSchema:
24
+ try:
25
+ pr = await self.http_client.pr.get_pull_request(
26
+ owner=self.owner, repo=self.repo, pull_number=self.pull_number
27
+ )
28
+ files = await self.http_client.pr.get_files(
29
+ owner=self.owner, repo=self.repo, pull_number=self.pull_number
30
+ )
31
+
32
+ logger.info(
33
+ f"Fetched PR info for {self.owner}/{self.repo}#{self.pull_number}"
34
+ )
35
+
36
+ return MRInfoSchema(
37
+ title=pr.title,
38
+ author=MRUserSchema(
39
+ name=pr.user.login,
40
+ username=pr.user.login,
41
+ ),
42
+ labels=[label.name for label in pr.labels],
43
+ base_sha=pr.base.sha,
44
+ head_sha=pr.head.sha,
45
+ assignees=[
46
+ MRUserSchema(name=user.login, username=user.login)
47
+ for user in pr.assignees
48
+ ],
49
+ reviewers=[
50
+ MRUserSchema(name=user.login, username=user.login)
51
+ for user in pr.requested_reviewers
52
+ ],
53
+ description=pr.body or "",
54
+ source_branch=pr.head.ref,
55
+ target_branch=pr.base.ref,
56
+ changed_files=[file.filename for file in files.root],
57
+ )
58
+ except Exception as error:
59
+ logger.exception(
60
+ f"Failed to fetch PR info {self.owner}/{self.repo}#{self.pull_number}: {error}"
61
+ )
62
+ return MRInfoSchema()
63
+
64
+ async def get_comments(self) -> list[MRCommentSchema]:
65
+ try:
66
+ response = await self.http_client.pr.get_issue_comments(
67
+ owner=self.owner,
68
+ repo=self.repo,
69
+ issue_number=self.pull_number,
70
+ )
71
+ logger.info(
72
+ f"Fetched issue comments for {self.owner}/{self.repo}#{self.pull_number}"
73
+ )
74
+
75
+ return [MRCommentSchema(id=comment.id, body=comment.body) for comment in response.root]
76
+ except Exception as error:
77
+ logger.exception(
78
+ f"Failed to fetch issue comments {self.owner}/{self.repo}#{self.pull_number}: {error}"
79
+ )
80
+ return []
81
+
82
+ async def get_discussions(self) -> list[MRDiscussionSchema]:
83
+ try:
84
+ response = await self.http_client.pr.get_review_comments(
85
+ owner=self.owner,
86
+ repo=self.repo,
87
+ pull_number=self.pull_number,
88
+ )
89
+ logger.info(
90
+ f"Fetched review comments for {self.owner}/{self.repo}#{self.pull_number}"
91
+ )
92
+
93
+ return [
94
+ MRDiscussionSchema(
95
+ id=str(comment.id),
96
+ notes=[MRNoteSchema(id=comment.id, body=comment.body or "")]
97
+ )
98
+ for comment in response.root
99
+ ]
100
+ except Exception as error:
101
+ logger.exception(
102
+ f"Failed to fetch review comments {self.owner}/{self.repo}#{self.pull_number}: {error}"
103
+ )
104
+ return []
105
+
106
+ async def create_comment(self, message: str) -> None:
107
+ try:
108
+ logger.info(
109
+ f"Posting general comment to PR {self.owner}/{self.repo}#{self.pull_number}: {message}"
110
+ )
111
+ await self.http_client.pr.create_issue_comment(
112
+ owner=self.owner,
113
+ repo=self.repo,
114
+ issue_number=self.pull_number,
115
+ body=message,
116
+ )
117
+ logger.info(
118
+ f"Created general comment in PR {self.owner}/{self.repo}#{self.pull_number}"
119
+ )
120
+ except Exception as error:
121
+ logger.exception(
122
+ f"Failed to create general comment in PR {self.owner}/{self.repo}#{self.pull_number}: {error}"
123
+ )
124
+
125
+ async def create_discussion(self, file: str, line: int, message: str) -> None:
126
+ try:
127
+ pr = await self.http_client.pr.get_pull_request(
128
+ owner=self.owner, repo=self.repo, pull_number=self.pull_number
129
+ )
130
+ commit_id = pr.head.sha
131
+
132
+ await self.http_client.pr.create_review_comment(
133
+ owner=self.owner,
134
+ repo=self.repo,
135
+ pull_number=self.pull_number,
136
+ body=message,
137
+ path=file,
138
+ line=line,
139
+ commit_id=commit_id,
140
+ )
141
+ logger.info(
142
+ f"Created inline comment in {self.owner}/{self.repo}#{self.pull_number} at {file}:{line}"
143
+ )
144
+ except Exception as error:
145
+ logger.exception(
146
+ f"Failed to create inline comment in {self.owner}/{self.repo}#{self.pull_number} at {file}:{line}: {error}"
147
+ )
File without changes
@@ -0,0 +1,36 @@
1
+ import pytest
2
+ from httpx import AsyncClient
3
+ from pydantic import HttpUrl, SecretStr
4
+
5
+ from ai_review.clients.github.client import get_github_http_client, GitHubHTTPClient
6
+ from ai_review.clients.github.pr.client import GitHubPullRequestsHTTPClient
7
+ from ai_review.config import settings
8
+ from ai_review.libs.config.github import GitHubPipelineConfig, GitHubHTTPClientConfig
9
+ from ai_review.libs.config.vcs import GitHubVCSConfig
10
+ from ai_review.libs.constants.vcs_provider import VCSProvider
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def github_http_client_config(monkeypatch: pytest.MonkeyPatch):
15
+ fake_config = GitHubVCSConfig(
16
+ provider=VCSProvider.GITHUB,
17
+ pipeline=GitHubPipelineConfig(
18
+ repo="repo",
19
+ owner="owner",
20
+ pull_number="pull_number"
21
+ ),
22
+ http_client=GitHubHTTPClientConfig(
23
+ timeout=10,
24
+ api_url=HttpUrl("https://github.com"),
25
+ api_token=SecretStr("fake-token"),
26
+ )
27
+ )
28
+ monkeypatch.setattr(settings, "vcs", fake_config)
29
+
30
+
31
+ def test_get_github_http_client_builds_ok():
32
+ github_http_client = get_github_http_client()
33
+
34
+ assert isinstance(github_http_client, GitHubHTTPClient)
35
+ assert isinstance(github_http_client.pr, GitHubPullRequestsHTTPClient)
36
+ assert isinstance(github_http_client.pr.client, AsyncClient)
@@ -1,5 +1,6 @@
1
1
  import pytest
2
2
 
3
+ from ai_review.config import settings
3
4
  from ai_review.libs.config.prompt import PromptConfig
4
5
  from ai_review.services.diff.schema import DiffFileSchema
5
6
  from ai_review.services.prompt.schema import PromptContextSchema
@@ -134,3 +135,35 @@ def test_diff_placeholders_are_not_replaced(dummy_context: PromptContextSchema)
134
135
 
135
136
  assert "<<merge_request_title>>" in result
136
137
  assert "Fix login bug" not in result
138
+
139
+
140
+ def test_prepare_prompt_basic_substitution(dummy_context: PromptContextSchema) -> None:
141
+ prompts = ["Hello", "MR title: <<merge_request_title>>"]
142
+ result = PromptService.prepare_prompt(prompts, dummy_context)
143
+ assert "Hello" in result
144
+ assert "MR title: Fix login bug" in result
145
+
146
+
147
+ def test_prepare_prompt_applies_normalization(
148
+ monkeypatch: pytest.MonkeyPatch,
149
+ dummy_context: PromptContextSchema
150
+ ) -> None:
151
+ monkeypatch.setattr(settings.prompt, "normalize_prompts", True)
152
+ prompts = ["Line with space ", "", "", "Next line"]
153
+ result = PromptService.prepare_prompt(prompts, dummy_context)
154
+
155
+ assert "Line with space" in result
156
+ assert "Next line" in result
157
+ assert "\n\n\n" not in result
158
+
159
+
160
+ def test_prepare_prompt_skips_normalization(
161
+ monkeypatch: pytest.MonkeyPatch,
162
+ dummy_context: PromptContextSchema
163
+ ) -> None:
164
+ monkeypatch.setattr(settings.prompt, "normalize_prompts", False)
165
+ prompts = ["Line with space ", "", "", "Next line"]
166
+ result = PromptService.prepare_prompt(prompts, dummy_context)
167
+
168
+ assert "Line with space " in result
169
+ assert "\n\n\n" in result
@@ -0,0 +1,72 @@
1
+ from ai_review.services.diff.schema import DiffFileSchema
2
+ from ai_review.services.prompt.tools import format_file, normalize_prompt
3
+
4
+
5
+ def test_format_file_basic():
6
+ diff = DiffFileSchema(file="main.py", diff="+ print('hello')")
7
+ result = format_file(diff)
8
+ assert result == "# File: main.py\n+ print('hello')\n"
9
+
10
+
11
+ def test_format_file_empty_diff():
12
+ diff = DiffFileSchema(file="empty.py", diff="")
13
+ result = format_file(diff)
14
+ assert result == "# File: empty.py\n\n"
15
+
16
+
17
+ def test_format_file_multiline_diff():
18
+ diff = DiffFileSchema(
19
+ file="utils/helpers.py",
20
+ diff="- old line\n+ new line\n+ another line"
21
+ )
22
+ result = format_file(diff)
23
+ expected = (
24
+ "# File: utils/helpers.py\n"
25
+ "- old line\n"
26
+ "+ new line\n"
27
+ "+ another line\n"
28
+ )
29
+ assert result == expected
30
+
31
+
32
+ def test_format_file_filename_with_path():
33
+ diff = DiffFileSchema(file="src/app/models/user.py", diff="+ class User:")
34
+ result = format_file(diff)
35
+ assert result.startswith("# File: src/app/models/user.py\n")
36
+ assert result.endswith("+ class User:\n")
37
+
38
+
39
+ def test_trailing_spaces_are_removed():
40
+ text = "hello \nworld\t\t"
41
+ result = normalize_prompt(text)
42
+ assert result == "hello\nworld"
43
+
44
+
45
+ def test_multiple_empty_lines_collapsed():
46
+ text = "line1\n\n\n\nline2"
47
+ result = normalize_prompt(text)
48
+ assert result == "line1\n\nline2"
49
+
50
+
51
+ def test_leading_and_trailing_whitespace_removed():
52
+ text = "\n\n hello\nworld \n\n"
53
+ result = normalize_prompt(text)
54
+ assert result == "hello\nworld"
55
+
56
+
57
+ def test_internal_spaces_preserved():
58
+ text = "foo bar\nbaz\t\tqux"
59
+ result = normalize_prompt(text)
60
+ assert result == "foo bar\nbaz\t\tqux"
61
+
62
+
63
+ def test_only_whitespace_string():
64
+ text = " \n \n"
65
+ result = normalize_prompt(text)
66
+ assert result == ""
67
+
68
+
69
+ def test_no_changes_when_already_clean():
70
+ text = "line1\nline2"
71
+ result = normalize_prompt(text)
72
+ assert result == text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xai-review
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: AI-powered code review tool
5
5
  Author-email: Nikita Filonov <nikita.filonov@example.com>
6
6
  Maintainer-email: Nikita Filonov <nikita.filonov@example.com>
@@ -14,6 +14,15 @@ ai_review/clients/claude/schema.py,sha256=LE6KCjJKDXqBGU2Cno5XL5R8vUfScgskE9MqvE
14
14
  ai_review/clients/gemini/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  ai_review/clients/gemini/client.py,sha256=7ZPgqx77ER7gonxX0VoN4YrMpex3iBEQtd9Hi-bnDms,1780
16
16
  ai_review/clients/gemini/schema.py,sha256=5oVvbI-h_sw8bFreS4JUmMj-aXa_frvxK3H8sg4iJIA,2264
17
+ ai_review/clients/github/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ ai_review/clients/github/client.py,sha256=4uZsnMY-OZ9BNzMLqEHG80jgpyQdd61ePNdVuwGMcrI,1134
19
+ ai_review/clients/github/pr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ ai_review/clients/github/pr/client.py,sha256=Trv5MwmOOi5gAM8KHknbpf8NWNZ-Tnag-bi_74KIdu0,4678
21
+ ai_review/clients/github/pr/schema/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ ai_review/clients/github/pr/schema/comments.py,sha256=K9KQ9TmWv9Hjw8uTrCPkzyAVbFohjCCf_rww6Ucj3wM,650
23
+ ai_review/clients/github/pr/schema/files.py,sha256=mLHg1CfXUKCdQf5YUtuJ8n6xOROKoAjiJY5PL70kP-w,269
24
+ ai_review/clients/github/pr/schema/pull_request.py,sha256=sdSvPgBkspW2DVO9GIyiqdhTngaVFFpYMCgcc5kFf8I,573
25
+ ai_review/clients/github/pr/schema/reviews.py,sha256=v99DLYT5LOAcc18PATIse1mld8J0wKEAaTzUKI70s0c,288
17
26
  ai_review/clients/gitlab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
27
  ai_review/clients/gitlab/client.py,sha256=acMflkHGp8mv0TVLdZ1gmdXkWQPcq609QjmkYWjEmys,1136
19
28
  ai_review/clients/gitlab/mr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -36,14 +45,15 @@ ai_review/libs/config/artifacts.py,sha256=8BzbQu5GxwV6i6qzrUKM1De1Ogb00Ph5WTqwZ3
36
45
  ai_review/libs/config/base.py,sha256=sPf3OKeF1ID0ouOwiVaUtvpWuZXJXQvIw5kbnPUyN9o,686
37
46
  ai_review/libs/config/claude.py,sha256=E9AJmszfY4TH8PkJjnDDDJYNAU9bLGsUThM3kriVA58,302
38
47
  ai_review/libs/config/gemini.py,sha256=sXHud43LWb4xTvhdkGQeHSLC7qvWl5LfU41fgcIVE5E,274
48
+ ai_review/libs/config/github.py,sha256=1yFfvkTOt5ernIrxjqmiUKDpbEEHpa6lTpDiFQ5gVn4,238
39
49
  ai_review/libs/config/gitlab.py,sha256=VFvoVtni86tWky6Y34XCYdNrBuAtbgFFYGK3idPSOS4,234
40
50
  ai_review/libs/config/http.py,sha256=QsIj0yH1IYELOFBQ5AoqPZT0kGIIrQ19cxk1ozPRhLE,345
41
51
  ai_review/libs/config/llm.py,sha256=cK-e4NCQxnnixLATCsO8-r5k3zUWz1N0BdPCoqerORM,1824
42
52
  ai_review/libs/config/logger.py,sha256=oPmjpjf6EZwW7CgOjT8mOQdGnT98CLwXepiGB_ajZvU,384
43
53
  ai_review/libs/config/openai.py,sha256=vOYqhUq0ceEuNdQrQaHq44lVS5M648mB61Zc4YlfJVw,271
44
- ai_review/libs/config/prompt.py,sha256=e5jmHsfC6WpnkYZpTLT9TyKQfGtsbqJbxMkJBmLAWf0,4434
54
+ ai_review/libs/config/prompt.py,sha256=8aO5WNnhVhQcpWzWxqzb9lq6PzormaJazVwPHuf_ia8,4469
45
55
  ai_review/libs/config/review.py,sha256=LEZni68iH_0m4URPfN0d3F6yrrK7KSn-BwXf-7w2al8,1058
46
- ai_review/libs/config/vcs.py,sha256=FduvJJkGObh2LgTSapaMB6FABIvjX7E63yTwZF4p8CU,517
56
+ ai_review/libs/config/vcs.py,sha256=ULuLicuulFgG-_sTuDWsldyVWIT2SkiS8brPUU1svgk,778
47
57
  ai_review/libs/constants/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
58
  ai_review/libs/constants/llm_provider.py,sha256=sKnDLylCIIosYjq0-0r91LMiYJ4DlHVH2jeRDv_DlsQ,121
49
59
  ai_review/libs/constants/vcs_provider.py,sha256=mZMC8DWIDWQ1YeUZh1a1jduX5enOAe1rWeza0RBmpTY,99
@@ -98,7 +108,8 @@ ai_review/services/llm/openai/client.py,sha256=WhMXNfH_G1NTlFkdRK5sgYvrCIE5ZQNfP
98
108
  ai_review/services/prompt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
99
109
  ai_review/services/prompt/adapter.py,sha256=humGHLRVBu0JspeULgYHCs782BAy4YYKSf5yaG8aF24,1003
100
110
  ai_review/services/prompt/schema.py,sha256=erAecUYzOWyZfixt-pjmPSnvcMDh5DajMd1b7_SPm_o,2052
101
- ai_review/services/prompt/service.py,sha256=VsY8mj6UvY1a4Zsb8JlDJIg_8l7LBW6PXrObiHCwCzo,2128
111
+ ai_review/services/prompt/service.py,sha256=D1PR2HC4cgrEND6mAhU5EPRAtp4mgEkOLEyD51WBc0g,2129
112
+ ai_review/services/prompt/tools.py,sha256=-gS74mVM3OZPKWQkY9_QfStkfxaUfssDbJ3Bdi4AQ74,636
102
113
  ai_review/services/review/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
103
114
  ai_review/services/review/service.py,sha256=8YhRFqhZAk2pAnkDaytKSCENlOeOti1brAJq3R9tVMY,8394
104
115
  ai_review/services/review/inline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -110,8 +121,10 @@ ai_review/services/review/summary/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeR
110
121
  ai_review/services/review/summary/schema.py,sha256=GipVNWrEKtgZPkytNSrXwzvX9Zq8Pv2wxjXhfJq4D3g,364
111
122
  ai_review/services/review/summary/service.py,sha256=GB7-l4UyjZfUe6yP_8Q-SD1_uDKHM0W-CZJVMiEL8S0,449
112
123
  ai_review/services/vcs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
113
- ai_review/services/vcs/factory.py,sha256=_xp8l1Pn2_yXi5xX1v13SROT3rRbVzFHXP1eTZxVcxI,451
124
+ ai_review/services/vcs/factory.py,sha256=na1AOXgL9oUHqGIdRwT73BofxnkXEFnDr7fL3Sk_hkw,586
114
125
  ai_review/services/vcs/types.py,sha256=o3CJ8bZJ8unB9AKSpS66NwPVkFkweV4R02nCYsNqCko,1270
126
+ ai_review/services/vcs/github/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
127
+ ai_review/services/vcs/github/client.py,sha256=EfFa6DwQ527sfUmq0RWTc-y3t1I2GRRNRVG4A0U-xgY,5477
115
128
  ai_review/services/vcs/gitlab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
116
129
  ai_review/services/vcs/gitlab/client.py,sha256=-ZZFFlB7vv2DgEYAU016FP4CcYO8hp5LY1E2xokuCmU,6140
117
130
  ai_review/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -125,6 +138,8 @@ ai_review/tests/suites/clients/claude/test_schema.py,sha256=MUZXvEROgLNpUVHfCsH5
125
138
  ai_review/tests/suites/clients/gemini/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
126
139
  ai_review/tests/suites/clients/gemini/test_client.py,sha256=6hxpK7r7iZVbOzAffRNDJnA63-3Zxvqw5ynANPhBhBg,1066
127
140
  ai_review/tests/suites/clients/gemini/test_schema.py,sha256=88dU28m7sEWvGx6tqYl7if7weWYuVc8erlkFkKKI3bk,3109
141
+ ai_review/tests/suites/clients/github/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
142
+ ai_review/tests/suites/clients/github/test_client.py,sha256=iCFQRaDPNota21No5SaCwMvWRW4VTJu_MgmeCC4Dk2o,1328
128
143
  ai_review/tests/suites/clients/gitlab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
129
144
  ai_review/tests/suites/clients/gitlab/test_client.py,sha256=vXN7UZLC2yc7P7GZftpVvvUDycqR231ZFnfHZk97VLY,1325
130
145
  ai_review/tests/suites/clients/openai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -147,7 +162,8 @@ ai_review/tests/suites/services/diff/test_service.py,sha256=iFkGX9Vj2X44JU3eFsoH
147
162
  ai_review/tests/suites/services/diff/test_tools.py,sha256=HBQ3eCn-kLeb_k5iTgyr09x0VwLYSegSbxm0Qk9ZrCc,3543
148
163
  ai_review/tests/suites/services/prompt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
149
164
  ai_review/tests/suites/services/prompt/test_schema.py,sha256=XkJk4N9ovgod7G3i6oZwRBjpd71sv0vtVDJhSOfIwGA,2660
150
- ai_review/tests/suites/services/prompt/test_service.py,sha256=M8vvBhEbyHnXCSiIRu7231odn89sPDyCiRMOc2XufC4,5570
165
+ ai_review/tests/suites/services/prompt/test_service.py,sha256=plJ8xDnBifCrLtHJO00cdl11U1EsqSw7lBrEGxu0AIw,6752
166
+ ai_review/tests/suites/services/prompt/test_tools.py,sha256=_yNZoBATvPU5enWNIopbjY8lVVjfaB_46kNIKODhCW4,1981
151
167
  ai_review/tests/suites/services/review/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
152
168
  ai_review/tests/suites/services/review/inline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
153
169
  ai_review/tests/suites/services/review/inline/test_schema.py,sha256=tIz-1UA_GgwcdsyUqgrodiiVVmd_jhoOVmtEwzRVWiY,2474
@@ -157,9 +173,9 @@ ai_review/tests/suites/services/review/policy/test_service.py,sha256=kRWT550OjWY
157
173
  ai_review/tests/suites/services/review/summary/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
158
174
  ai_review/tests/suites/services/review/summary/test_schema.py,sha256=xSoydvABZldHaVDa0OBFvYrj8wMuZqUDN3MO-XdvxOI,819
159
175
  ai_review/tests/suites/services/review/summary/test_service.py,sha256=8UMvi_NL9frm280vD6Q1NCDrdI7K8YbXzoViIus-I2g,541
160
- xai_review-0.11.0.dist-info/licenses/LICENSE,sha256=p-v8m7Kmz4KKc7PcvsGiGEmCw9AiSXY4_ylOPy_u--Y,11343
161
- xai_review-0.11.0.dist-info/METADATA,sha256=HSYx4FRillzjL5CfB97QZZTxnCOlLRoRfDMg_Kufa4w,9618
162
- xai_review-0.11.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
163
- xai_review-0.11.0.dist-info/entry_points.txt,sha256=JyC5URanMi5io5P_PXQf7H_I1OGIpk5cZQhaPQ0g4Zs,53
164
- xai_review-0.11.0.dist-info/top_level.txt,sha256=sTsZbfzLoqvRZKdKa-BcxWvjlHdrpbeJ6DrGY0EuR0E,10
165
- xai_review-0.11.0.dist-info/RECORD,,
176
+ xai_review-0.13.0.dist-info/licenses/LICENSE,sha256=p-v8m7Kmz4KKc7PcvsGiGEmCw9AiSXY4_ylOPy_u--Y,11343
177
+ xai_review-0.13.0.dist-info/METADATA,sha256=mUB-mJITaTeO1i_y4DHOTFl1MyTxsqX6vayu4iBLZZM,9618
178
+ xai_review-0.13.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
179
+ xai_review-0.13.0.dist-info/entry_points.txt,sha256=JyC5URanMi5io5P_PXQf7H_I1OGIpk5cZQhaPQ0g4Zs,53
180
+ xai_review-0.13.0.dist-info/top_level.txt,sha256=sTsZbfzLoqvRZKdKa-BcxWvjlHdrpbeJ6DrGY0EuR0E,10
181
+ xai_review-0.13.0.dist-info/RECORD,,