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/pyproject.toml b/pyproject.toml index 66648d0..803d1ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [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" } ] requires-python = ">=3.13" dependencies = [ + "aiosqlite>=0.21.0", "dynaconf>=3.2.11", "fastapi[standard]>=0.118.0", "gigachat>=0.1.42.post2", @@ -54,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 9a7f616..03d9c9a 100644 --- a/src/revu/application/entities/default_prompts.py +++ b/src/revu/application/entities/default_prompts.py @@ -57,6 +57,10 @@ - следование стандартам проекта (например, PEP8 для Python); - единый стиль именования переменных и функций. +7. Комментарии должны быть краткими, точными и полезными. Избегай ненужных комментариев. +8. Пиши комментарии на русском языке +9. Не пиши положительные комментарии к файлам - только замечания. Всё положительное можно указать в итоговом комментарии. + """ DIFF_PROMPT = """ @@ -208,5 +212,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..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 @@ -14,6 +14,13 @@ class GiteaReviewComment(BaseModel): body: str +class BitbucketReviewComment(BaseModel): + path: str + position: int + body: str + lineType: str + + class ReviewResponse(BaseModel): general_comment: str @@ -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..00d0fb5 100644 --- a/src/revu/domain/entities/dto/ai_provider_dto.py +++ b/src/revu/domain/entities/dto/ai_provider_dto.py @@ -20,10 +20,18 @@ class GiteaReviewCommentDTO: 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/base.py b/src/revu/infrastructure/ai_providers/base.py new file mode 100644 index 0000000..2350fa3 --- /dev/null +++ b/src/revu/infrastructure/ai_providers/base.py @@ -0,0 +1,57 @@ +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 + + @staticmethod + def _get_prompt(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") + + @staticmethod + def _get_comment_prompt() -> str: + """Return the default comment prompt""" + return COMMENT_PROMPT + + @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) + + @staticmethod + def _get_response_model(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..983ba64 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..ac2ddcb 100644 --- a/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py +++ b/src/revu/infrastructure/ai_providers/gigachat/gigachat_port.py @@ -2,30 +2,21 @@ 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.base import BaseAIPort from revu.infrastructure.ai_providers.gigachat.gigachat_adapter import ( GigaChatAdapter, get_gigachat_adapter, ) -class GigaChatPort(AIProviderProtocol): +class GigaChatPort(BaseAIPort): def __init__(self, adapter: GigaChatAdapter) -> None: + super().__init__() self.adapter = adapter - self.system_prompt = get_settings().SYSTEM_PROMPT @staticmethod def _get_chat(system_prompt: str, user_prompt: str) -> Chat: @@ -37,9 +28,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 +39,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..6f02321 100644 --- a/src/revu/infrastructure/ai_providers/openai/openai_port.py +++ b/src/revu/infrastructure/ai_providers/openai/openai_port.py @@ -1,57 +1,36 @@ -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.base import BaseAIPort from revu.infrastructure.ai_providers.openai.openai_adapter import ( OpenAIAdapter, get_openai_adapter, ) -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,8 +38,8 @@ 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, ) 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 f4065b9..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 @@ -3,33 +3,18 @@ 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, -) -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.base import BaseAIPort from revu.infrastructure.ai_providers.openai_compatible.openai_compatible_adapter import ( OpenAICompatibleAdapter, get_openai_compatible_adapter, ) -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( @@ -43,31 +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 _: - 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), @@ -77,8 +56,8 @@ 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, ) diff --git a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py index 202ffc5..75da13c 100755 --- a/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py +++ b/src/revu/infrastructure/ai_providers/yandexgpt/yandexgpt_adapter.py @@ -8,10 +8,12 @@ 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: + # 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 7af2ce1..e7ec79d 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.base import BaseAIPort from revu.infrastructure.ai_providers.yandexgpt.yandexgpt_adapter import ( YandexGPTAdapter, get_yandexgpt_adapter, ) -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 new file mode 100644 index 0000000..68ac95d --- /dev/null +++ b/src/revu/infrastructure/git_providers/bitbucket/bitbucket_port.py @@ -0,0 +1,55 @@ +from urllib.parse import urljoin + +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, +) + + +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 # 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 + + 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} + + 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, 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": 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: + 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) # type: ignore + + await self.send_comment(repo_owner, review.general_comment, index) + + +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..ff8e538 --- /dev/null +++ b/src/revu/infrastructure/git_providers/bitbucket/helpers.py @@ -0,0 +1,23 @@ +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") + 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"@@ -{src_start},{src_span} +{dst_start},{dst_span} @@") + + 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) diff --git a/src/revu/presentation/webhooks/di.py b/src/revu/presentation/webhooks/di.py index 23cf412..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, @@ -34,12 +38,14 @@ ) -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..52de7c7 100644 --- a/src/revu/presentation/webhooks/mappers.py +++ b/src/revu/presentation/webhooks/mappers.py @@ -1,6 +1,7 @@ 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, @@ -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..5eb6954 100644 --- a/src/revu/presentation/webhooks/routes.py +++ b/src/revu/presentation/webhooks/routes.py @@ -6,14 +6,20 @@ 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 ( + bitbucket_to_domain, + gitea_to_domain, + github_to_domain, +) from revu.presentation.webhooks.schemas.github_schemas import ( + BitBucketRawPullRequestWebhook, GiteaPullRequestWebhook, GithubPullRequestWebhook, GitVersePullRequestWebhook, ) from revu.presentation.webhooks.validators import ( gitverse_validate_authorization, + parse_bitbucket_webhook, parse_gitea_webhook, parse_github_webhook, ) @@ -41,6 +47,17 @@ async def gitea_webhook( 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: Annotated[BitBucketRawPullRequestWebhook, Depends(parse_bitbucket_webhook)], + 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) async def gitverse_webhook( webhook_data: GitVersePullRequestWebhook, diff --git a/src/revu/presentation/webhooks/schemas/github_schemas.py b/src/revu/presentation/webhooks/schemas/github_schemas.py index b8b66ba..605944d 100644 --- a/src/revu/presentation/webhooks/schemas/github_schemas.py +++ b/src/revu/presentation/webhooks/schemas/github_schemas.py @@ -32,3 +32,46 @@ class GiteaPullRequestWebhook(GithubPullRequestWebhook): class GitVersePullRequestWebhook(GithubPullRequestWebhook): pass + + +class BitBucketPullRequestWebhook(GithubPullRequestWebhook): + pass + + +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}/repos/{self.pullRequest.toRef.repository.slug}" + ), + ) diff --git a/src/revu/presentation/webhooks/validators.py b/src/revu/presentation/webhooks/validators.py index f51bb1a..a8f4c94 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,27 @@ 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) + 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") + 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") + + return BitBucketRawPullRequestWebhook.model_validate(payload_dict) + + async def parse_gitea_webhook(request: Request) -> GiteaPullRequestWebhook: body = await verify_github_webhook(request=request) diff --git a/tests/conftest.py b/tests/conftest.py index 0793954..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,5 +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 + ) 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 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" },