Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions fixtures/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -3559,6 +3559,10 @@
"check_run": {
"external_id": "4663713",
"html_url": "https://github.com/test/repo/runs/4"
},
"sender": {
"id": 12345678,
"login": "test-user"
}
}"""

Expand All @@ -3569,5 +3573,9 @@
"id": 35129377,
"full_name": "getsentry/sentry",
"html_url": "https://github.com/getsentry/sentry"
},
"sender": {
"id": 12345678,
"login": "test-user"
}
}"""
1 change: 1 addition & 0 deletions src/sentry/integrations/github/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def __call__(
event: Mapping[str, Any],
organization: Organization,
repo: Repository,
integration: RpcIntegration | None = None,
**kwargs: Any,
) -> None: ...

Expand Down
14 changes: 14 additions & 0 deletions src/sentry/models/repositorysettings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum

from django.contrib.postgres.fields.array import ArrayField
Expand All @@ -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):
"""
Expand All @@ -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)
38 changes: 8 additions & 30 deletions src/sentry/overwatch/endpoints/overwatch_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that when I moved this over I removed the tag for repository id as I thought it would add too much cardinality on the metric. Will connect with @ajay-sentry if that is going to be a problem..

)
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
Expand Down
39 changes: 39 additions & 0 deletions src/sentry/seer/code_review/billing.py
Original file line number Diff line number Diff line change
@@ -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,
)
129 changes: 129 additions & 0 deletions src/sentry/seer/code_review/preflight.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that I updated this to always let the billing check go through for now since it's not turned on for the overwatch flow yet either (just logged here). This will be turned on with GA.

"""
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,
)
)
24 changes: 24 additions & 0 deletions src/sentry/seer/code_review/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,27 @@ def transform_webhook_to_codegen_request(
},
},
}


def get_pr_author_id(event: Mapping[str, Any]) -> str | None:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll confirm with @ajay-sentry / Product who/what we want to increment for this webhook event. It's possible they would want this one to be "always free"

if (user_id := event.get("sender", {}).get("id")) is not None:
return str(user_id)

return None
9 changes: 0 additions & 9 deletions src/sentry/seer/code_review/webhooks/check_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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"


Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading