diff --git a/fixtures/github.py b/fixtures/github.py index ee19573a37803e..4862a35687ddea 100644 --- a/fixtures/github.py +++ b/fixtures/github.py @@ -3559,6 +3559,10 @@ "check_run": { "external_id": "4663713", "html_url": "https://github.com/test/repo/runs/4" + }, + "sender": { + "id": 12345678, + "login": "test-user" } }""" @@ -3569,5 +3573,9 @@ "id": 35129377, "full_name": "getsentry/sentry", "html_url": "https://github.com/getsentry/sentry" + }, + "sender": { + "id": 12345678, + "login": "test-user" } }""" diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index 533ee6b58c3fd4..17bfa6b74ed8c5 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -86,6 +86,7 @@ def __call__( event: Mapping[str, Any], organization: Organization, repo: Repository, + integration: RpcIntegration | None = None, **kwargs: Any, ) -> None: ... diff --git a/src/sentry/models/repositorysettings.py b/src/sentry/models/repositorysettings.py index 4ae894d084966b..5a80c398cf21ab 100644 --- a/src/sentry/models/repositorysettings.py +++ b/src/sentry/models/repositorysettings.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from enum import StrEnum from django.contrib.postgres.fields.array import ArrayField @@ -19,6 +20,14 @@ def as_choices(cls) -> tuple[tuple[str, str], ...]: return tuple((trigger.value, trigger.value) for trigger in cls) +@dataclass +class CodeReviewSettings: + """Settings for code review functionality on a repository.""" + + enabled: bool + triggers: list[CodeReviewTrigger] + + @region_silo_model class RepositorySettings(Model): """ @@ -41,3 +50,8 @@ class Meta: db_table = "sentry_repositorysettings" __repr__ = sane_repr("repository_id", "enabled_code_review") + + def get_code_review_settings(self) -> CodeReviewSettings: + """Return code review settings for this repository.""" + triggers = [CodeReviewTrigger(t) for t in self.code_review_triggers] + return CodeReviewSettings(enabled=self.enabled_code_review, triggers=triggers) diff --git a/src/sentry/overwatch/endpoints/overwatch_rpc.py b/src/sentry/overwatch/endpoints/overwatch_rpc.py index 34fdca4b3264f7..4e7962078513a6 100644 --- a/src/sentry/overwatch/endpoints/overwatch_rpc.py +++ b/src/sentry/overwatch/endpoints/overwatch_rpc.py @@ -11,21 +11,20 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features, quotas +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication from sentry.api.base import Endpoint, region_silo_endpoint -from sentry.constants import DEFAULT_CODE_REVIEW_TRIGGERS, DataCategory, ObjectStatus +from sentry.constants import DEFAULT_CODE_REVIEW_TRIGGERS, ObjectStatus from sentry.integrations.services.integration import integration_service from sentry.models.organization import Organization -from sentry.models.organizationcontributors import OrganizationContributors from sentry.models.repository import Repository from sentry.models.repositorysettings import RepositorySettings from sentry.prevent.models import PreventAIConfiguration from sentry.prevent.types.config import PREVENT_AI_CONFIG_DEFAULT, PREVENT_AI_CONFIG_DEFAULT_V1 +from sentry.seer.code_review.billing import passes_code_review_billing_check from sentry.silo.base import SiloMode -from sentry.utils import metrics from sentry.utils.seer import can_use_prevent_ai_features logger = logging.getLogger(__name__) @@ -309,32 +308,11 @@ def _is_eligible_for_code_review( if not code_review_enabled: return False - # Check if contributor exists, and if there's either a seat or quota available. - # NOTE: We explicitly check billing as the source of truth because if the contributor exists, - # then that means that they've opened a PR before, and either have a seat already OR it's their - # "Free action." - try: - contributor = OrganizationContributors.objects.get( - organization_id=organization.id, - integration_id=integration_id, - external_identifier=external_identifier, - ) - - if not quotas.backend.check_seer_quota( - org_id=organization.id, - data_category=DataCategory.SEER_USER, - seat_object=contributor, - ): - return False - - except OrganizationContributors.DoesNotExist: - metrics.incr( - "overwatch.code_review.contributor_not_found", - tags={"organization_id": organization.id, "repository_id": repository_id}, - ) - return False - - return True + return passes_code_review_billing_check( + organization_id=organization.id, + integration_id=integration_id, + external_identifier=external_identifier, + ) @region_silo_endpoint diff --git a/src/sentry/seer/code_review/billing.py b/src/sentry/seer/code_review/billing.py new file mode 100644 index 00000000000000..bba319322e8ca1 --- /dev/null +++ b/src/sentry/seer/code_review/billing.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from sentry import quotas +from sentry.constants import DataCategory +from sentry.models.organizationcontributors import OrganizationContributors +from sentry.utils import metrics + + +def passes_code_review_billing_check( + organization_id: int, + integration_id: int, + external_identifier: str, +) -> bool: + """ + Check if contributor exists, and if there's either a seat or quota available. + NOTE: We explicitly check billing as the source of truth because if the contributor exists, + then that means that they've opened a PR before, and either have a seat already OR it's their + "Free action." + + Returns False if the billing check does not pass for code review feature. + """ + try: + contributor = OrganizationContributors.objects.get( + organization_id=organization_id, + integration_id=integration_id, + external_identifier=external_identifier, + ) + except OrganizationContributors.DoesNotExist: + metrics.incr( + "overwatch.code_review.contributor_not_found", + tags={"organization_id": organization_id}, + ) + return False + + return quotas.backend.check_seer_quota( + org_id=organization_id, + data_category=DataCategory.SEER_USER, + seat_object=contributor, + ) diff --git a/src/sentry/seer/code_review/preflight.py b/src/sentry/seer/code_review/preflight.py new file mode 100644 index 00000000000000..5d485878b7936f --- /dev/null +++ b/src/sentry/seer/code_review/preflight.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from sentry import features +from sentry.constants import ENABLE_PR_REVIEW_TEST_GENERATION_DEFAULT, HIDE_AI_FEATURES_DEFAULT +from sentry.models.organization import Organization +from sentry.models.repository import Repository +from sentry.models.repositorysettings import CodeReviewSettings, RepositorySettings + +DenialReason = str | None + + +@dataclass +class CodeReviewPreflightResult: + allowed: bool + settings: CodeReviewSettings | None = None + denial_reason: DenialReason = None + + +class CodeReviewPreflightService: + def __init__( + self, + organization: Organization, + repo: Repository, + integration_id: int | None = None, + pr_author_external_id: str | None = None, + ): + self.organization = organization + self.repo = repo + self.integration_id = integration_id + self.pr_author_external_id = pr_author_external_id + + repo_settings = RepositorySettings.objects.filter(repository=repo).first() + self._repo_settings = repo_settings.get_code_review_settings() if repo_settings else None + + def check(self) -> CodeReviewPreflightResult: + checks: list[Callable[[], DenialReason]] = [ + self._check_legal_ai_consent, + self._check_org_feature_enablement, + self._check_repo_feature_enablement, + self._check_billing, + ] + + for check in checks: + denial_reason = check() + if denial_reason is not None: + return CodeReviewPreflightResult(allowed=False, denial_reason=denial_reason) + + return CodeReviewPreflightResult(allowed=True, settings=self._repo_settings) + + # ------------------------------------------------------------------------- + # Checks - each returns denial reason (str) or None if valid + # ------------------------------------------------------------------------- + + def _check_legal_ai_consent(self) -> DenialReason: + has_gen_ai_flag = features.has("organizations:gen-ai-features", self.organization) + has_hidden_ai = self.organization.get_option( + "sentry:hide_ai_features", HIDE_AI_FEATURES_DEFAULT + ) + + if not has_gen_ai_flag or has_hidden_ai: + return "org_legal_ai_consent_not_granted" + return None + + def _check_org_feature_enablement(self) -> DenialReason: + # Seat-based orgs are always eligible + if self._is_seat_based_seer_plan_org(): + return None + + # Beta orgs need the legacy toggle enabled + if self._is_code_review_beta_org(): + if self._has_legacy_toggle_enabled(): + return None + return "org_pr_review_legacy_toggle_disabled" + + return "org_not_eligible_for_code_review" + + def _check_repo_feature_enablement(self) -> DenialReason: + if self._is_seat_based_seer_plan_org(): + if self._repo_settings is None or not self._repo_settings.enabled: + return "repo_code_review_disabled" + return None + elif self._is_code_review_beta_org(): + # For beta orgs, all repos are considered enabled + return None + else: + return "repo_code_review_disabled" + + def _check_billing(self) -> DenialReason: + # TODO: Once we're ready to actually gate billing (when it's time for GA), uncomment this + """ + if self.integration_id is None or self.pr_author_external_id is None: + return "billing_missing_contributor_info" + + billing_ok = passes_code_review_billing_check( + organization_id=self.organization.id, + integration_id=self.integration_id, + external_identifier=self.pr_author_external_id, + ) + if not billing_ok: + return "billing_quota_exceeded" + """ + + return None + + # ------------------------------------------------------------------------- + # Org type helpers + # ------------------------------------------------------------------------- + + def _is_seat_based_seer_plan_org(self) -> bool: + return features.has("organizations:seat-based-seer-enabled", self.organization) + + def _is_code_review_beta_org(self) -> bool: + # TODO: Remove the has_legacy_opt_in check once the beta list is frozen + has_beta_flag = features.has("organizations:code-review-beta", self.organization) + has_legacy_opt_in = self.organization.get_option( + "sentry:enable_pr_review_test_generation", False + ) + return has_beta_flag or bool(has_legacy_opt_in) + + def _has_legacy_toggle_enabled(self) -> bool: + return bool( + self.organization.get_option( + "sentry:enable_pr_review_test_generation", + ENABLE_PR_REVIEW_TEST_GENERATION_DEFAULT, + ) + ) diff --git a/src/sentry/seer/code_review/utils.py b/src/sentry/seer/code_review/utils.py index 1e2d522add1a7f..79130ed5c0e480 100644 --- a/src/sentry/seer/code_review/utils.py +++ b/src/sentry/seer/code_review/utils.py @@ -201,3 +201,27 @@ def transform_webhook_to_codegen_request( }, }, } + + +def get_pr_author_id(event: Mapping[str, Any]) -> str | None: + """ + Extract the PR author's GitHub user ID from the webhook payload. + The user information can be found in different locations depending on the webhook type. + """ + # Check issue.user.id (for issue comments on PRs) + if (user_id := event.get("issue", {}).get("user", {}).get("id")) is not None: + return str(user_id) + + # Check pull_request.user.id (for pull request events) + if (user_id := event.get("pull_request", {}).get("user", {}).get("id")) is not None: + return str(user_id) + + # Check user.id (fallback for direct user events) + if (user_id := event.get("user", {}).get("id")) is not None: + return str(user_id) + + # Check sender.id (for check_run events). Sender is the user who triggered the event + if (user_id := event.get("sender", {}).get("id")) is not None: + return str(user_id) + + return None diff --git a/src/sentry/seer/code_review/webhooks/check_run.py b/src/sentry/seer/code_review/webhooks/check_run.py index 2df17e9aa327fc..172bd408cb878c 100644 --- a/src/sentry/seer/code_review/webhooks/check_run.py +++ b/src/sentry/seer/code_review/webhooks/check_run.py @@ -21,7 +21,6 @@ from sentry.models.organization import Organization from sentry.utils import metrics -from ..permissions import has_code_review_enabled from ..utils import SeerEndpoint, make_seer_request logger = logging.getLogger(__name__) @@ -30,7 +29,6 @@ class ErrorStatus(enum.StrEnum): MISSING_ORGANIZATION = "missing_organization" MISSING_ACTION = "missing_action" - CODE_REVIEW_NOT_ENABLED = "code_review_not_enabled" INVALID_PAYLOAD = "invalid_payload" @@ -115,13 +113,6 @@ def handle_check_run_event( if action != GitHubCheckRunAction.REREQUESTED: return - if not has_code_review_enabled(organization): - metrics.incr( - f"{Metrics.ERROR.value}", - tags={**tags, "error_status": ErrorStatus.CODE_REVIEW_NOT_ENABLED.value}, - ) - return - try: validated_event = _validate_github_check_run_event(event) except (ValidationError, ValueError): diff --git a/src/sentry/seer/code_review/webhooks/handlers.py b/src/sentry/seer/code_review/webhooks/handlers.py index d279644523b2b8..5d6cbbdd437b0a 100644 --- a/src/sentry/seer/code_review/webhooks/handlers.py +++ b/src/sentry/seer/code_review/webhooks/handlers.py @@ -5,9 +5,13 @@ from typing import TYPE_CHECKING, Any from sentry.integrations.github.webhook_types import GithubWebhookType +from sentry.integrations.services.integration import RpcIntegration from sentry.models.organization import Organization from sentry.models.repository import Repository +from ..preflight import CodeReviewPreflightService +from ..utils import get_pr_author_id + if TYPE_CHECKING: from sentry.integrations.github.webhook import WebhookProcessor @@ -31,6 +35,7 @@ def handle_webhook_event( event: Mapping[str, Any], organization: Organization, repo: Repository, + integration: RpcIntegration | None = None, **kwargs: Any, ) -> None: """ @@ -41,7 +46,8 @@ def handle_webhook_event( event: The webhook event payload organization: The Sentry organization that the webhook event belongs to repo: The repository that the webhook event is for - **kwargs: Additional keyword arguments including integration + integration: The GitHub integration + **kwargs: Additional keyword arguments """ handler = EVENT_TYPE_TO_HANDLER.get(github_event) if handler is None: @@ -51,10 +57,21 @@ def handle_webhook_event( ) return + preflight = CodeReviewPreflightService( + organization=organization, + repo=repo, + integration_id=integration.id if integration else None, + pr_author_external_id=get_pr_author_id(event), + ).check() + if not preflight.allowed: + # TODO: add metric + return + handler( github_event=github_event, event=event, organization=organization, repo=repo, + integration=integration, **kwargs, ) diff --git a/src/sentry/seer/code_review/webhooks/issue_comment.py b/src/sentry/seer/code_review/webhooks/issue_comment.py index c9e4eabb3c49c6..e6d0088415fcc7 100644 --- a/src/sentry/seer/code_review/webhooks/issue_comment.py +++ b/src/sentry/seer/code_review/webhooks/issue_comment.py @@ -18,7 +18,6 @@ from sentry.models.repositorysettings import CodeReviewTrigger from sentry.utils import metrics -from ..permissions import has_code_review_enabled from ..utils import _get_target_commit_sha logger = logging.getLogger(__name__) @@ -101,9 +100,6 @@ def handle_issue_comment_event( comment_id = comment.get("id") comment_body = comment.get("body") - if not has_code_review_enabled(organization): - return - if not is_pr_review_command(comment_body or ""): return diff --git a/tests/sentry/models/test_repositorysettings.py b/tests/sentry/models/test_repositorysettings.py new file mode 100644 index 00000000000000..f1bc181ace2fc1 --- /dev/null +++ b/tests/sentry/models/test_repositorysettings.py @@ -0,0 +1,78 @@ +import pytest +from django.db import IntegrityError + +from sentry.models.repositorysettings import ( + CodeReviewSettings, + CodeReviewTrigger, + RepositorySettings, +) +from sentry.testutils.cases import TestCase + + +class TestCodeReviewSettings(TestCase): + def test_initialization(self) -> None: + triggers = [CodeReviewTrigger.ON_COMMAND_PHRASE, CodeReviewTrigger.ON_NEW_COMMIT] + settings = CodeReviewSettings(enabled=True, triggers=triggers) + + assert settings.enabled is True + assert settings.triggers == triggers + + +class TestRepositorySettings(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.repo = self.create_repo(project=self.project) + + def test_get_code_review_settings_with_defaults(self) -> None: + repo_settings = RepositorySettings.objects.create(repository=self.repo) + + settings = repo_settings.get_code_review_settings() + + assert settings.enabled is False + assert settings.triggers == [] + + def test_get_code_review_settings_with_enabled_and_triggers(self) -> None: + repo_settings = RepositorySettings.objects.create( + repository=self.repo, + enabled_code_review=True, + code_review_triggers=[ + CodeReviewTrigger.ON_COMMAND_PHRASE.value, + CodeReviewTrigger.ON_READY_FOR_REVIEW.value, + ], + ) + + settings = repo_settings.get_code_review_settings() + + assert settings.enabled is True + assert len(settings.triggers) == 2 + assert CodeReviewTrigger.ON_COMMAND_PHRASE in settings.triggers + assert CodeReviewTrigger.ON_READY_FOR_REVIEW in settings.triggers + + def test_get_code_review_settings_converts_string_triggers_to_enum(self) -> None: + repo_settings = RepositorySettings.objects.create( + repository=self.repo, + enabled_code_review=True, + code_review_triggers=["on_new_commit"], + ) + + settings = repo_settings.get_code_review_settings() + + assert settings.triggers == [CodeReviewTrigger.ON_NEW_COMMIT] + assert isinstance(settings.triggers[0], CodeReviewTrigger) + + def test_repository_settings_unique_per_repository(self) -> None: + RepositorySettings.objects.create(repository=self.repo) + + # Creating another settings for the same repo should fail + with pytest.raises(IntegrityError): + RepositorySettings.objects.create(repository=self.repo) + + def test_repository_settings_deleted_with_repository(self) -> None: + repo_settings = RepositorySettings.objects.create(repository=self.repo) + settings_id = repo_settings.id + + self.repo.delete() + + assert not RepositorySettings.objects.filter(id=settings_id).exists() diff --git a/tests/sentry/overwatch/endpoints/test_overwatch_rpc.py b/tests/sentry/overwatch/endpoints/test_overwatch_rpc.py index 5b35e40860718d..bbd6a00c863195 100644 --- a/tests/sentry/overwatch/endpoints/test_overwatch_rpc.py +++ b/tests/sentry/overwatch/endpoints/test_overwatch_rpc.py @@ -878,7 +878,7 @@ def test_returns_false_when_code_review_enabled_but_no_contributor_exists(self): "sentry.overwatch.endpoints.overwatch_rpc.settings.OVERWATCH_RPC_SHARED_SECRET", ["test-secret"], ) - @patch("sentry.overwatch.endpoints.overwatch_rpc.quotas.backend.check_seer_quota") + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") def test_returns_false_when_quota_check_fails(self, mock_check_quota): mock_check_quota.return_value = False @@ -927,7 +927,7 @@ def test_returns_false_when_quota_check_fails(self, mock_check_quota): "sentry.overwatch.endpoints.overwatch_rpc.settings.OVERWATCH_RPC_SHARED_SECRET", ["test-secret"], ) - @patch("sentry.overwatch.endpoints.overwatch_rpc.quotas.backend.check_seer_quota") + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") def test_returns_true_when_code_review_enabled_and_quota_available(self, mock_check_quota): mock_check_quota.return_value = True @@ -980,7 +980,7 @@ def test_returns_true_when_code_review_enabled_and_quota_available(self, mock_ch "sentry.overwatch.endpoints.overwatch_rpc.settings.OVERWATCH_RPC_SHARED_SECRET", ["test-secret"], ) - @patch("sentry.overwatch.endpoints.overwatch_rpc.quotas.backend.check_seer_quota") + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") def test_returns_true_when_any_org_is_eligible(self, mock_check_quota): mock_check_quota.return_value = True diff --git a/tests/sentry/seer/code_review/test_billing.py b/tests/sentry/seer/code_review/test_billing.py new file mode 100644 index 00000000000000..27e75bbb448f7d --- /dev/null +++ b/tests/sentry/seer/code_review/test_billing.py @@ -0,0 +1,85 @@ +from unittest.mock import MagicMock, patch + +from sentry.constants import DataCategory +from sentry.models.organizationcontributors import OrganizationContributors +from sentry.seer.code_review.billing import passes_code_review_billing_check +from sentry.silo.base import SiloMode +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import assume_test_silo_mode + + +class TestPassesCodeReviewBillingCheck(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + with assume_test_silo_mode(SiloMode.CONTROL): + self.integration = self.create_integration( + organization=self.organization, + provider="github", + external_id="github:123", + ) + self.external_identifier = "user123" + + def test_billing_check_fails_when_contributor_does_not_exist(self) -> None: + with patch("sentry.seer.code_review.billing.metrics.incr") as mock_incr: + result = passes_code_review_billing_check( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier="nonexistent-user", + ) + + assert result is False + mock_incr.assert_called_once_with( + "overwatch.code_review.contributor_not_found", + tags={"organization_id": self.organization.id}, + ) + + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") + def test_billing_check_fails_when_quota_check_returns_false( + self, mock_check_quota: MagicMock + ) -> None: + mock_check_quota.return_value = False + + contributor = OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + result = passes_code_review_billing_check( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + assert result is False + mock_check_quota.assert_called_once_with( + org_id=self.organization.id, + data_category=DataCategory.SEER_USER, + seat_object=contributor, + ) + + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") + def test_billing_check_succeeds_when_contributor_exists_and_quota_available( + self, mock_check_quota: MagicMock + ) -> None: + mock_check_quota.return_value = True + + contributor = OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + result = passes_code_review_billing_check( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + assert result is True + mock_check_quota.assert_called_once_with( + org_id=self.organization.id, + data_category=DataCategory.SEER_USER, + seat_object=contributor, + ) diff --git a/tests/sentry/seer/code_review/test_preflight.py b/tests/sentry/seer/code_review/test_preflight.py new file mode 100644 index 00000000000000..1e6bcf28d85966 --- /dev/null +++ b/tests/sentry/seer/code_review/test_preflight.py @@ -0,0 +1,291 @@ +from unittest.mock import MagicMock, patch + +from sentry.models.organizationcontributors import OrganizationContributors +from sentry.models.repositorysettings import CodeReviewTrigger, RepositorySettings +from sentry.seer.code_review.preflight import CodeReviewPreflightService +from sentry.silo.base import SiloMode +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature +from sentry.testutils.silo import assume_test_silo_mode + + +class TestCodeReviewPreflightService(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.repo = self.create_repo(project=self.project) + with assume_test_silo_mode(SiloMode.CONTROL): + self.integration = self.create_integration( + organization=self.organization, + provider="github", + external_id="github:123", + ) + self.external_identifier = "user123" + + def _create_service( + self, + integration_id: int | None = None, + external_identifier: str | None = None, + ) -> CodeReviewPreflightService: + return CodeReviewPreflightService( + organization=self.organization, + repo=self.repo, + integration_id=integration_id if integration_id is not None else self.integration.id, + pr_author_external_id=( + external_identifier if external_identifier is not None else self.external_identifier + ), + ) + + # ------------------------------------------------------------------------- + # Legal AI consent tests + # ------------------------------------------------------------------------- + + def test_denied_when_gen_ai_feature_flag_disabled(self) -> None: + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "org_legal_ai_consent_not_granted" + + @with_feature("organizations:gen-ai-features") + def test_denied_when_hide_ai_features_enabled(self) -> None: + self.organization.update_option("sentry:hide_ai_features", True) + + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "org_legal_ai_consent_not_granted" + + # ------------------------------------------------------------------------- + # Org feature enablement tests + # ------------------------------------------------------------------------- + + @with_feature("organizations:gen-ai-features") + def test_denied_when_org_not_eligible_for_code_review(self) -> None: + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "org_not_eligible_for_code_review" + + @with_feature(["organizations:gen-ai-features", "organizations:code-review-beta"]) + def test_denied_when_beta_org_has_legacy_toggle_disabled(self) -> None: + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "org_pr_review_legacy_toggle_disabled" + + @with_feature(["organizations:gen-ai-features", "organizations:code-review-beta"]) + def test_allowed_when_beta_org_has_legacy_toggle_enabled(self) -> None: + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + with patch( + "sentry.seer.code_review.billing.quotas.backend.check_seer_quota", + return_value=True, + ): + service = self._create_service() + result = service.check() + + assert result.allowed is True + assert result.denial_reason is None + + @with_feature("organizations:gen-ai-features") + def test_allowed_when_org_is_legacy_opt_in_without_beta_flag(self) -> None: + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + with patch( + "sentry.seer.code_review.billing.quotas.backend.check_seer_quota", + return_value=True, + ): + service = self._create_service() + result = service.check() + + assert result.allowed is True + assert result.denial_reason is None + + # ------------------------------------------------------------------------- + # Seat-based org tests + # ------------------------------------------------------------------------- + + @with_feature(["organizations:gen-ai-features", "organizations:seat-based-seer-enabled"]) + def test_denied_when_seat_based_org_has_no_repo_settings(self) -> None: + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "repo_code_review_disabled" + + @with_feature(["organizations:gen-ai-features", "organizations:seat-based-seer-enabled"]) + def test_denied_when_seat_based_org_has_repo_settings_disabled(self) -> None: + RepositorySettings.objects.create( + repository=self.repo, + enabled_code_review=False, + ) + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "repo_code_review_disabled" + + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") + @with_feature(["organizations:gen-ai-features", "organizations:seat-based-seer-enabled"]) + def test_allowed_when_seat_based_org_has_repo_settings_enabled( + self, mock_check_quota: MagicMock + ) -> None: + mock_check_quota.return_value = True + + RepositorySettings.objects.create( + repository=self.repo, + enabled_code_review=True, + ) + + OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + service = self._create_service() + result = service.check() + + assert result.allowed is True + assert result.denial_reason is None + assert result.settings is not None + assert result.settings.enabled is True + + # ------------------------------------------------------------------------- + # Settings retrieval tests + # ------------------------------------------------------------------------- + + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") + @with_feature(["organizations:gen-ai-features", "organizations:seat-based-seer-enabled"]) + def test_returns_repo_settings_when_allowed(self, mock_check_quota: MagicMock) -> None: + mock_check_quota.return_value = True + + RepositorySettings.objects.create( + repository=self.repo, + enabled_code_review=True, + code_review_triggers=[ + CodeReviewTrigger.ON_COMMAND_PHRASE.value, + CodeReviewTrigger.ON_READY_FOR_REVIEW.value, + ], + ) + + OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + service = self._create_service() + result = service.check() + + assert result.allowed is True + assert result.settings is not None + assert result.settings.enabled is True + assert CodeReviewTrigger.ON_COMMAND_PHRASE in result.settings.triggers + assert CodeReviewTrigger.ON_READY_FOR_REVIEW in result.settings.triggers + + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") + @with_feature(["organizations:gen-ai-features", "organizations:code-review-beta"]) + def test_returns_none_settings_for_beta_org_without_repo_settings( + self, mock_check_quota: MagicMock + ) -> None: + mock_check_quota.return_value = True + + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + service = self._create_service() + result = service.check() + + assert result.allowed is True + assert result.settings is None + + # ------------------------------------------------------------------------- + # Billing tests + # ------------------------------------------------------------------------- + + +# TODO: Uncomment these billing tests once we're ready to actually gate billing (when it's time for GA) +""" + @with_feature(["organizations:gen-ai-features", "organizations:code-review-beta"]) + def test_denied_when_missing_integration_id(self) -> None: + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + service = CodeReviewPreflightService( + organization=self.organization, + repo=self.repo, + integration_id=None, + pr_author_external_id=self.external_identifier, + ) + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "billing_missing_contributor_info" + + @with_feature(["organizations:gen-ai-features", "organizations:code-review-beta"]) + def test_denied_when_missing_external_identifier(self) -> None: + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + service = CodeReviewPreflightService( + organization=self.organization, + repo=self.repo, + integration_id=self.integration.id, + pr_author_external_id=None, + ) + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "billing_missing_contributor_info" + + @with_feature(["organizations:gen-ai-features", "organizations:code-review-beta"]) + def test_denied_when_contributor_does_not_exist(self) -> None: + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "billing_quota_exceeded" + + @patch("sentry.seer.code_review.billing.quotas.backend.check_seer_quota") + @with_feature(["organizations:gen-ai-features", "organizations:code-review-beta"]) + def test_denied_when_quota_check_fails(self, mock_check_quota: MagicMock) -> None: + mock_check_quota.return_value = False + + self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + OrganizationContributors.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier=self.external_identifier, + ) + + service = self._create_service() + result = service.check() + + assert result.allowed is False + assert result.denial_reason == "billing_quota_exceeded" + +""" diff --git a/tests/sentry/seer/code_review/test_webhooks.py b/tests/sentry/seer/code_review/test_webhooks.py index ed5b59f6913a92..3d1a01274a282a 100644 --- a/tests/sentry/seer/code_review/test_webhooks.py +++ b/tests/sentry/seer/code_review/test_webhooks.py @@ -17,6 +17,8 @@ ) from sentry.integrations.github.client import GitHubReaction from sentry.integrations.github.webhook_types import GithubWebhookType +from sentry.integrations.models.integration import Integration +from sentry.models.organizationcontributors import OrganizationContributors from sentry.seer.code_review.utils import ClientError from sentry.seer.code_review.webhooks.check_run import GitHubCheckRunAction from sentry.seer.code_review.webhooks.issue_comment import ( @@ -36,20 +38,49 @@ CODE_REVIEW_FEATURES = {"organizations:gen-ai-features", "organizations:code-review-beta"} +DEFAULT_PR_AUTHOR_ID = "12345678" + class GitHubWebhookHelper(GitHubWebhookTestCase): """Base class for GitHub webhook integration tests.""" + github_integration: Integration | None = None + + @pytest.fixture(autouse=True) + def mock_billing_quota(self) -> Generator[None]: + """Mock billing quota check to return True for all tests.""" + with patch( + "sentry.seer.code_review.billing.quotas.backend.check_seer_quota", return_value=True + ): + yield + def _enable_code_review(self) -> None: """Enable all required options for code review to work.""" self.organization.update_option("sentry:enable_pr_review_test_generation", True) + # Setup billing data + self.github_integration = self.create_github_integration() + OrganizationContributors.objects.get_or_create( + organization_id=self.organization.id, + integration_id=self.github_integration.id, + external_identifier=DEFAULT_PR_AUTHOR_ID, + ) + @contextmanager def code_review_setup( self, features: Collection[str] | Mapping[str, Any] = CODE_REVIEW_FEATURES ) -> Generator[None]: """Helper to set up code review test context.""" self.organization.update_option("sentry:enable_pr_review_test_generation", True) + + # Setup billing data + self.github_integration = self.create_github_integration() + OrganizationContributors.objects.get_or_create( + organization_id=self.organization.id, + integration_id=self.github_integration.id, + external_identifier=DEFAULT_PR_AUTHOR_ID, + ) + with ( self.feature(features), self.options({"github.webhook.issue-comment": False}), @@ -65,7 +96,7 @@ def _send_webhook_event( ) repo_id = int(self.event_dict["repository"]["id"]) - integration = self.create_github_integration() + integration = self.github_integration or self.create_github_integration() self.create_repo( project=self.project, provider="integrations:github", @@ -780,12 +811,20 @@ def _build_issue_comment_event( "issue": { "number": 42, "pull_request": {"url": "https://api.github.com/repos/owner/repo/pulls/42"}, + "user": { + "id": int(DEFAULT_PR_AUTHOR_ID), + "login": "pr-author", + }, }, "repository": { "id": 12345, "full_name": "owner/repo", "html_url": "https://github.com/owner/repo", }, + "sender": { + "id": 87654321, + "login": "commenter", + }, } return orjson.dumps(event) @@ -811,15 +850,11 @@ def test_skips_when_no_review_command(self) -> None: self.mock_seer.assert_not_called() - @with_feature({"organizations:gen-ai-features"}) def test_runs_when_code_review_beta_flag_disabled_but_pr_review_test_generation_enabled( self, ) -> None: """Test that processing runs with gen-ai-features flag alone when org option is enabled.""" - with self.options( - {"organizations:code-review-beta": False, "github.webhook.issue-comment": False} - ): - self.organization.update_option("sentry:enable_pr_review_test_generation", True) + with self.code_review_setup(features={"organizations:gen-ai-features"}): event = self._build_issue_comment_event(f"Please {SENTRY_REVIEW_COMMAND} this PR") with self.tasks():