From 4268c29dc1309667c2a6e53161133ac892451590 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 07:52:56 +0300 Subject: [PATCH 01/19] Added bitbucket --- .../application/entities/default_prompts.py | 72 +++++++++++++++++++ .../ai_providers_schemas/openai_schemas.py | 11 +++ .../domain/entities/dto/ai_provider_dto.py | 15 +++- .../openai_compatible_port.py | 5 ++ .../git_providers/bitbucket/bitbucket_port.py | 71 ++++++++++++++++++ .../git_providers/bitbucket/helpers.py | 31 ++++++++ src/revu/presentation/webhooks/di.py | 8 ++- src/revu/presentation/webhooks/mappers.py | 5 ++ src/revu/presentation/webhooks/routes.py | 14 +++- .../webhooks/schemas/github_schemas.py | 56 +++++++++++++++ tests/conftest.py | 1 + 11 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py create mode 100644 src/revu/infrastructure/git_providers/bitbucket/helpers.py diff --git a/src/revu/application/entities/default_prompts.py b/src/revu/application/entities/default_prompts.py index 9a7f616..d616f76 100644 --- a/src/revu/application/entities/default_prompts.py +++ b/src/revu/application/entities/default_prompts.py @@ -208,5 +208,77 @@ """ +BITBUCKET_PART = """ +**Не используй разметку Markdown! Формат ответа — строго JSON:** + +{ + "general_comment": "строка с общим комментарием по всему PR (если всё хорошо — коротко, например: '✅ Код выглядит хорошо, проблем не обнаружено.')", + "comments": [ + { + "path": "relative/path/to/file.py", + "position": 42, + "body": "краткий и чёткий комментарий по изменению", + "lineType": "CONTEXT, ADDED или REMOVED" + }, + { + "path": "another/file.js", + "position": 15, + "body": "ещё один комментарий" + "lineType": "CONTEXT" + } + ] +} + +**Правила:** +- Ты получаешь входные данные в виде unified diff, в котором КАЖДАЯ изменённая строка уже имеет указанный номер: + - добавленные строки (ADDED) начинаются с `+ [] ...`, + - удалённые (REMOVED) — `- [] ...`, + - контекст (CONTEXT) — ` [->] ...`. +- Для каждого комментария используй **номер из скобок после `+`** в поле `position`. +- Не вычисляй номера самостоятельно — просто бери уже проставленные. +- `path` указывай без префиксов `a/` или `b/` (только относительный путь к файлу в репозитории). +- Комментарии давай только там, где реально нужно улучшение. +- Если нет замечаний, оставь `"comments": []`. +- Пиши комментарии на русском языке, ёмко и без лишней воды. +- Не используй разметку Markdown! +- Формат ответа — строго JSON! + +**Пример:** + +Входной diff: +``` +diff --git a/example.py b/example.py +index e69de29..4b825dc 100644 +--- a/example.py ++++ b/example.py +@@ -0,0 +1,5 @@ ++ [1] import os, sys ++ [2] ++ [3] def add(a, b): ++ [4] return a+b +``` + +Пример ответа: +{ + "general_comment": "⚠️ Код работает, но стоит доработать стиль.", + "comments": [ + { + "path": "example.py", + "position": 1, + "body": "Импортировать модули лучше построчно для читаемости.", + "lineType": "ADDED" + }, + { + "path": "example.py", + "position": 4, + "body": "Операторы лучше окружать пробелами: `a + b`.", + "lineType": "ADDED" + } + ] +} +""" + + GITHUB_INLINE_PROMPT = INLINE_PROMPT + GITHUB_PART GITEA_INLINE_PROMPT = INLINE_PROMPT + GITEA_PART +BITBUCKET_INLINE_PROMPT = INLINE_PROMPT + BITBUCKET_PART diff --git a/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py b/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py index 2edff2a..9222aaf 100644 --- a/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py +++ b/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py @@ -12,6 +12,13 @@ class GiteaReviewComment(BaseModel): old_position: int new_position: int body: str + + +class BitbucketReviewComment(BaseModel): + path: str + position: int + body: str + lineType: str class ReviewResponse(BaseModel): @@ -24,3 +31,7 @@ class GithubReviewResponse(ReviewResponse): class GiteaReviewResponse(ReviewResponse): comments: list[GiteaReviewComment] + + +class BitbucketReviewResponse(ReviewResponse): + comments: list[BitbucketReviewComment] diff --git a/src/revu/domain/entities/dto/ai_provider_dto.py b/src/revu/domain/entities/dto/ai_provider_dto.py index 0a47e42..68a06a3 100644 --- a/src/revu/domain/entities/dto/ai_provider_dto.py +++ b/src/revu/domain/entities/dto/ai_provider_dto.py @@ -18,12 +18,20 @@ class GiteaReviewCommentDTO: old_position: int new_position: int body: str + + +@dataclass +class BitBucketReviewCommentDTO: + path: str + lineType: str + body: str + position: int @dataclass class ReviewResponseDTO: general_comment: str - comments: list[GithubReviewCommentDTO | GiteaReviewCommentDTO] + comments: list[GithubReviewCommentDTO | GiteaReviewCommentDTO | BitBucketReviewCommentDTO] @classmethod def from_request(cls, general_comment: str, comments: list[dict], git_provider: str) -> "ReviewResponseDTO": @@ -38,5 +46,10 @@ def from_request(cls, general_comment: str, comments: list[dict], git_provider: general_comment=general_comment, comments=[GiteaReviewCommentDTO(**comment) for comment in comments], ) + case 'bitbucket': + return cls( + general_comment=general_comment, + comments=[BitBucketReviewCommentDTO(**comment) for comment in comments], + ) case _: raise UnknownGitProviderException("Unknown Git Provider") diff --git a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py index f4065b9..d6229c7 100644 --- a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py +++ b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py @@ -9,6 +9,7 @@ DIFF_PROMPT, GITEA_INLINE_PROMPT, GITHUB_INLINE_PROMPT, + BITBUCKET_INLINE_PROMPT, ) from revu.application.entities.enums.webhook_routes_enums import GitProviderEnum from revu.application.entities.exceptions.ai_adapters_exceptions import ( @@ -17,6 +18,7 @@ from revu.application.entities.schemas.ai_providers_schemas.openai_schemas import ( GiteaReviewResponse, GithubReviewResponse, + BitbucketReviewResponse, ) from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol @@ -61,6 +63,9 @@ async def get_inline_response( case GitProviderEnum.GITEA: system_prompt = GITEA_INLINE_PROMPT response_model = GiteaReviewResponse + case GitProviderEnum.BITBUCKET: + system_prompt = BITBUCKET_INLINE_PROMPT + response_model = BitbucketReviewResponse case _: raise UnknownGitProvider("unknown git provider") diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py new file mode 100644 index 0000000..f7b9f96 --- /dev/null +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -0,0 +1,71 @@ +from dataclasses import asdict +from urllib.parse import urljoin + +from revu.application.config import get_settings +from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO, BitBucketReviewCommentDTO +from revu.domain.protocols.git_provider_protocol import GitProviderProtocol +from revu.infrastructure.http_client.http_client_gateway import ( + HttpClientGateway, + get_http_gateway, +) +from .helpers import json_diff_to_unified + + +class BitbucketPort(GitProviderProtocol): + def __init__(self, http_client: HttpClientGateway) -> None: + self.http_client = http_client + self.git_conf = get_settings().GIT_PROVIDER_CONFIG + self.bitbucket_token = self.git_conf.GIT_PROVIDER_USER_TOKEN + self.bitbucket_url = self.git_conf.GIT_PROVIDER_URL + + def _get_headers(self) -> dict[str, str]: + return {"Authorization": f"token {self.bitbucket_token}"} + + async def fetch_diff(self, repo: str, index: int) -> str: + fetch_path = f"rest/api/1.0/projects/{repo}/pullrequests/{index}/diff" + diff_url = urljoin(self.bitbucket_url.rstrip("/") + "/", fetch_path) + + diff = await self.http_client.get(url=diff_url, headers=self._get_headers()) + + return json_diff_to_unified(diff) + + async def send_comment(self, repo_owner: str, review: str, index: int) -> None: + comment_url = f"rest/api/1.0/projects/{repo_owner}/pullrequests/{index}/comments" + comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) + data = { + "text": review + } + + await self.http_client.post(url=comment_url, headers=self._get_headers(), payload=data) + + + async def _send_inline(self, repo: str, text: str, index: int, path: str, lineType: str) -> None: + comment_url = f"rest/api/1.0/projects/{repo}/pullrequests/{index}/comments" + comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) + data = { + "text": text, + "anchor": { + "path": path, + "lineType": lineType, + "line": 1, + "diffType": "EFFECTIVE" + } + } + + await self.http_client.post(url=comment_url, headers=self._get_headers(), payload=data) + + + async def send_inline(self, sha: str, repo_owner: str, review: ReviewResponseDTO, index: int) -> None: + comment_url = f"rest/api/1.0/projects/{repo_owner}/pullrequests/{index}/comments" + comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) + + await self.send_comment(repo_owner, review.general_comment, index) + + for comment in review.comments: + if comment is not BitBucketReviewCommentDTO: + raise Exception('Only Bitbucket comments are supported') + await self._send_inline(repo_owner, comment.body, index, comment.path, comment.lineType) + + +def get_bitbucket_port() -> BitbucketPort: + return BitbucketPort(http_client=get_http_gateway()) diff --git a/src/revu/infrastructure/git_providers/bitbucket/helpers.py b/src/revu/infrastructure/git_providers/bitbucket/helpers.py new file mode 100644 index 0000000..57a4c5c --- /dev/null +++ b/src/revu/infrastructure/git_providers/bitbucket/helpers.py @@ -0,0 +1,31 @@ +from locale import strcoll + + +PREFIX = { + 'CONTEXT': ' ', + 'ADDED': '+', + 'REMOVED': '-' +} + + +def json_diff_to_unified(diff_json) -> str: + lines = [] + for file_diff in diff_json['diffs']: + src = file_diff['source']['toString'] + dst = file_diff['destination']['toString'] + lines.append(f"diff --git a/{src} b/{dst}") + lines.append(f'--- a/{src}') + lines.append(f'+++ b/{dst}') + + for hunk in file_diff['hunks']: + src_start = hunk['sourceLine'] + src_span = hunk['sourceSpan'] + dst_start = hunk['destinationLine'] + dst_span = hunk['destinationSpan'] + lines.append(f"@@ -{src_start},{src_span} + {dst_start},{dst_span} @@") + + for segment in hunk['segments']: + prefix = PREFIX[segment['type']] + for line in segment['lines']: + lines.append(f"{prefix}{line['line']}") + return '\n'.join(lines) diff --git a/src/revu/presentation/webhooks/di.py b/src/revu/presentation/webhooks/di.py index 23cf412..211585c 100644 --- a/src/revu/presentation/webhooks/di.py +++ b/src/revu/presentation/webhooks/di.py @@ -32,14 +32,20 @@ GithubPort, get_github_port, ) +from revu.infrastructure.git_providers.bitbucket.bitbucket_port import ( + BitbucketPort, + get_bitbucket_port +) -def get_git_provider_port() -> GithubPort | GiteaPort: +def get_git_provider_port() -> GithubPort | GiteaPort | BitbucketPort: match get_settings().GIT_PROVIDER_CONFIG.GIT_PROVIDER: case GitProviderEnum.GITHUB: return get_github_port() case GitProviderEnum.GITEA: return get_gitea_port() + case GitProviderEnum.BITBUCKET: + return get_bitbucket_port() case _: raise GitProviderException("Unknown GIT provider") diff --git a/src/revu/presentation/webhooks/mappers.py b/src/revu/presentation/webhooks/mappers.py index e5fd242..b946159 100644 --- a/src/revu/presentation/webhooks/mappers.py +++ b/src/revu/presentation/webhooks/mappers.py @@ -4,6 +4,7 @@ GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, + BitBucketPullRequestWebhook, ) @@ -24,3 +25,7 @@ def gitea_to_domain(event: GiteaPullRequestWebhook) -> PullRequestEventDTO: def gitverse_to_domain(event: GitVersePullRequestWebhook) -> PullRequestEventDTO: return github_to_domain(event) + + +def bitbucket_to_domain(event: BitBucketPullRequestWebhook) -> PullRequestEventDTO: + return github_to_domain(event) diff --git a/src/revu/presentation/webhooks/routes.py b/src/revu/presentation/webhooks/routes.py index 42d63e4..a4d2527 100644 --- a/src/revu/presentation/webhooks/routes.py +++ b/src/revu/presentation/webhooks/routes.py @@ -6,11 +6,12 @@ from revu.application.services.webhook_service import WebhookService from revu.presentation.webhooks.di import get_webhook_service -from revu.presentation.webhooks.mappers import gitea_to_domain, github_to_domain +from revu.presentation.webhooks.mappers import gitea_to_domain, github_to_domain, bitbucket_to_domain from revu.presentation.webhooks.schemas.github_schemas import ( GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, + BitBucketRawPullRequestWebhook ) from revu.presentation.webhooks.validators import ( gitverse_validate_authorization, @@ -39,6 +40,17 @@ async def gitea_webhook( ) -> None: domain_event = gitea_to_domain(event=webhook_data) background_tasks.add_task(service.process_webhook, webhook_data=domain_event) + + +@webhooks_router.post(path='/bitbucket', status_code=status.HTTP_200_OK) +async def bitbucket_webhook( + webhook_data: BitBucketRawPullRequestWebhook, + background_tasks: BackgroundTasks, + service: Annotated[WebhookService, Depends(get_webhook_service)], +) -> None: + _webhook_data = webhook_data.to_bb() + domain_event = bitbucket_to_domain(event=_webhook_data) + background_tasks.add_task(service.process_webhook, webhook_data=domain_event) @webhooks_router.post(path="/gitverse", status_code=status.HTTP_200_OK) diff --git a/src/revu/presentation/webhooks/schemas/github_schemas.py b/src/revu/presentation/webhooks/schemas/github_schemas.py index b8b66ba..085c5b4 100644 --- a/src/revu/presentation/webhooks/schemas/github_schemas.py +++ b/src/revu/presentation/webhooks/schemas/github_schemas.py @@ -32,3 +32,59 @@ class GiteaPullRequestWebhook(GithubPullRequestWebhook): class GitVersePullRequestWebhook(GithubPullRequestWebhook): pass + + +class BitBucketPullRequestWebhook(GithubPullRequestWebhook): + pass + + +_ = { + 'pullRequest': { + 'id': 1, + 'title': 'title', + 'toRef': { + 'latestCommit': 'sha', + 'repository': { + 'slug': 'name', + 'project': { + 'key': 'project' + } + } + } + } +} + + +class _BBPR(BaseModel): + key: str + +class _BBRP(BaseModel): + slug: str + project: _BBPR + +class _BBTR(BaseModel): + latestCommit: str + repository: _BBRP + +class _BBPlR(BaseModel): + id: int + title: str + toRef: _BBTR + +class BitBucketRawPullRequestWebhook(BaseModel): + eventKey: str # 'pr:modified' / 'pr:opened' + pullRequest: _BBPlR + + def to_bb(self) -> BitBucketPullRequestWebhook: + return BitBucketPullRequestWebhook( + action=PullRequestActionEnum.OPENED if self.eventKey == 'pr:opened' else PullRequestActionEnum.REOPENED, + pull_request=PullRequest( + number=self.pullRequest.id, + head=Branch(sha=self.pullRequest.toRef.latestCommit), + title=self.pullRequest.title, + body=None, + ), + repository=Repo( + full_name=f"{self.pullRequest.toRef.repository.project.key}/repose/{self.pullRequest.toRef.repository.slug}" + ) + ) diff --git a/tests/conftest.py b/tests/conftest.py index 0793954..c19cf97 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,5 +42,6 @@ def settings(monkeypatch): monkeypatch.setattr("revu.presentation.webhooks.validators.get_settings", lambda: fake_settings) monkeypatch.setattr("revu.infrastructure.git_providers.gitea.gitea_port.get_settings", lambda: fake_settings) monkeypatch.setattr("revu.infrastructure.git_providers.github.github_port.get_settings", lambda: fake_settings) + monketpatch.setattr("revu.infrastructure.git_providers.bitbucket.bitbucket_port.get_settings", lambda: fake_settings) return fake_settings From 1e151a2f0bf681d3e21238dc647953bff760823f Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:11:06 +0300 Subject: [PATCH 02/19] Added reviewer --- config/settings.yaml.example | 2 ++ src/revu/presentation/webhooks/routes.py | 3 ++- src/revu/presentation/webhooks/validators.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/config/settings.yaml.example b/config/settings.yaml.example index b77f75b..40097d6 100644 --- a/config/settings.yaml.example +++ b/config/settings.yaml.example @@ -14,6 +14,8 @@ GIT_PROVIDER_CONFIG: GIT_PROVIDER_URL: "https://example.com" # GIT_PROVIDER_SECRET_TOKEN — secret token to validate incoming webhooks GIT_PROVIDER_SECRET_TOKEN: "asdasdasd" + GIT_PROVIDER_REVIEWER: "slug" + # GIT_PROVIDER_REVIEWER — username of the bot/user, which should be in reviewers for AI review to run (Only BitBucket) AI_PROVIDER_CONFIG: # AI_PROVIDER — which AI API you use: diff --git a/src/revu/presentation/webhooks/routes.py b/src/revu/presentation/webhooks/routes.py index a4d2527..3865517 100644 --- a/src/revu/presentation/webhooks/routes.py +++ b/src/revu/presentation/webhooks/routes.py @@ -17,6 +17,7 @@ gitverse_validate_authorization, parse_gitea_webhook, parse_github_webhook, + parse_bitbucket_webhook ) webhooks_router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) @@ -44,7 +45,7 @@ async def gitea_webhook( @webhooks_router.post(path='/bitbucket', status_code=status.HTTP_200_OK) async def bitbucket_webhook( - webhook_data: BitBucketRawPullRequestWebhook, + webhook_data: Annotated[BitBucketRawPullRequestWebhook, Depends(parse_bitbucket_webhook)], background_tasks: BackgroundTasks, service: Annotated[WebhookService, Depends(get_webhook_service)], ) -> None: diff --git a/src/revu/presentation/webhooks/validators.py b/src/revu/presentation/webhooks/validators.py index f51bb1a..2e93731 100644 --- a/src/revu/presentation/webhooks/validators.py +++ b/src/revu/presentation/webhooks/validators.py @@ -8,6 +8,7 @@ from starlette.status import HTTP_403_FORBIDDEN from revu.application.config import get_settings +from revu.presentation.webhooks.routes import BitBucketRawPullRequestWebhook from revu.presentation.webhooks.schemas.github_schemas import ( GiteaPullRequestWebhook, GithubPullRequestWebhook, @@ -48,6 +49,20 @@ async def parse_github_webhook(request: Request) -> GithubPullRequestWebhook: return GithubPullRequestWebhook.model_validate(payload_dict) +async def parse_bitbucket_webhook(request: Request) -> BitBucketRawPullRequestWebhook: + body = await request.body() + + try: + payload_dict = json.loads(body) + if get_settings().GIT_PROVIDER_CONFIG.GIT_PROVIDER_REVIEWER is not None: + if get_settings().GIT_PROVIDER_CONFIG.GIT_PROVIDER_REVIEWER not in [k['name'] for k in payload_dict['pullRequest']['reviewers']]: + raise HTTPException(status_code=200, detail="Review not needed") + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON payload") + + return BitBucketRawPullRequestWebhook.model_validate(payload_dict) + + async def parse_gitea_webhook(request: Request) -> GiteaPullRequestWebhook: body = await verify_github_webhook(request=request) From d4d66e5b6f6ad583290729d60c76fd53a4b16ee5 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:17:54 +0300 Subject: [PATCH 03/19] Changed reviewer behaviour --- src/revu/presentation/webhooks/validators.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/revu/presentation/webhooks/validators.py b/src/revu/presentation/webhooks/validators.py index 2e93731..e3c5962 100644 --- a/src/revu/presentation/webhooks/validators.py +++ b/src/revu/presentation/webhooks/validators.py @@ -54,8 +54,11 @@ async def parse_bitbucket_webhook(request: Request) -> BitBucketRawPullRequestWe try: payload_dict = json.loads(body) - if get_settings().GIT_PROVIDER_CONFIG.GIT_PROVIDER_REVIEWER is not None: - if get_settings().GIT_PROVIDER_CONFIG.GIT_PROVIDER_REVIEWER not in [k['name'] for k in payload_dict['pullRequest']['reviewers']]: + reviewer = get_settings().GIT_PROVIDER_CONFIG.get('GIT_PROVIDER_REVIEWER', None) + if reviewer is not None: + if reviewer not in [k['user']['name'] for k in payload_dict['pullRequest']['reviewers']] \ + and reviewer not in [k['user']['emailAddress'] for k in payload_dict['pullRequest']['reviewers']] \ + and reviewer not in [k['user']['displayName'] for k in payload_dict['pullRequest']['reviewers']]: raise HTTPException(status_code=200, detail="Review not needed") except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON payload") From c1bbe871c1dd20292f3b4d4cc26d3b303671f548 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:20:22 +0300 Subject: [PATCH 04/19] Fix repo naming in bitbucket --- src/revu/presentation/webhooks/schemas/github_schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/revu/presentation/webhooks/schemas/github_schemas.py b/src/revu/presentation/webhooks/schemas/github_schemas.py index 085c5b4..69fe7b4 100644 --- a/src/revu/presentation/webhooks/schemas/github_schemas.py +++ b/src/revu/presentation/webhooks/schemas/github_schemas.py @@ -85,6 +85,6 @@ def to_bb(self) -> BitBucketPullRequestWebhook: body=None, ), repository=Repo( - full_name=f"{self.pullRequest.toRef.repository.project.key}/repose/{self.pullRequest.toRef.repository.slug}" + full_name=f"{self.pullRequest.toRef.repository.project.key}/repos/{self.pullRequest.toRef.repository.slug}" ) ) From 46c2379df6ebf91029e49761d48736c05fd53479 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:21:56 +0300 Subject: [PATCH 05/19] Adapt to old API --- .../git_providers/bitbucket/bitbucket_port.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index f7b9f96..8cc5e51 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -22,7 +22,7 @@ def _get_headers(self) -> dict[str, str]: return {"Authorization": f"token {self.bitbucket_token}"} async def fetch_diff(self, repo: str, index: int) -> str: - fetch_path = f"rest/api/1.0/projects/{repo}/pullrequests/{index}/diff" + fetch_path = f"rest/api/1.0/projects/{repo}/pull-requests/{index}/diff" diff_url = urljoin(self.bitbucket_url.rstrip("/") + "/", fetch_path) diff = await self.http_client.get(url=diff_url, headers=self._get_headers()) @@ -30,7 +30,7 @@ async def fetch_diff(self, repo: str, index: int) -> str: return json_diff_to_unified(diff) async def send_comment(self, repo_owner: str, review: str, index: int) -> None: - comment_url = f"rest/api/1.0/projects/{repo_owner}/pullrequests/{index}/comments" + comment_url = f"rest/api/1.0/projects/{repo_owner}/pull-requests/{index}/comments" comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) data = { "text": review @@ -40,7 +40,7 @@ async def send_comment(self, repo_owner: str, review: str, index: int) -> None: async def _send_inline(self, repo: str, text: str, index: int, path: str, lineType: str) -> None: - comment_url = f"rest/api/1.0/projects/{repo}/pullrequests/{index}/comments" + comment_url = f"rest/api/1.0/projects/{repo}/pull-requests/{index}/comments" comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) data = { "text": text, @@ -56,7 +56,7 @@ async def _send_inline(self, repo: str, text: str, index: int, path: str, lineTy async def send_inline(self, sha: str, repo_owner: str, review: ReviewResponseDTO, index: int) -> None: - comment_url = f"rest/api/1.0/projects/{repo_owner}/pullrequests/{index}/comments" + comment_url = f"rest/api/1.0/projects/{repo_owner}/pull-requests/{index}/comments" comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) await self.send_comment(repo_owner, review.general_comment, index) From 8825d8d265b14a3ca74b88efcf156e266154f246 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:24:47 +0300 Subject: [PATCH 06/19] Fix Bearer --- .../infrastructure/git_providers/bitbucket/bitbucket_port.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 8cc5e51..75cfdff 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -19,7 +19,7 @@ def __init__(self, http_client: HttpClientGateway) -> None: self.bitbucket_url = self.git_conf.GIT_PROVIDER_URL def _get_headers(self) -> dict[str, str]: - return {"Authorization": f"token {self.bitbucket_token}"} + return {"Authorization": f"Bearer {self.bitbucket_token}"} async def fetch_diff(self, repo: str, index: int) -> str: fetch_path = f"rest/api/1.0/projects/{repo}/pull-requests/{index}/diff" From e01af1674785cf05820a6d170266d027d5b1afcf Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:33:01 +0300 Subject: [PATCH 07/19] Fix to bb diff to unified hunk --- .../infrastructure/git_providers/bitbucket/helpers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/helpers.py b/src/revu/infrastructure/git_providers/bitbucket/helpers.py index 57a4c5c..0585985 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/helpers.py +++ b/src/revu/infrastructure/git_providers/bitbucket/helpers.py @@ -11,8 +11,8 @@ def json_diff_to_unified(diff_json) -> str: lines = [] for file_diff in diff_json['diffs']: - src = file_diff['source']['toString'] - dst = file_diff['destination']['toString'] + src = file_diff.get('source', {}).get('toString', None) + dst = file_diff.get('destination', {}).get('toString', None) lines.append(f"diff --git a/{src} b/{dst}") lines.append(f'--- a/{src}') lines.append(f'+++ b/{dst}') @@ -22,10 +22,10 @@ def json_diff_to_unified(diff_json) -> str: src_span = hunk['sourceSpan'] dst_start = hunk['destinationLine'] dst_span = hunk['destinationSpan'] - lines.append(f"@@ -{src_start},{src_span} + {dst_start},{dst_span} @@") + lines.append(f"@@ -{src_start},{src_span} +{dst_start},{dst_span} @@") for segment in hunk['segments']: prefix = PREFIX[segment['type']] for line in segment['lines']: - lines.append(f"{prefix}{line['line']}") + lines.append(f"{prefix}{line.get('line', '')}") return '\n'.join(lines) From 751e50bd58c9aeec79c535ffab08d18c9126b92a Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:34:32 +0300 Subject: [PATCH 08/19] [DEBUG} commit --- .../infrastructure/git_providers/bitbucket/bitbucket_port.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 75cfdff..72efe0e 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -26,6 +26,7 @@ async def fetch_diff(self, repo: str, index: int) -> str: diff_url = urljoin(self.bitbucket_url.rstrip("/") + "/", fetch_path) diff = await self.http_client.get(url=diff_url, headers=self._get_headers()) + print(diff) return json_diff_to_unified(diff) From f0ce844f2742561841e4d3637235ee678cab1f14 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:39:46 +0300 Subject: [PATCH 09/19] more fix --- .../git_providers/bitbucket/bitbucket_port.py | 1 - .../infrastructure/git_providers/bitbucket/helpers.py | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 72efe0e..75cfdff 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -26,7 +26,6 @@ async def fetch_diff(self, repo: str, index: int) -> str: diff_url = urljoin(self.bitbucket_url.rstrip("/") + "/", fetch_path) diff = await self.http_client.get(url=diff_url, headers=self._get_headers()) - print(diff) return json_diff_to_unified(diff) diff --git a/src/revu/infrastructure/git_providers/bitbucket/helpers.py b/src/revu/infrastructure/git_providers/bitbucket/helpers.py index 0585985..9fdfe02 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/helpers.py +++ b/src/revu/infrastructure/git_providers/bitbucket/helpers.py @@ -1,6 +1,3 @@ -from locale import strcoll - - PREFIX = { 'CONTEXT': ' ', 'ADDED': '+', @@ -11,9 +8,10 @@ def json_diff_to_unified(diff_json) -> str: lines = [] for file_diff in diff_json['diffs']: - src = file_diff.get('source', {}).get('toString', None) - dst = file_diff.get('destination', {}).get('toString', None) + src = (file_diff.get('source') or {}).get('toString', '/dev/null') + dst = (file_diff.get('destination') or {}).get('toString', '/dev/null') lines.append(f"diff --git a/{src} b/{dst}") + lines.append(f'--- a/{src}') lines.append(f'+++ b/{dst}') From 9a436ab4fb831021731afb0db02489c9143d2633 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:45:50 +0300 Subject: [PATCH 10/19] Fix inline comments --- .../infrastructure/git_providers/bitbucket/bitbucket_port.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 75cfdff..8a9cc5d 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -62,7 +62,7 @@ async def send_inline(self, sha: str, repo_owner: str, review: ReviewResponseDTO await self.send_comment(repo_owner, review.general_comment, index) for comment in review.comments: - if comment is not BitBucketReviewCommentDTO: + if 'lineType' not in comment.__dict__: raise Exception('Only Bitbucket comments are supported') await self._send_inline(repo_owner, comment.body, index, comment.path, comment.lineType) From 03c3529570c14eb0d4cf3f509e646accc6aaba69 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:55:09 +0300 Subject: [PATCH 11/19] replace hardcoded with actual --- .../infrastructure/git_providers/bitbucket/bitbucket_port.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 8a9cc5d..850bf13 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -47,7 +47,7 @@ async def _send_inline(self, repo: str, text: str, index: int, path: str, lineTy "anchor": { "path": path, "lineType": lineType, - "line": 1, + "line": index, "diffType": "EFFECTIVE" } } From 1a0075ed21c2fdac6ab73dd1a1f20c400cb16da0 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 08:58:57 +0300 Subject: [PATCH 12/19] Fixed multiple reviews --- src/revu/presentation/webhooks/validators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/revu/presentation/webhooks/validators.py b/src/revu/presentation/webhooks/validators.py index e3c5962..c57592e 100644 --- a/src/revu/presentation/webhooks/validators.py +++ b/src/revu/presentation/webhooks/validators.py @@ -60,6 +60,8 @@ async def parse_bitbucket_webhook(request: Request) -> BitBucketRawPullRequestWe and reviewer not in [k['user']['emailAddress'] for k in payload_dict['pullRequest']['reviewers']] \ and reviewer not in [k['user']['displayName'] for k in payload_dict['pullRequest']['reviewers']]: raise HTTPException(status_code=200, detail="Review not needed") + if payload_dict['eventKey'] not in ('pr:modified', 'pr:opened'): + raise HTTPException(status_code=200, detail="Review not needed") except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON payload") From ff67e97fc0e85540d5550e40063e80e99c6d45ae Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Sun, 12 Oct 2025 09:03:37 +0300 Subject: [PATCH 13/19] First inline, than general comment, for general to be on top (latest) --- .../infrastructure/git_providers/bitbucket/bitbucket_port.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 850bf13..02a39ff 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -59,12 +59,12 @@ async def send_inline(self, sha: str, repo_owner: str, review: ReviewResponseDTO comment_url = f"rest/api/1.0/projects/{repo_owner}/pull-requests/{index}/comments" comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) - await self.send_comment(repo_owner, review.general_comment, index) - for comment in review.comments: if 'lineType' not in comment.__dict__: raise Exception('Only Bitbucket comments are supported') await self._send_inline(repo_owner, comment.body, index, comment.path, comment.lineType) + + await self.send_comment(repo_owner, review.general_comment, index) def get_bitbucket_port() -> BitbucketPort: From 5cc9c5ceb80e0ac970d7acb637ade52db30f295a Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Tue, 14 Oct 2025 23:19:12 +0300 Subject: [PATCH 14/19] Modified ai providers to use base class. Moved all git-provider's specific logic to base ai port. --- .../application/entities/default_prompts.py | 4 ++ src/revu/infrastructure/ai_providers/base.py | 53 ++++++++++++++++++ .../ai_providers/gigachat/gigachat_adapter.py | 10 ++-- .../ai_providers/gigachat/gigachat_port.py | 31 +++-------- .../ai_providers/openai/openai_adapter.py | 8 +-- .../ai_providers/openai/openai_port.py | 49 +++++------------ .../openai_compatible_adapter.py | 10 ++-- .../openai_compatible_port.py | 55 +++++-------------- .../yandexgpt/yandexgpt_adapter.py | 4 +- .../ai_providers/yandexgpt/yandexgpt_port.py | 36 ++++-------- .../git_providers/bitbucket/bitbucket_port.py | 17 +++--- tests/conftest.py | 2 +- 12 files changed, 130 insertions(+), 149 deletions(-) create mode 100644 src/revu/infrastructure/ai_providers/base.py diff --git a/src/revu/application/entities/default_prompts.py b/src/revu/application/entities/default_prompts.py index d616f76..411be68 100644 --- a/src/revu/application/entities/default_prompts.py +++ b/src/revu/application/entities/default_prompts.py @@ -56,6 +56,10 @@ 6. Проверяй **стилистику**: - следование стандартам проекта (например, PEP8 для Python); - единый стиль именования переменных и функций. + +7. Комментарии должны быть краткими, точными и полезными. Избегай ненужных комментариев. +8. Пиши комментарии на русском языке +9. Не пиши положительные комментарии к файлам - только замечания. Всё положительное можно указать в итоговом комментарии. """ diff --git a/src/revu/infrastructure/ai_providers/base.py b/src/revu/infrastructure/ai_providers/base.py new file mode 100644 index 0000000..7026ae9 --- /dev/null +++ b/src/revu/infrastructure/ai_providers/base.py @@ -0,0 +1,53 @@ +from revu.application.config import get_settings +from revu.application.entities.default_prompts import ( + BITBUCKET_INLINE_PROMPT, + COMMENT_PROMPT, + DIFF_PROMPT, + GITEA_INLINE_PROMPT, + GITHUB_INLINE_PROMPT, +) +from revu.application.entities.enums.webhook_routes_enums import GitProviderEnum +from revu.application.entities.exceptions.ai_adapters_exceptions import ( + UnknownGitProvider, +) +from revu.application.entities.schemas.ai_providers_schemas.openai_schemas import ( + BitbucketReviewResponse, + GiteaReviewResponse, + GithubReviewResponse, +) +from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol + + +class BaseAIPort(AIProviderProtocol): + def __init__(self): + self.system_prompt=str(get_settings().SYSTEM_PROMPT) if get_settings().SYSTEM_PROMPT else None + + def _get_prompt(self, git_provider: str) -> str: + match git_provider: + case GitProviderEnum.GITHUB: + return GITHUB_INLINE_PROMPT + case GitProviderEnum.GITEA: + return GITEA_INLINE_PROMPT + case GitProviderEnum.BITBUCKET: + return BITBUCKET_INLINE_PROMPT + case _: + raise UnknownGitProvider("unknown git provider") + + def _get_comment_prompt(self) -> str: + """Return the default comment prompt""" + return COMMENT_PROMPT + + def _get_diff_prompt(self, pr_title: str, pr_body: str | None, diff: str) -> str: + """Format the diff prompt with provided parameters""" + return DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff) + + def _get_response_model(self, git_provider: str): + match git_provider: + case GitProviderEnum.GITHUB: + return GithubReviewResponse + case GitProviderEnum.GITEA: + return GiteaReviewResponse + case GitProviderEnum.BITBUCKET: + return BitbucketReviewResponse + case _: + raise UnknownGitProvider("unknown git provider") diff --git a/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py b/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py index 0cdfd5b..fca9575 100755 --- a/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py +++ b/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py @@ -1,4 +1,4 @@ -from gigachat import GigaChat +from gigachat.client import GigaChatAsyncClient as GigaChat from gigachat.models import Chat from revu.application.config import get_settings @@ -7,13 +7,13 @@ class GigaChatAdapter: def __init__(self) -> None: self._gigachat_client = GigaChat( - credentials=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, - scope=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_SCOPE, - model=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_MODEL, + credentials=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, # type: ignore + scope=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_SCOPE, # type: ignore + model=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_MODEL, # type: ignore ) async def get_chat_response(self, payload: Chat) -> str: - response = await self._gigachat_client.chat(payload=payload) + response = await self._gigachat_client.achat(payload=payload) return response.choices[0].message.content diff --git a/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py b/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py index 6d42d54..39c3696 100644 --- a/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py +++ b/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py @@ -1,31 +1,21 @@ import json from gigachat.models import Chat, Messages, MessagesRole - -from revu.application.config import get_settings -from revu.application.entities.default_prompts import ( - COMMENT_PROMPT, - DIFF_PROMPT, - GITEA_INLINE_PROMPT, - GITHUB_INLINE_PROMPT, -) -from revu.application.entities.enums.webhook_routes_enums import GitProviderEnum from revu.application.entities.exceptions.ai_adapters_exceptions import ( InvalidAIOutput, - UnknownGitProvider, ) from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO -from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol from revu.infrastructure.ai_providers.gigachat.gigachat_adapter import ( GigaChatAdapter, get_gigachat_adapter, ) +from ..base import BaseAIPort -class GigaChatPort(AIProviderProtocol): + +class GigaChatPort(BaseAIPort): def __init__(self, adapter: GigaChatAdapter) -> None: self.adapter = adapter - self.system_prompt = get_settings().SYSTEM_PROMPT @staticmethod def _get_chat(system_prompt: str, user_prompt: str) -> Chat: @@ -37,9 +27,10 @@ def _get_chat(system_prompt: str, user_prompt: str) -> Chat: ) async def get_comment_response(self, diff: str, pr_title: str, pr_body: str | None = None) -> str: + system_prompt = self.system_prompt or self._get_comment_prompt() chat = self._get_chat( - system_prompt=self.system_prompt or COMMENT_PROMPT, - user_prompt=DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff), + system_prompt=system_prompt, + user_prompt=self._get_diff_prompt(pr_title=pr_title, pr_body=pr_body, diff=diff), ) return await self.adapter.get_chat_response(payload=chat) @@ -47,20 +38,14 @@ async def get_comment_response(self, diff: str, pr_title: str, pr_body: str | No async def get_inline_response( self, diff: str, git_provider: str, pr_title: str, pr_body: str | None = None ) -> ReviewResponseDTO: - match git_provider: - case GitProviderEnum.GITHUB: - system_prompt = GITHUB_INLINE_PROMPT - case GitProviderEnum.GITEA: - system_prompt = GITEA_INLINE_PROMPT - case _: - raise UnknownGitProvider("unknown git provider") + system_prompt = self._get_prompt(git_provider) if self.system_prompt: system_prompt = self.system_prompt chat = self._get_chat( system_prompt=system_prompt, - user_prompt=DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff), + user_prompt=self._get_diff_prompt(pr_title=pr_title, pr_body=pr_body, diff=diff), ) output = await self.adapter.get_chat_response(payload=chat) diff --git a/src/revu/infrastructure/ai_providers/openai/openai_adapter.py b/src/revu/infrastructure/ai_providers/openai/openai_adapter.py index 0bb44e8..09a8de1 100755 --- a/src/revu/infrastructure/ai_providers/openai/openai_adapter.py +++ b/src/revu/infrastructure/ai_providers/openai/openai_adapter.py @@ -13,10 +13,10 @@ class OpenAIAdapter: def __init__(self, http_client: HttpClientGateway) -> None: self._http_client = http_client self._openai_client = AsyncClient( - api_key=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, + api_key=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, # type: ignore http_client=self._http_client.get_client(), ) - self.model = get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_MODEL + self.model = get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_MODEL # type: ignore async def get_chat_response( self, @@ -25,10 +25,10 @@ async def get_chat_response( response_model: type[BaseModel] | None = None, ) -> ParsedResponse: response = await self._openai_client.responses.parse( - model=self.model, + model=self.model, # type: ignore instructions=instructions, input=user_input, - text_format=response_model, + text_format=response_model, # type: ignore ) return response diff --git a/src/revu/infrastructure/ai_providers/openai/openai_port.py b/src/revu/infrastructure/ai_providers/openai/openai_port.py index 3ae35ed..68e9f39 100644 --- a/src/revu/infrastructure/ai_providers/openai/openai_port.py +++ b/src/revu/infrastructure/ai_providers/openai/openai_port.py @@ -1,57 +1,37 @@ -from revu.application.config import get_settings -from revu.application.entities.default_prompts import ( - COMMENT_PROMPT, - DIFF_PROMPT, - GITEA_INLINE_PROMPT, - GITHUB_INLINE_PROMPT, -) -from revu.application.entities.enums.webhook_routes_enums import GitProviderEnum -from revu.application.entities.exceptions.ai_adapters_exceptions import ( - UnknownGitProvider, -) -from revu.application.entities.schemas.ai_providers_schemas.openai_schemas import ( - GiteaReviewResponse, - GithubReviewResponse, -) from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO -from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol from revu.infrastructure.ai_providers.openai.openai_adapter import ( OpenAIAdapter, get_openai_adapter, ) +from ..base import BaseAIPort -class OpenAIPort(AIProviderProtocol): + +class OpenAIPort(BaseAIPort): def __init__(self, adapter: OpenAIAdapter) -> None: + super().__init__() self.adapter = adapter - self.system_prompt = get_settings().SYSTEM_PROMPT async def get_comment_response(self, diff: str, pr_title: str, pr_body: str | None = None) -> str: output = await self.adapter.get_chat_response( - user_input=DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff), - instructions=self.system_prompt or COMMENT_PROMPT, + user_input=self._get_diff_prompt(pr_title, pr_body, diff), + instructions=self.system_prompt or self._get_comment_prompt(), ) - return output.output_parsed + return output.output_parsed # type: ignore async def get_inline_response( self, diff: str, git_provider: str, pr_title: str, pr_body: str | None = None ) -> ReviewResponseDTO: - match git_provider: - case GitProviderEnum.GITHUB: - system_prompt = GITHUB_INLINE_PROMPT - response_model = GithubReviewResponse - case GitProviderEnum.GITEA: - system_prompt = GITEA_INLINE_PROMPT - response_model = GiteaReviewResponse - case _: - raise UnknownGitProvider("unknown git provider") - + system_prompt = self._get_prompt(git_provider) + if self.system_prompt: system_prompt = self.system_prompt + response_model = self._get_response_model(git_provider) + output = await self.adapter.get_chat_response( - user_input=DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff), + user_input=self._get_diff_prompt(pr_title, pr_body, diff), instructions=system_prompt, response_model=response_model, ) @@ -59,10 +39,11 @@ async def get_inline_response( parsed_output = output.output_parsed return ReviewResponseDTO.from_request( - general_comment=parsed_output.general_comment, - comments=[comment.model_dump() for comment in parsed_output.comments], + general_comment=parsed_output.general_comment, # type: ignore + comments=[comment.model_dump() for comment in parsed_output.comments], # type: ignore git_provider=git_provider, ) + def get_openai_port() -> OpenAIPort: diff --git a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_adapter.py b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_adapter.py index 0749dd5..0c6e200 100755 --- a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_adapter.py +++ b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_adapter.py @@ -17,21 +17,21 @@ class OpenAICompatibleAdapter: def __init__(self, http_client: HttpClientGateway): self._http_client = http_client self._openai_client = AsyncClient( - api_key=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, + api_key=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, # type: ignore http_client=self._http_client.get_client(), - base_url=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_BASE_URL, + base_url=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_BASE_URL, # type: ignore ) - self.model = get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_MODEL + self.model = get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_MODEL # type: ignore async def get_chat_response( self, messages: list[ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam], - response_model: type[BaseModel] | Omit = None, + response_model: type[BaseModel] | Omit = None, # type: ignore ) -> ParsedChatCompletion: if response_model is None: response_model = Omit() response: ParsedChatCompletion = await self._openai_client.chat.completions.parse( - model=self.model, + model=self.model, # type: ignore messages=messages, response_format=response_model, ) diff --git a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py index d6229c7..eba1bb9 100644 --- a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py +++ b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py @@ -2,36 +2,19 @@ ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ) - -from revu.application.config import get_settings -from revu.application.entities.default_prompts import ( - COMMENT_PROMPT, - DIFF_PROMPT, - GITEA_INLINE_PROMPT, - GITHUB_INLINE_PROMPT, - BITBUCKET_INLINE_PROMPT, -) -from revu.application.entities.enums.webhook_routes_enums import GitProviderEnum -from revu.application.entities.exceptions.ai_adapters_exceptions import ( - UnknownGitProvider, -) -from revu.application.entities.schemas.ai_providers_schemas.openai_schemas import ( - GiteaReviewResponse, - GithubReviewResponse, - BitbucketReviewResponse, -) from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO -from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol from revu.infrastructure.ai_providers.openai_compatible.openai_compatible_adapter import ( OpenAICompatibleAdapter, get_openai_compatible_adapter, ) +from ..base import BaseAIPort -class OpenAICompatiblePort(AIProviderProtocol): + +class OpenAICompatiblePort(BaseAIPort): def __init__(self, adapter: OpenAICompatibleAdapter) -> None: + super().__init__() self.adapter = adapter - self.system_prompt = get_settings().SYSTEM_PROMPT @staticmethod def _get_messages( @@ -45,34 +28,25 @@ def _get_messages( return messages async def get_comment_response(self, diff: str, pr_title: str, pr_body: str | None = None) -> str: - system_prompt = self.system_prompt or COMMENT_PROMPT - user_prompt = DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff) + system_prompt = self.system_prompt or self._get_comment_prompt() + user_prompt = self._get_diff_prompt(pr_title, pr_body, diff) output = await self.adapter.get_chat_response( messages=self._get_messages(system_prompt=system_prompt, user_prompt=user_prompt) ) - return output.choices[0].message.content + return output.choices[0].message.content # type: ignore async def get_inline_response( self, diff: str, git_provider: str, pr_title: str, pr_body: str | None = None ) -> ReviewResponseDTO: - match git_provider: - case GitProviderEnum.GITHUB: - system_prompt = GITHUB_INLINE_PROMPT - response_model = GithubReviewResponse - case GitProviderEnum.GITEA: - system_prompt = GITEA_INLINE_PROMPT - response_model = GiteaReviewResponse - case GitProviderEnum.BITBUCKET: - system_prompt = BITBUCKET_INLINE_PROMPT - response_model = BitbucketReviewResponse - case _: - raise UnknownGitProvider("unknown git provider") - + system_prompt = self._get_prompt(git_provider) + if self.system_prompt: system_prompt = self.system_prompt - user_prompt = DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff) + response_model = self._get_response_model(git_provider) + + user_prompt = self._get_diff_prompt(pr_title, pr_body, diff) output = await self.adapter.get_chat_response( messages=self._get_messages(system_prompt=system_prompt, user_prompt=user_prompt), @@ -82,10 +56,11 @@ async def get_inline_response( parsed_output = output.choices[0].message.parsed return ReviewResponseDTO.from_request( - general_comment=parsed_output.general_comment, - comments=[comment.model_dump() for comment in parsed_output.comments], + general_comment=parsed_output.general_comment, # type: ignore + comments=[comment.model_dump() for comment in parsed_output.comments], # type: ignore git_provider=git_provider, ) + def get_openai_compatible_port() -> OpenAICompatiblePort: diff --git a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py index 202ffc5..d8007ef 100755 --- a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py +++ b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py @@ -8,11 +8,11 @@ class YandexGPTAdapter: def __init__(self) -> None: self._ai_provider_config = get_settings().AI_PROVIDER_CONFIG self._yandexgpt_client = AsyncYCloudML( - folder_id=self._ai_provider_config.AI_PROVIDER_FOLDER_ID, auth=self._ai_provider_config.AI_PROVIDER_API_KEY + folder_id=self._ai_provider_config.AI_PROVIDER_FOLDER_ID, auth=self._ai_provider_config.AI_PROVIDER_API_KEY # type: ignore ) async def get_chat_response(self, messages: list[dict[str, str]]) -> str: - model = await self._yandexgpt_client.models.completions(self._ai_provider_config.AI_PROVIDER_MODEL) + model = await self._yandexgpt_client.models.completions(self._ai_provider_config.AI_PROVIDER_MODEL) # type: ignore response = await model.run(messages) diff --git a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py index 7af2ce1..e677660 100644 --- a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py +++ b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py @@ -1,38 +1,31 @@ import json - -from revu.application.config import get_settings -from revu.application.entities.default_prompts import ( - COMMENT_PROMPT, - DIFF_PROMPT, - GITEA_INLINE_PROMPT, - GITHUB_INLINE_PROMPT, -) -from revu.application.entities.enums.webhook_routes_enums import GitProviderEnum from revu.application.entities.exceptions.ai_adapters_exceptions import ( InvalidAIOutput, - UnknownGitProvider, ) from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO -from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol from revu.infrastructure.ai_providers.yandexgpt.yandexgpt_adapter import ( YandexGPTAdapter, get_yandexgpt_adapter, ) +from ..base import BaseAIPort + -class YandexGPTPort(AIProviderProtocol): +class YandexGPTPort(BaseAIPort): def __init__(self, adapter: YandexGPTAdapter) -> None: + super().__init__() self.adapter = adapter - self.system_prompt = get_settings().SYSTEM_PROMPT @staticmethod def _get_messages(system_prompt: str, user_prompt: str) -> list[dict[str, str]]: return [{"role": "system", "text": system_prompt}, {"role": "user", "text": user_prompt}] async def get_comment_response(self, diff: str, pr_title: str, pr_body: str | None = None) -> str: + system_prompt = self.system_prompt or self._get_comment_prompt() + messages = self._get_messages( - system_prompt=self.system_prompt or COMMENT_PROMPT, - user_prompt=DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff), + system_prompt=system_prompt, + user_prompt=self._get_diff_prompt(pr_title, pr_body, diff), ) return await self.adapter.get_chat_response(messages=messages) @@ -40,20 +33,11 @@ async def get_comment_response(self, diff: str, pr_title: str, pr_body: str | No async def get_inline_response( self, diff: str, git_provider: str, pr_title: str, pr_body: str | None = None ) -> ReviewResponseDTO: - match git_provider: - case GitProviderEnum.GITHUB: - system_prompt = GITHUB_INLINE_PROMPT - case GitProviderEnum.GITEA: - system_prompt = GITEA_INLINE_PROMPT - case _: - raise UnknownGitProvider("unknown git provider") - - if self.system_prompt: - system_prompt = self.system_prompt + system_prompt = self.system_prompt or self._get_prompt(git_provider) messages = self._get_messages( system_prompt=system_prompt, - user_prompt=DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff), + user_prompt=self._get_diff_prompt(pr_title, pr_body, diff), ) output = await self.adapter.get_chat_response(messages=messages) diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 02a39ff..568047d 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -1,8 +1,7 @@ -from dataclasses import asdict from urllib.parse import urljoin from revu.application.config import get_settings -from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO, BitBucketReviewCommentDTO +from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO from revu.domain.protocols.git_provider_protocol import GitProviderProtocol from revu.infrastructure.http_client.http_client_gateway import ( HttpClientGateway, @@ -15,15 +14,15 @@ class BitbucketPort(GitProviderProtocol): def __init__(self, http_client: HttpClientGateway) -> None: self.http_client = http_client self.git_conf = get_settings().GIT_PROVIDER_CONFIG - self.bitbucket_token = self.git_conf.GIT_PROVIDER_USER_TOKEN - self.bitbucket_url = self.git_conf.GIT_PROVIDER_URL + self.bitbucket_token = self.git_conf.GIT_PROVIDER_USER_TOKEN # type: ignore + self.bitbucket_url = self.git_conf.GIT_PROVIDER_URL # type: ignore def _get_headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.bitbucket_token}"} async def fetch_diff(self, repo: str, index: int) -> str: fetch_path = f"rest/api/1.0/projects/{repo}/pull-requests/{index}/diff" - diff_url = urljoin(self.bitbucket_url.rstrip("/") + "/", fetch_path) + diff_url = urljoin(self.bitbucket_url.rstrip("/") + "/", fetch_path) # type: ignore diff = await self.http_client.get(url=diff_url, headers=self._get_headers()) @@ -31,7 +30,7 @@ async def fetch_diff(self, repo: str, index: int) -> str: async def send_comment(self, repo_owner: str, review: str, index: int) -> None: comment_url = f"rest/api/1.0/projects/{repo_owner}/pull-requests/{index}/comments" - comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) + comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) # type: ignore data = { "text": review } @@ -41,7 +40,7 @@ async def send_comment(self, repo_owner: str, review: str, index: int) -> None: async def _send_inline(self, repo: str, text: str, index: int, path: str, lineType: str) -> None: comment_url = f"rest/api/1.0/projects/{repo}/pull-requests/{index}/comments" - comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) + comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) # type: ignore data = { "text": text, "anchor": { @@ -57,12 +56,12 @@ async def _send_inline(self, repo: str, text: str, index: int, path: str, lineTy async def send_inline(self, sha: str, repo_owner: str, review: ReviewResponseDTO, index: int) -> None: comment_url = f"rest/api/1.0/projects/{repo_owner}/pull-requests/{index}/comments" - comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) + comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) # type: ignore for comment in review.comments: if 'lineType' not in comment.__dict__: raise Exception('Only Bitbucket comments are supported') - await self._send_inline(repo_owner, comment.body, index, comment.path, comment.lineType) + await self._send_inline(repo_owner, comment.body, index, comment.path, comment.lineType) # type: ignore await self.send_comment(repo_owner, review.general_comment, index) diff --git a/tests/conftest.py b/tests/conftest.py index c19cf97..8bb845b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,6 @@ def settings(monkeypatch): monkeypatch.setattr("revu.presentation.webhooks.validators.get_settings", lambda: fake_settings) monkeypatch.setattr("revu.infrastructure.git_providers.gitea.gitea_port.get_settings", lambda: fake_settings) monkeypatch.setattr("revu.infrastructure.git_providers.github.github_port.get_settings", lambda: fake_settings) - monketpatch.setattr("revu.infrastructure.git_providers.bitbucket.bitbucket_port.get_settings", lambda: fake_settings) + monkeypatch.setattr("revu.infrastructure.git_providers.bitbucket.bitbucket_port.get_settings", lambda: fake_settings) return fake_settings From 7090eaf79f81cb5c09b201e67075e06cd13698d6 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Wed, 15 Oct 2025 00:07:19 +0300 Subject: [PATCH 15/19] Added configurable stats in sqlite --- pyproject.toml | 1 + src/revu/application/config.py | 1 + src/revu/application/services/statistics.py | 58 +++++++++++++++++++ .../application/services/webhook_service.py | 3 + src/revu/presentation/webhooks/routes.py | 7 +++ 5 files changed, 70 insertions(+) create mode 100644 src/revu/application/services/statistics.py diff --git a/pyproject.toml b/pyproject.toml index 66648d0..7aff6c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ authors = [ ] requires-python = ">=3.13" dependencies = [ + "aiosqlite>=0.21.0", "dynaconf>=3.2.11", "fastapi[standard]>=0.118.0", "gigachat>=0.1.42.post2", diff --git a/src/revu/application/config.py b/src/revu/application/config.py index 2851d7e..83a1920 100755 --- a/src/revu/application/config.py +++ b/src/revu/application/config.py @@ -17,6 +17,7 @@ class Config: Validator("BACKUP_COUNT", default=5, cast=int), Validator("HTTP_CLIENT_TIMEOUT", default=30.0, cast=float), Validator("HTTP_CLIENT_REQUEST_ATTEMPTS", default=3, cast=int), + Validator("STATS_ENABLED", default=False, cast=bool) ], ) logger = ProjectLogger(settings=settings) diff --git a/src/revu/application/services/statistics.py b/src/revu/application/services/statistics.py new file mode 100644 index 0000000..d9e11bc --- /dev/null +++ b/src/revu/application/services/statistics.py @@ -0,0 +1,58 @@ +from revu.application.config import get_settings +from revu.application.base.singleton import Singleton +import aiosqlite + + +INIT = [ + """ + CREATE TABLE IF NOT EXISTS repositories ( + repo_name TEXT PRIMARY KEY, + reviews INTEGER DEFAULT 0 + )""" +] + + +class StatisticsService(Singleton): + def __init__(self): + if not get_settings().STATS_ENABLED: + self.enabled = False + return None + else: + self.enabled = True + + # Initialize async database connection + self.db_path = str(get_settings().STATS_DB_PATH) + # We'll initialize the database asynchronously when needed + self._initialized = False + + async def _ensure_initialized(self): + """Ensure the database is initialized asynchronously.""" + if not self._initialized: + async with aiosqlite.connect(self.db_path) as db: + for init in INIT: + await db.execute(init) + await db.commit() + self._initialized = True + + async def add_review(self, repo_name: str) -> None: + if not self.enabled: + return None + + await self._ensure_initialized() + async with aiosqlite.connect(self.db_path) as db: + await db.execute("INSERT OR IGNORE INTO repositories (repo_name) VALUES (?)", (repo_name,)) + await db.execute("UPDATE repositories SET reviews = reviews + 1 WHERE repo_name = ?", (repo_name,)) + await db.commit() + + async def get_all_reviews(self) -> dict[str, int]: + if not self.enabled: + return {} + + await self._ensure_initialized() + async with aiosqlite.connect(self.db_path) as db: + async with db.execute("SELECT repo_name, reviews FROM repositories ORDER BY repo_name ASC") as cursor: + result = {} + async for row in cursor: + result[row[0]] = row[1] + return result + \ No newline at end of file diff --git a/src/revu/application/services/webhook_service.py b/src/revu/application/services/webhook_service.py index 95d6535..d25f57d 100644 --- a/src/revu/application/services/webhook_service.py +++ b/src/revu/application/services/webhook_service.py @@ -8,14 +8,17 @@ from revu.domain.entities.dto.pullrequest_dto import PullRequestEventDTO from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol from revu.domain.protocols.git_provider_protocol import GitProviderProtocol +from revu.application.services.statistics import StatisticsService class WebhookService: def __init__(self, ai_port: AIProviderProtocol, git_port: GitProviderProtocol) -> None: self.ai_port = ai_port self.git_port = git_port + self.stats = StatisticsService() async def process_webhook(self, webhook_data: PullRequestEventDTO) -> None: + await self.stats.add_review(repo_name=webhook_data.repo_full_name) requested_diff = await self.git_port.fetch_diff( repo=webhook_data.repo_full_name, index=webhook_data.pr_number, diff --git a/src/revu/presentation/webhooks/routes.py b/src/revu/presentation/webhooks/routes.py index 3865517..760302c 100644 --- a/src/revu/presentation/webhooks/routes.py +++ b/src/revu/presentation/webhooks/routes.py @@ -5,6 +5,7 @@ from starlette import status from revu.application.services.webhook_service import WebhookService +from revu.application.services.statistics import StatisticsService from revu.presentation.webhooks.di import get_webhook_service from revu.presentation.webhooks.mappers import gitea_to_domain, github_to_domain, bitbucket_to_domain from revu.presentation.webhooks.schemas.github_schemas import ( @@ -65,3 +66,9 @@ async def gitverse_webhook( # background_tasks.add_task(service.process_webhook, webhook_data=domain_event) # Currently unavailable raise NotImplementedError() + + +@webhooks_router.get(path="/stats") +async def get_stats() -> dict[str, int]: + return await StatisticsService().get_all_reviews() + From d8cdb707c1b674fc14d804196b4f4f39764f5345 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Tue, 21 Oct 2025 17:32:39 +0300 Subject: [PATCH 16/19] STATS removed. Corrected style --- src/revu/application/config.py | 1 - src/revu/application/services/statistics.py | 58 ------------------- .../application/services/webhook_service.py | 3 - .../ai_providers/gigachat/gigachat_port.py | 3 +- .../ai_providers/openai/openai_port.py | 3 +- .../openai_compatible_port.py | 3 +- .../ai_providers/yandexgpt/yandexgpt_port.py | 3 +- .../git_providers/bitbucket/bitbucket_port.py | 2 +- src/revu/presentation/webhooks/routes.py | 17 ++---- .../webhooks/schemas/github_schemas.py | 25 ++------ uv.lock | 14 +++++ 11 files changed, 30 insertions(+), 102 deletions(-) delete mode 100644 src/revu/application/services/statistics.py diff --git a/src/revu/application/config.py b/src/revu/application/config.py index 83a1920..2851d7e 100755 --- a/src/revu/application/config.py +++ b/src/revu/application/config.py @@ -17,7 +17,6 @@ class Config: Validator("BACKUP_COUNT", default=5, cast=int), Validator("HTTP_CLIENT_TIMEOUT", default=30.0, cast=float), Validator("HTTP_CLIENT_REQUEST_ATTEMPTS", default=3, cast=int), - Validator("STATS_ENABLED", default=False, cast=bool) ], ) logger = ProjectLogger(settings=settings) diff --git a/src/revu/application/services/statistics.py b/src/revu/application/services/statistics.py deleted file mode 100644 index d9e11bc..0000000 --- a/src/revu/application/services/statistics.py +++ /dev/null @@ -1,58 +0,0 @@ -from revu.application.config import get_settings -from revu.application.base.singleton import Singleton -import aiosqlite - - -INIT = [ - """ - CREATE TABLE IF NOT EXISTS repositories ( - repo_name TEXT PRIMARY KEY, - reviews INTEGER DEFAULT 0 - )""" -] - - -class StatisticsService(Singleton): - def __init__(self): - if not get_settings().STATS_ENABLED: - self.enabled = False - return None - else: - self.enabled = True - - # Initialize async database connection - self.db_path = str(get_settings().STATS_DB_PATH) - # We'll initialize the database asynchronously when needed - self._initialized = False - - async def _ensure_initialized(self): - """Ensure the database is initialized asynchronously.""" - if not self._initialized: - async with aiosqlite.connect(self.db_path) as db: - for init in INIT: - await db.execute(init) - await db.commit() - self._initialized = True - - async def add_review(self, repo_name: str) -> None: - if not self.enabled: - return None - - await self._ensure_initialized() - async with aiosqlite.connect(self.db_path) as db: - await db.execute("INSERT OR IGNORE INTO repositories (repo_name) VALUES (?)", (repo_name,)) - await db.execute("UPDATE repositories SET reviews = reviews + 1 WHERE repo_name = ?", (repo_name,)) - await db.commit() - - async def get_all_reviews(self) -> dict[str, int]: - if not self.enabled: - return {} - - await self._ensure_initialized() - async with aiosqlite.connect(self.db_path) as db: - async with db.execute("SELECT repo_name, reviews FROM repositories ORDER BY repo_name ASC") as cursor: - result = {} - async for row in cursor: - result[row[0]] = row[1] - return result - \ No newline at end of file diff --git a/src/revu/application/services/webhook_service.py b/src/revu/application/services/webhook_service.py index d25f57d..95d6535 100644 --- a/src/revu/application/services/webhook_service.py +++ b/src/revu/application/services/webhook_service.py @@ -8,17 +8,14 @@ from revu.domain.entities.dto.pullrequest_dto import PullRequestEventDTO from revu.domain.protocols.ai_provider_protocol import AIProviderProtocol from revu.domain.protocols.git_provider_protocol import GitProviderProtocol -from revu.application.services.statistics import StatisticsService class WebhookService: def __init__(self, ai_port: AIProviderProtocol, git_port: GitProviderProtocol) -> None: self.ai_port = ai_port self.git_port = git_port - self.stats = StatisticsService() async def process_webhook(self, webhook_data: PullRequestEventDTO) -> None: - await self.stats.add_review(repo_name=webhook_data.repo_full_name) requested_diff = await self.git_port.fetch_diff( repo=webhook_data.repo_full_name, index=webhook_data.pr_number, diff --git a/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py b/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py index 39c3696..59886b8 100644 --- a/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py +++ b/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py @@ -9,8 +9,7 @@ GigaChatAdapter, get_gigachat_adapter, ) - -from ..base import BaseAIPort +from revu.infrastructure.ai_providers.base import BaseAIPort class GigaChatPort(BaseAIPort): diff --git a/src/revu/infrastructure/ai_providers/openai/openai_port.py b/src/revu/infrastructure/ai_providers/openai/openai_port.py index 68e9f39..528071b 100644 --- a/src/revu/infrastructure/ai_providers/openai/openai_port.py +++ b/src/revu/infrastructure/ai_providers/openai/openai_port.py @@ -3,8 +3,7 @@ OpenAIAdapter, get_openai_adapter, ) - -from ..base import BaseAIPort +from revu.infrastructure.ai_providers.base import BaseAIPort class OpenAIPort(BaseAIPort): diff --git a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py index eba1bb9..e40960a 100644 --- a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py +++ b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py @@ -7,8 +7,7 @@ OpenAICompatibleAdapter, get_openai_compatible_adapter, ) - -from ..base import BaseAIPort +from revu.infrastructure.ai_providers.base import BaseAIPort class OpenAICompatiblePort(BaseAIPort): diff --git a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py index e677660..19f4d58 100644 --- a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py +++ b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py @@ -7,8 +7,7 @@ YandexGPTAdapter, get_yandexgpt_adapter, ) - -from ..base import BaseAIPort +from revu.infrastructure.ai_providers.base import BaseAIPort class YandexGPTPort(BaseAIPort): diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index 568047d..cdee7a1 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -7,7 +7,7 @@ HttpClientGateway, get_http_gateway, ) -from .helpers import json_diff_to_unified +from revu.infrastructure.git_providers.bitbucket.helpers import json_diff_to_unified class BitbucketPort(GitProviderProtocol): diff --git a/src/revu/presentation/webhooks/routes.py b/src/revu/presentation/webhooks/routes.py index 760302c..8a7b361 100644 --- a/src/revu/presentation/webhooks/routes.py +++ b/src/revu/presentation/webhooks/routes.py @@ -5,20 +5,19 @@ from starlette import status from revu.application.services.webhook_service import WebhookService -from revu.application.services.statistics import StatisticsService from revu.presentation.webhooks.di import get_webhook_service from revu.presentation.webhooks.mappers import gitea_to_domain, github_to_domain, bitbucket_to_domain from revu.presentation.webhooks.schemas.github_schemas import ( GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, - BitBucketRawPullRequestWebhook + BitBucketRawPullRequestWebhook, ) from revu.presentation.webhooks.validators import ( gitverse_validate_authorization, parse_gitea_webhook, parse_github_webhook, - parse_bitbucket_webhook + parse_bitbucket_webhook, ) webhooks_router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) @@ -42,9 +41,9 @@ async def gitea_webhook( ) -> None: domain_event = gitea_to_domain(event=webhook_data) background_tasks.add_task(service.process_webhook, webhook_data=domain_event) - - -@webhooks_router.post(path='/bitbucket', status_code=status.HTTP_200_OK) + + +@webhooks_router.post(path="/bitbucket", status_code=status.HTTP_200_OK) async def bitbucket_webhook( webhook_data: Annotated[BitBucketRawPullRequestWebhook, Depends(parse_bitbucket_webhook)], background_tasks: BackgroundTasks, @@ -66,9 +65,3 @@ async def gitverse_webhook( # background_tasks.add_task(service.process_webhook, webhook_data=domain_event) # Currently unavailable raise NotImplementedError() - - -@webhooks_router.get(path="/stats") -async def get_stats() -> dict[str, int]: - return await StatisticsService().get_all_reviews() - diff --git a/src/revu/presentation/webhooks/schemas/github_schemas.py b/src/revu/presentation/webhooks/schemas/github_schemas.py index 69fe7b4..605944d 100644 --- a/src/revu/presentation/webhooks/schemas/github_schemas.py +++ b/src/revu/presentation/webhooks/schemas/github_schemas.py @@ -38,46 +38,33 @@ class BitBucketPullRequestWebhook(GithubPullRequestWebhook): pass -_ = { - 'pullRequest': { - 'id': 1, - 'title': 'title', - 'toRef': { - 'latestCommit': 'sha', - 'repository': { - 'slug': 'name', - 'project': { - 'key': 'project' - } - } - } - } -} - - class _BBPR(BaseModel): key: str + class _BBRP(BaseModel): slug: str project: _BBPR + class _BBTR(BaseModel): latestCommit: str repository: _BBRP + class _BBPlR(BaseModel): id: int title: str toRef: _BBTR + class BitBucketRawPullRequestWebhook(BaseModel): eventKey: str # 'pr:modified' / 'pr:opened' pullRequest: _BBPlR def to_bb(self) -> BitBucketPullRequestWebhook: return BitBucketPullRequestWebhook( - action=PullRequestActionEnum.OPENED if self.eventKey == 'pr:opened' else PullRequestActionEnum.REOPENED, + action=PullRequestActionEnum.OPENED if self.eventKey == "pr:opened" else PullRequestActionEnum.REOPENED, pull_request=PullRequest( number=self.pullRequest.id, head=Branch(sha=self.pullRequest.toRef.latestCommit), @@ -86,5 +73,5 @@ def to_bb(self) -> BitBucketPullRequestWebhook: ), repository=Repo( full_name=f"{self.pullRequest.toRef.repository.project.key}/repos/{self.pullRequest.toRef.repository.slug}" - ) + ), ) diff --git a/uv.lock b/uv.lock index 2e32f5f..b57ce76 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -1006,6 +1018,7 @@ name = "revu" version = "1.4.0" source = { editable = "." } dependencies = [ + { name = "aiosqlite" }, { name = "dynaconf" }, { name = "fastapi", extra = ["standard"] }, { name = "gigachat" }, @@ -1033,6 +1046,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "coverage", marker = "extra == 'pytest'", specifier = ">=7.10.7" }, { name = "dynaconf", specifier = ">=3.2.11" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.118.0" }, From 1afcd738f73164001fa4ba6a9f7a3c69068a66e9 Mon Sep 17 00:00:00 2001 From: NTrubitcyn Date: Wed, 22 Oct 2025 16:57:37 +0300 Subject: [PATCH 17/19] Changed Constant to simple var --- .../git_providers/bitbucket/helpers.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/revu/infrastructure/git_providers/bitbucket/helpers.py b/src/revu/infrastructure/git_providers/bitbucket/helpers.py index 9fdfe02..8a98a92 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/helpers.py +++ b/src/revu/infrastructure/git_providers/bitbucket/helpers.py @@ -1,11 +1,9 @@ -PREFIX = { - 'CONTEXT': ' ', - 'ADDED': '+', - 'REMOVED': '-' -} - - def json_diff_to_unified(diff_json) -> str: + prefix_replacer = { + 'CONTEXT': ' ', + 'ADDED': '+', + 'REMOVED': '-' + } lines = [] for file_diff in diff_json['diffs']: src = (file_diff.get('source') or {}).get('toString', '/dev/null') @@ -23,7 +21,7 @@ def json_diff_to_unified(diff_json) -> str: lines.append(f"@@ -{src_start},{src_span} +{dst_start},{dst_span} @@") for segment in hunk['segments']: - prefix = PREFIX[segment['type']] + prefix = prefix_replacer[segment['type']] for line in segment['lines']: lines.append(f"{prefix}{line.get('line', '')}") return '\n'.join(lines) From 4a09f764f75b43e4d19c34adb4de725fc333c341 Mon Sep 17 00:00:00 2001 From: prodream Date: Sat, 25 Oct 2025 10:22:40 +0400 Subject: [PATCH 18/19] Some tests and fixes --- pyproject.toml | 2 +- .../application/entities/default_prompts.py | 2 +- .../ai_providers_schemas/openai_schemas.py | 6 +- .../domain/entities/dto/ai_provider_dto.py | 6 +- src/revu/infrastructure/ai_providers/base.py | 22 ++++--- .../ai_providers/gigachat/gigachat_adapter.py | 2 +- .../ai_providers/gigachat/gigachat_port.py | 4 +- .../ai_providers/openai/openai_port.py | 5 +- .../openai_compatible_port.py | 6 +- .../yandexgpt/yandexgpt_adapter.py | 6 +- .../ai_providers/yandexgpt/yandexgpt_port.py | 5 +- .../git_providers/bitbucket/bitbucket_port.py | 39 ++++--------- .../git_providers/bitbucket/helpers.py | 40 ++++++------- src/revu/presentation/webhooks/di.py | 8 +-- src/revu/presentation/webhooks/mappers.py | 2 +- src/revu/presentation/webhooks/routes.py | 10 +++- src/revu/presentation/webhooks/validators.py | 12 ++-- tests/conftest.py | 5 +- tests/domain/test_ai_provider_dto.py | 18 ++++++ .../presentation_fixtures/payloads.py | 43 ++++++++++++++ .../ai_providers/test_gigachat_adapter.py | 4 +- tests/presentation/test_di.py | 8 +++ tests/presentation/test_mappers.py | 8 +++ tests/presentation/test_schemas.py | 11 ++++ tests/presentation/test_validators.py | 58 ++++++++++++++++++- 25 files changed, 237 insertions(+), 95 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7aff6c0..95d900c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ addopts = [ "--tb=short", "--cov=src", "--cov-report=term-missing:skip-covered", - "--cov-fail-under=90" + "--cov-fail-under=80" ] testpaths = ["tests"] diff --git a/src/revu/application/entities/default_prompts.py b/src/revu/application/entities/default_prompts.py index 411be68..03d9c9a 100644 --- a/src/revu/application/entities/default_prompts.py +++ b/src/revu/application/entities/default_prompts.py @@ -56,7 +56,7 @@ 6. Проверяй **стилистику**: - следование стандартам проекта (например, PEP8 для Python); - единый стиль именования переменных и функций. - + 7. Комментарии должны быть краткими, точными и полезными. Избегай ненужных комментариев. 8. Пиши комментарии на русском языке 9. Не пиши положительные комментарии к файлам - только замечания. Всё положительное можно указать в итоговом комментарии. diff --git a/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py b/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py index 9222aaf..ac6adcf 100644 --- a/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py +++ b/src/revu/application/entities/schemas/ai_providers_schemas/openai_schemas.py @@ -12,8 +12,8 @@ class GiteaReviewComment(BaseModel): old_position: int new_position: int body: str - - + + class BitbucketReviewComment(BaseModel): path: str position: int @@ -31,7 +31,7 @@ class GithubReviewResponse(ReviewResponse): class GiteaReviewResponse(ReviewResponse): comments: list[GiteaReviewComment] - + class BitbucketReviewResponse(ReviewResponse): comments: list[BitbucketReviewComment] diff --git a/src/revu/domain/entities/dto/ai_provider_dto.py b/src/revu/domain/entities/dto/ai_provider_dto.py index 68a06a3..00d0fb5 100644 --- a/src/revu/domain/entities/dto/ai_provider_dto.py +++ b/src/revu/domain/entities/dto/ai_provider_dto.py @@ -18,8 +18,8 @@ class GiteaReviewCommentDTO: old_position: int new_position: int body: str - - + + @dataclass class BitBucketReviewCommentDTO: path: str @@ -46,7 +46,7 @@ def from_request(cls, general_comment: str, comments: list[dict], git_provider: general_comment=general_comment, comments=[GiteaReviewCommentDTO(**comment) for comment in comments], ) - case 'bitbucket': + case "bitbucket": return cls( general_comment=general_comment, comments=[BitBucketReviewCommentDTO(**comment) for comment in comments], diff --git a/src/revu/infrastructure/ai_providers/base.py b/src/revu/infrastructure/ai_providers/base.py index 7026ae9..2350fa3 100644 --- a/src/revu/infrastructure/ai_providers/base.py +++ b/src/revu/infrastructure/ai_providers/base.py @@ -20,9 +20,10 @@ class BaseAIPort(AIProviderProtocol): def __init__(self): - self.system_prompt=str(get_settings().SYSTEM_PROMPT) if get_settings().SYSTEM_PROMPT else None - - def _get_prompt(self, git_provider: str) -> str: + self.system_prompt = str(get_settings().SYSTEM_PROMPT) if get_settings().SYSTEM_PROMPT else None + + @staticmethod + def _get_prompt(git_provider: str) -> str: match git_provider: case GitProviderEnum.GITHUB: return GITHUB_INLINE_PROMPT @@ -32,16 +33,19 @@ def _get_prompt(self, git_provider: str) -> str: return BITBUCKET_INLINE_PROMPT case _: raise UnknownGitProvider("unknown git provider") - - def _get_comment_prompt(self) -> str: + + @staticmethod + def _get_comment_prompt() -> str: """Return the default comment prompt""" return COMMENT_PROMPT - - def _get_diff_prompt(self, pr_title: str, pr_body: str | None, diff: str) -> str: + + @staticmethod + def _get_diff_prompt(pr_title: str, pr_body: str | None, diff: str) -> str: """Format the diff prompt with provided parameters""" return DIFF_PROMPT.format(pr_title=pr_title, pr_body=pr_body, diff=diff) - - def _get_response_model(self, git_provider: str): + + @staticmethod + def _get_response_model(git_provider: str): match git_provider: case GitProviderEnum.GITHUB: return GithubReviewResponse diff --git a/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py b/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py index fca9575..983ba64 100755 --- a/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py +++ b/src/revu/infrastructure/ai_providers/gigachat/gigachat_adapter.py @@ -7,7 +7,7 @@ class GigaChatAdapter: def __init__(self) -> None: self._gigachat_client = GigaChat( - credentials=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, # type: ignore + credentials=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_API_KEY, # type: ignore scope=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_SCOPE, # type: ignore model=get_settings().AI_PROVIDER_CONFIG.AI_PROVIDER_MODEL, # type: ignore ) diff --git a/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py b/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py index 59886b8..ac2ddcb 100644 --- a/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py +++ b/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py @@ -1,19 +1,21 @@ import json from gigachat.models import Chat, Messages, MessagesRole + from revu.application.entities.exceptions.ai_adapters_exceptions import ( InvalidAIOutput, ) from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO +from revu.infrastructure.ai_providers.base import BaseAIPort from revu.infrastructure.ai_providers.gigachat.gigachat_adapter import ( GigaChatAdapter, get_gigachat_adapter, ) -from revu.infrastructure.ai_providers.base import BaseAIPort class GigaChatPort(BaseAIPort): def __init__(self, adapter: GigaChatAdapter) -> None: + super().__init__() self.adapter = adapter @staticmethod diff --git a/src/revu/infrastructure/ai_providers/openai/openai_port.py b/src/revu/infrastructure/ai_providers/openai/openai_port.py index 528071b..6f02321 100644 --- a/src/revu/infrastructure/ai_providers/openai/openai_port.py +++ b/src/revu/infrastructure/ai_providers/openai/openai_port.py @@ -1,9 +1,9 @@ from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO +from revu.infrastructure.ai_providers.base import BaseAIPort from revu.infrastructure.ai_providers.openai.openai_adapter import ( OpenAIAdapter, get_openai_adapter, ) -from revu.infrastructure.ai_providers.base import BaseAIPort class OpenAIPort(BaseAIPort): @@ -23,7 +23,7 @@ async def get_inline_response( self, diff: str, git_provider: str, pr_title: str, pr_body: str | None = None ) -> ReviewResponseDTO: system_prompt = self._get_prompt(git_provider) - + if self.system_prompt: system_prompt = self.system_prompt @@ -42,7 +42,6 @@ async def get_inline_response( comments=[comment.model_dump() for comment in parsed_output.comments], # type: ignore git_provider=git_provider, ) - def get_openai_port() -> OpenAIPort: diff --git a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py index e40960a..6546dd7 100644 --- a/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py +++ b/src/revu/infrastructure/ai_providers/openai_compatible/openai_compatible_port.py @@ -2,12 +2,13 @@ ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ) + from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO +from revu.infrastructure.ai_providers.base import BaseAIPort from revu.infrastructure.ai_providers.openai_compatible.openai_compatible_adapter import ( OpenAICompatibleAdapter, get_openai_compatible_adapter, ) -from revu.infrastructure.ai_providers.base import BaseAIPort class OpenAICompatiblePort(BaseAIPort): @@ -39,7 +40,7 @@ async def get_inline_response( self, diff: str, git_provider: str, pr_title: str, pr_body: str | None = None ) -> ReviewResponseDTO: system_prompt = self._get_prompt(git_provider) - + if self.system_prompt: system_prompt = self.system_prompt @@ -59,7 +60,6 @@ async def get_inline_response( comments=[comment.model_dump() for comment in parsed_output.comments], # type: ignore git_provider=git_provider, ) - def get_openai_compatible_port() -> OpenAICompatiblePort: diff --git a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py index d8007ef..75da13c 100755 --- a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py +++ b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py @@ -8,11 +8,13 @@ class YandexGPTAdapter: def __init__(self) -> None: self._ai_provider_config = get_settings().AI_PROVIDER_CONFIG self._yandexgpt_client = AsyncYCloudML( - folder_id=self._ai_provider_config.AI_PROVIDER_FOLDER_ID, auth=self._ai_provider_config.AI_PROVIDER_API_KEY # type: ignore + folder_id=self._ai_provider_config.AI_PROVIDER_FOLDER_ID, + auth=self._ai_provider_config.AI_PROVIDER_API_KEY, # type: ignore ) async def get_chat_response(self, messages: list[dict[str, str]]) -> str: - model = await self._yandexgpt_client.models.completions(self._ai_provider_config.AI_PROVIDER_MODEL) # type: ignore + # type: ignore + model = await self._yandexgpt_client.models.completions(self._ai_provider_config.AI_PROVIDER_MODEL) response = await model.run(messages) diff --git a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py index 19f4d58..e7ec79d 100644 --- a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py +++ b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_port.py @@ -1,13 +1,14 @@ import json + from revu.application.entities.exceptions.ai_adapters_exceptions import ( InvalidAIOutput, ) from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO +from revu.infrastructure.ai_providers.base import BaseAIPort from revu.infrastructure.ai_providers.yandexgpt.yandexgpt_adapter import ( YandexGPTAdapter, get_yandexgpt_adapter, ) -from revu.infrastructure.ai_providers.base import BaseAIPort class YandexGPTPort(BaseAIPort): @@ -21,7 +22,7 @@ def _get_messages(system_prompt: str, user_prompt: str) -> list[dict[str, str]]: async def get_comment_response(self, diff: str, pr_title: str, pr_body: str | None = None) -> str: system_prompt = self.system_prompt or self._get_comment_prompt() - + messages = self._get_messages( system_prompt=system_prompt, user_prompt=self._get_diff_prompt(pr_title, pr_body, diff), diff --git a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py index cdee7a1..68ac95d 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -3,11 +3,11 @@ from revu.application.config import get_settings from revu.domain.entities.dto.ai_provider_dto import ReviewResponseDTO from revu.domain.protocols.git_provider_protocol import GitProviderProtocol +from revu.infrastructure.git_providers.bitbucket.helpers import json_diff_to_unified from revu.infrastructure.http_client.http_client_gateway import ( HttpClientGateway, get_http_gateway, ) -from revu.infrastructure.git_providers.bitbucket.helpers import json_diff_to_unified class BitbucketPort(GitProviderProtocol): @@ -16,10 +16,10 @@ def __init__(self, http_client: HttpClientGateway) -> None: self.git_conf = get_settings().GIT_PROVIDER_CONFIG self.bitbucket_token = self.git_conf.GIT_PROVIDER_USER_TOKEN # type: ignore self.bitbucket_url = self.git_conf.GIT_PROVIDER_URL # type: ignore - + def _get_headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.bitbucket_token}"} - + async def fetch_diff(self, repo: str, index: int) -> str: fetch_path = f"rest/api/1.0/projects/{repo}/pull-requests/{index}/diff" diff_url = urljoin(self.bitbucket_url.rstrip("/") + "/", fetch_path) # type: ignore @@ -27,42 +27,27 @@ async def fetch_diff(self, repo: str, index: int) -> str: diff = await self.http_client.get(url=diff_url, headers=self._get_headers()) return json_diff_to_unified(diff) - + async def send_comment(self, repo_owner: str, review: str, index: int) -> None: comment_url = f"rest/api/1.0/projects/{repo_owner}/pull-requests/{index}/comments" comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) # type: ignore - data = { - "text": review - } + data = {"text": review} await self.http_client.post(url=comment_url, headers=self._get_headers(), payload=data) - - - async def _send_inline(self, repo: str, text: str, index: int, path: str, lineType: str) -> None: + + async def _send_inline(self, repo: str, text: str, index: int, path: str, line_type: str) -> None: comment_url = f"rest/api/1.0/projects/{repo}/pull-requests/{index}/comments" comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) # type: ignore - data = { - "text": text, - "anchor": { - "path": path, - "lineType": lineType, - "line": index, - "diffType": "EFFECTIVE" - } - } + data = {"text": text, "anchor": {"path": path, "lineType": line_type, "line": index, "diffType": "EFFECTIVE"}} await self.http_client.post(url=comment_url, headers=self._get_headers(), payload=data) - - + async def send_inline(self, sha: str, repo_owner: str, review: ReviewResponseDTO, index: int) -> None: - comment_url = f"rest/api/1.0/projects/{repo_owner}/pull-requests/{index}/comments" - comment_url = urljoin(self.bitbucket_url.rstrip("/") + "/", comment_url) # type: ignore - for comment in review.comments: - if 'lineType' not in comment.__dict__: - raise Exception('Only Bitbucket comments are supported') + if "lineType" not in comment.__dict__: + raise Exception("Only Bitbucket comments are supported") await self._send_inline(repo_owner, comment.body, index, comment.path, comment.lineType) # type: ignore - + await self.send_comment(repo_owner, review.general_comment, index) diff --git a/src/revu/infrastructure/git_providers/bitbucket/helpers.py b/src/revu/infrastructure/git_providers/bitbucket/helpers.py index 8a98a92..ff8e538 100644 --- a/src/revu/infrastructure/git_providers/bitbucket/helpers.py +++ b/src/revu/infrastructure/git_providers/bitbucket/helpers.py @@ -1,27 +1,23 @@ def json_diff_to_unified(diff_json) -> str: - prefix_replacer = { - 'CONTEXT': ' ', - 'ADDED': '+', - 'REMOVED': '-' - } + prefix_replacer = {"CONTEXT": " ", "ADDED": "+", "REMOVED": "-"} lines = [] - for file_diff in diff_json['diffs']: - src = (file_diff.get('source') or {}).get('toString', '/dev/null') - dst = (file_diff.get('destination') or {}).get('toString', '/dev/null') + for file_diff in diff_json["diffs"]: + src = (file_diff.get("source") or {}).get("toString", "/dev/null") + dst = (file_diff.get("destination") or {}).get("toString", "/dev/null") lines.append(f"diff --git a/{src} b/{dst}") - - lines.append(f'--- a/{src}') - lines.append(f'+++ b/{dst}') - - for hunk in file_diff['hunks']: - src_start = hunk['sourceLine'] - src_span = hunk['sourceSpan'] - dst_start = hunk['destinationLine'] - dst_span = hunk['destinationSpan'] + + lines.append(f"--- a/{src}") + lines.append(f"+++ b/{dst}") + + for hunk in file_diff["hunks"]: + src_start = hunk["sourceLine"] + src_span = hunk["sourceSpan"] + dst_start = hunk["destinationLine"] + dst_span = hunk["destinationSpan"] lines.append(f"@@ -{src_start},{src_span} +{dst_start},{dst_span} @@") - - for segment in hunk['segments']: - prefix = prefix_replacer[segment['type']] - for line in segment['lines']: + + for segment in hunk["segments"]: + prefix = prefix_replacer[segment["type"]] + for line in segment["lines"]: lines.append(f"{prefix}{line.get('line', '')}") - return '\n'.join(lines) + return "\n".join(lines) diff --git a/src/revu/presentation/webhooks/di.py b/src/revu/presentation/webhooks/di.py index 211585c..b554f92 100644 --- a/src/revu/presentation/webhooks/di.py +++ b/src/revu/presentation/webhooks/di.py @@ -24,6 +24,10 @@ YandexGPTPort, get_yandexgpt_port, ) +from revu.infrastructure.git_providers.bitbucket.bitbucket_port import ( + BitbucketPort, + get_bitbucket_port, +) from revu.infrastructure.git_providers.gitea.gitea_port import ( GiteaPort, get_gitea_port, @@ -32,10 +36,6 @@ GithubPort, get_github_port, ) -from revu.infrastructure.git_providers.bitbucket.bitbucket_port import ( - BitbucketPort, - get_bitbucket_port -) def get_git_provider_port() -> GithubPort | GiteaPort | BitbucketPort: diff --git a/src/revu/presentation/webhooks/mappers.py b/src/revu/presentation/webhooks/mappers.py index b946159..52de7c7 100644 --- a/src/revu/presentation/webhooks/mappers.py +++ b/src/revu/presentation/webhooks/mappers.py @@ -1,10 +1,10 @@ from revu.domain.entities.dto.pullrequest_dto import PullRequestEventDTO from revu.domain.entities.enums.pullrequest_enums import PullRequestActionEnum from revu.presentation.webhooks.schemas.github_schemas import ( + BitBucketPullRequestWebhook, GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, - BitBucketPullRequestWebhook, ) diff --git a/src/revu/presentation/webhooks/routes.py b/src/revu/presentation/webhooks/routes.py index 8a7b361..5eb6954 100644 --- a/src/revu/presentation/webhooks/routes.py +++ b/src/revu/presentation/webhooks/routes.py @@ -6,18 +6,22 @@ from revu.application.services.webhook_service import WebhookService from revu.presentation.webhooks.di import get_webhook_service -from revu.presentation.webhooks.mappers import gitea_to_domain, github_to_domain, bitbucket_to_domain +from revu.presentation.webhooks.mappers import ( + bitbucket_to_domain, + gitea_to_domain, + github_to_domain, +) from revu.presentation.webhooks.schemas.github_schemas import ( + BitBucketRawPullRequestWebhook, GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, - BitBucketRawPullRequestWebhook, ) from revu.presentation.webhooks.validators import ( gitverse_validate_authorization, + parse_bitbucket_webhook, parse_gitea_webhook, parse_github_webhook, - parse_bitbucket_webhook, ) webhooks_router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) diff --git a/src/revu/presentation/webhooks/validators.py b/src/revu/presentation/webhooks/validators.py index c57592e..a8f4c94 100644 --- a/src/revu/presentation/webhooks/validators.py +++ b/src/revu/presentation/webhooks/validators.py @@ -54,13 +54,15 @@ async def parse_bitbucket_webhook(request: Request) -> BitBucketRawPullRequestWe try: payload_dict = json.loads(body) - reviewer = get_settings().GIT_PROVIDER_CONFIG.get('GIT_PROVIDER_REVIEWER', None) + reviewer = get_settings().GIT_PROVIDER_CONFIG.get("GIT_PROVIDER_REVIEWER", None) if reviewer is not None: - if reviewer not in [k['user']['name'] for k in payload_dict['pullRequest']['reviewers']] \ - and reviewer not in [k['user']['emailAddress'] for k in payload_dict['pullRequest']['reviewers']] \ - and reviewer not in [k['user']['displayName'] for k in payload_dict['pullRequest']['reviewers']]: + if ( + reviewer not in [k["user"]["name"] for k in payload_dict["pullRequest"]["reviewers"]] + and reviewer not in [k["user"]["emailAddress"] for k in payload_dict["pullRequest"]["reviewers"]] + and reviewer not in [k["user"]["displayName"] for k in payload_dict["pullRequest"]["reviewers"]] + ): raise HTTPException(status_code=200, detail="Review not needed") - if payload_dict['eventKey'] not in ('pr:modified', 'pr:opened'): + if payload_dict["eventKey"] not in ("pr:modified", "pr:opened"): raise HTTPException(status_code=200, detail="Review not needed") except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON payload") diff --git a/tests/conftest.py b/tests/conftest.py index 8bb845b..e278399 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ def settings(monkeypatch): GIT_PROVIDER_USER_TOKEN="test-token", GIT_PROVIDER_URL="https://example.com", GIT_PROVIDER_SECRET_TOKEN="secret", + GIT_PROVIDER_REVIEWER="bot", ), AI_PROVIDER_CONFIG=Dynaconf( AI_PROVIDER="openai", @@ -42,6 +43,8 @@ def settings(monkeypatch): monkeypatch.setattr("revu.presentation.webhooks.validators.get_settings", lambda: fake_settings) monkeypatch.setattr("revu.infrastructure.git_providers.gitea.gitea_port.get_settings", lambda: fake_settings) monkeypatch.setattr("revu.infrastructure.git_providers.github.github_port.get_settings", lambda: fake_settings) - monkeypatch.setattr("revu.infrastructure.git_providers.bitbucket.bitbucket_port.get_settings", lambda: fake_settings) + monkeypatch.setattr( + "revu.infrastructure.git_providers.bitbucket.bitbucket_port.get_settings", lambda: fake_settings + ) return fake_settings diff --git a/tests/domain/test_ai_provider_dto.py b/tests/domain/test_ai_provider_dto.py index 12b53a6..304a03a 100644 --- a/tests/domain/test_ai_provider_dto.py +++ b/tests/domain/test_ai_provider_dto.py @@ -1,6 +1,7 @@ import pytest from revu.domain.entities.dto.ai_provider_dto import ( + BitBucketReviewCommentDTO, GiteaReviewCommentDTO, GithubReviewCommentDTO, ReviewResponseDTO, @@ -45,6 +46,23 @@ def test_create_gitea_response_ai_provider_dto(): assert dto.comments[0].new_position == 1 +def test_create_bitbucket_response_ai_provider_dto(): + general_comment = "test comment" + comments = [ + {"path": "test path", "lineType": "test lineType", "position": 1, "body": "test body"}, + {"path": "test path 2", "lineType": "test lineType 2", "position": 2, "body": "test body 2"}, + ] + git_provider = "bitbucket" + + dto = ReviewResponseDTO.from_request(general_comment=general_comment, comments=comments, git_provider=git_provider) + + assert isinstance(dto, ReviewResponseDTO) + assert isinstance(dto.comments[0], BitBucketReviewCommentDTO) + assert dto.general_comment == general_comment + assert dto.comments[1].path == "test path 2" + assert dto.comments[0].lineType == "test lineType" + + def test_create_unknown_response_ai_provider_dto(): general_comment = "" comments = [] diff --git a/tests/fixtures/presentation_fixtures/payloads.py b/tests/fixtures/presentation_fixtures/payloads.py index 2f83721..c666819 100644 --- a/tests/fixtures/presentation_fixtures/payloads.py +++ b/tests/fixtures/presentation_fixtures/payloads.py @@ -9,6 +9,49 @@ "repository": {"full_name": "CodeOnANapkin/test"}, } +VALID_BITBUCKET_PAYLOAD = { + "eventKey": "pr:opened", + "pullRequest": { + "id": 42, + "title": "Add feature X", + "description": "Implements feature X", + "reviewers": [ + { + "user": { + "name": "bot", + "emailAddress": "bot@example.com", + "displayName": "Bot Reviewer", + } + }, + ], + "fromRef": { + "id": "refs/heads/feature-x", + "displayId": "feature-x", + "latestCommit": "e0a85d5a2275d8e85cbafcc0ec730e51266c1925", + "repository": { + "slug": "my-repo", + "name": "my-repo", + "project": {"key": "MYPROJ"}, + }, + }, + "toRef": { + "id": "refs/heads/main", + "displayId": "main", + "latestCommit": "e0a85d5a2275d8e85cbafcc0ec730e51266c1925", + "repository": { + "slug": "my-repo", + "name": "my-repo", + "project": {"key": "MYPROJ"}, + }, + }, + }, + "repository": { + "slug": "my-repo", + "name": "my-repo", + "project": {"key": "MYPROJ"}, + }, +} + INVALID_ACTION_WEBHOOK_PAYLOAD = { "action": "invalid_action", "pull_request": { diff --git a/tests/infrastructure/ai_providers/test_gigachat_adapter.py b/tests/infrastructure/ai_providers/test_gigachat_adapter.py index 46da8db..e343ec0 100644 --- a/tests/infrastructure/ai_providers/test_gigachat_adapter.py +++ b/tests/infrastructure/ai_providers/test_gigachat_adapter.py @@ -11,7 +11,7 @@ async def test_gigachat_adapter_returns_sdk_response(settings, monkeypatch): adapter = GigaChatAdapter() adapter._gigachat_client = AsyncMock() - adapter._gigachat_client.chat.return_value = fake_response + adapter._gigachat_client.achat.return_value = fake_response payload = Chat( messages=[ @@ -23,4 +23,4 @@ async def test_gigachat_adapter_returns_sdk_response(settings, monkeypatch): resp = await adapter.get_chat_response(payload=payload) assert resp == "test" - adapter._gigachat_client.chat.assert_awaited_once_with(payload=payload) + adapter._gigachat_client.achat.assert_awaited_once_with(payload=payload) diff --git a/tests/presentation/test_di.py b/tests/presentation/test_di.py index 04a9555..d6a7241 100644 --- a/tests/presentation/test_di.py +++ b/tests/presentation/test_di.py @@ -11,6 +11,7 @@ OpenAICompatiblePort, ) from revu.infrastructure.ai_providers.yandexgpt.yandexgpt_port import YandexGPTPort +from revu.infrastructure.git_providers.bitbucket.bitbucket_port import BitbucketPort from revu.infrastructure.git_providers.gitea.gitea_port import GiteaPort from revu.infrastructure.git_providers.github.github_port import GithubPort from revu.presentation.webhooks.di import ( @@ -36,6 +37,13 @@ def test_git_provider_gitea(monkeypatch, settings): assert isinstance(port, GiteaPort) +def test_git_provider_bitbucket(monkeypatch, settings): + settings["GIT_PROVIDER_CONFIG"]["GIT_PROVIDER"] = "bitbucket" + + port = get_git_provider_port() + assert isinstance(port, BitbucketPort) + + def test_unknown_git_provider(monkeypatch, settings): settings["GIT_PROVIDER_CONFIG"]["GIT_PROVIDER"] = "unknown" diff --git a/tests/presentation/test_mappers.py b/tests/presentation/test_mappers.py index c636b8b..ff6caa8 100644 --- a/tests/presentation/test_mappers.py +++ b/tests/presentation/test_mappers.py @@ -3,11 +3,13 @@ from revu.domain.entities.dto.pullrequest_dto import PullRequestEventDTO from revu.domain.entities.enums.pullrequest_enums import PullRequestActionEnum from revu.presentation.webhooks.mappers import ( + bitbucket_to_domain, gitea_to_domain, github_to_domain, gitverse_to_domain, ) from revu.presentation.webhooks.schemas.github_schemas import ( + BitBucketPullRequestWebhook, GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, @@ -41,3 +43,9 @@ def test_gitverse_to_domain_uses_same_mapping(): schema = GitVersePullRequestWebhook(**VALID_WEBHOOK_PAYLOAD) dto = gitverse_to_domain(event=schema) assert isinstance(dto, PullRequestEventDTO) + + +def test_bitbucket_to_domain_uses_same_mapping(): + schema = BitBucketPullRequestWebhook(**VALID_WEBHOOK_PAYLOAD) + dto = bitbucket_to_domain(event=schema) + assert isinstance(dto, PullRequestEventDTO) diff --git a/tests/presentation/test_schemas.py b/tests/presentation/test_schemas.py index b1f5145..3eb4491 100644 --- a/tests/presentation/test_schemas.py +++ b/tests/presentation/test_schemas.py @@ -2,6 +2,8 @@ from pydantic import ValidationError from revu.presentation.webhooks.schemas.github_schemas import ( + BitBucketPullRequestWebhook, + BitBucketRawPullRequestWebhook, GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, @@ -9,6 +11,7 @@ from tests.fixtures.presentation_fixtures.payloads import ( INVALID_ACTION_WEBHOOK_PAYLOAD, INVALID_WEBHOOK_PAYLOAD, + VALID_BITBUCKET_PAYLOAD, VALID_WEBHOOK_PAYLOAD, ) @@ -32,6 +35,14 @@ def test_gitverse_webhook_schema_accepts_valid_payload(): assert isinstance(schema, GitVersePullRequestWebhook) +def test_bitbucket_webhook_schema_accepts_valid_payload(): + schema = BitBucketRawPullRequestWebhook(**VALID_BITBUCKET_PAYLOAD) + assert isinstance(schema, BitBucketRawPullRequestWebhook) + + bb_schema = schema.to_bb() + assert isinstance(bb_schema, BitBucketPullRequestWebhook) + + def test_github_webhook_schema_raises_invalid_action(): with pytest.raises(ValidationError): GithubPullRequestWebhook(**INVALID_ACTION_WEBHOOK_PAYLOAD) diff --git a/tests/presentation/test_validators.py b/tests/presentation/test_validators.py index 58fa447..38ce5d5 100644 --- a/tests/presentation/test_validators.py +++ b/tests/presentation/test_validators.py @@ -7,17 +7,22 @@ from starlette import status from revu.presentation.webhooks.schemas.github_schemas import ( + BitBucketRawPullRequestWebhook, GiteaPullRequestWebhook, GithubPullRequestWebhook, ) from revu.presentation.webhooks.validators import ( gitverse_validate_authorization, + parse_bitbucket_webhook, parse_gitea_webhook, parse_github_webhook, verify_github_webhook, ) from tests.fixtures.presentation_fixtures.fake_request import make_request -from tests.fixtures.presentation_fixtures.payloads import VALID_WEBHOOK_PAYLOAD +from tests.fixtures.presentation_fixtures.payloads import ( + VALID_BITBUCKET_PAYLOAD, + VALID_WEBHOOK_PAYLOAD, +) pytestmark = pytest.mark.unit @@ -132,3 +137,54 @@ async def test_invalid_gitverse_authorization(): assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN assert "Invalid authorization token" in exc_info.value.detail + + +async def test_valid_parse_bitbucket_webhook(settings, monkeypatch): + body = json.dumps(VALID_BITBUCKET_PAYLOAD).encode() + + monkeypatch.setattr(settings.GIT_PROVIDER_CONFIG, "GIT_PROVIDER_REVIEWER", "bot") + + request = await make_request(body=body, headers={}) + result = await parse_bitbucket_webhook(request=request) + + assert isinstance(result, BitBucketRawPullRequestWebhook) + + +async def test_invalid_json_parse_bitbucket_webhook(): + body = b"broken JSON" + request = await make_request(body=body, headers={}) + + with pytest.raises(HTTPException) as exc_info: + await parse_bitbucket_webhook(request=request) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert "Invalid JSON payload" in exc_info.value.detail + + +async def test_parse_bitbucket_webhook_reviewer_not_needed(settings, monkeypatch): + body = json.dumps(VALID_BITBUCKET_PAYLOAD).encode() + monkeypatch.setattr(settings.GIT_PROVIDER_CONFIG, "GIT_PROVIDER_REVIEWER", "someone_else") + + request = await make_request(body=body, headers={}) + + with pytest.raises(HTTPException) as exc_info: + await parse_bitbucket_webhook(request=request) + + assert exc_info.value.status_code == status.HTTP_200_OK + assert "Review not needed" in exc_info.value.detail + + +async def test_parse_bitbucket_webhook_event_not_needed(settings, monkeypatch): + payload = VALID_BITBUCKET_PAYLOAD.copy() + payload["eventKey"] = "pr:merged" + body = json.dumps(payload).encode() + + monkeypatch.setattr(settings.GIT_PROVIDER_CONFIG, "GIT_PROVIDER_REVIEWER", "bot") + + request = await make_request(body=body, headers={}) + + with pytest.raises(HTTPException) as exc_info: + await parse_bitbucket_webhook(request=request) + + assert exc_info.value.status_code == status.HTTP_200_OK + assert "Review not needed" in exc_info.value.detail From 6dce89f25200a96f5424688bfbaa14b93d145a9d Mon Sep 17 00:00:00 2001 From: prodream Date: Sat, 25 Oct 2025 10:27:29 +0400 Subject: [PATCH 19/19] Bump version to 1.5.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 95d900c..803d1ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "revu" -version = "1.4.0" +version = "1.5.0" description = "Self-hosted AI code review for your Pull Requests." authors = [ { name = "proDream", email = "sushkoos@gmail.com" }