diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index e44308d8bacf46..e4666b14c44ce1 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -335,6 +335,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-slack-workflows", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Explorer in Slack via @mentions manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Assign Seer-created PRs to the invoking user on GitHub + manager.add("organizations:seer-assign-prs-to-user", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable search query attribute validation manager.add("organizations:search-query-attribute-validation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable search query builder to support explicit boolean filters diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 076e6583124d5d..a5c480180a657a 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -15,15 +15,12 @@ from sentry.api.endpoints.organization_trace import OrganizationTraceEndpoint from sentry.api.serializers import EventSerializer, serialize from sentry.constants import ENABLE_SEER_CODING_DEFAULT, DataCategory, ObjectStatus -from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig -from sentry.integrations.types import ExternalProviders from sentry.issues.auto_source_code_config.code_mapping import ( convert_stacktrace_frame_path_to_source_path, get_sorted_code_mapping_configs, ) from sentry.issues.grouptype import WebVitalsGroup -from sentry.models.commitauthor import CommitAuthor from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project @@ -458,64 +455,8 @@ def _respond_with_error(reason: str, status: int): ) -def _get_github_username_for_user(user: User | RpcUser, organization_id: int) -> str | None: - """ - Get GitHub username for a user by checking multiple sources. - - This function attempts to resolve a Sentry user to their GitHub username by: - 1. Checking ExternalActor for explicit user→GitHub mappings - 2. Falling back to CommitAuthor records matched by email (like suspect commits) - 3. Extracting the GitHub username from the CommitAuthor external_id - """ - # Method 1: Check ExternalActor for direct user→GitHub mapping - external_actor: ExternalActor | None = ( - ExternalActor.objects.filter( - user_id=user.id, - organization_id=organization_id, - provider__in=[ - ExternalProviders.GITHUB.value, - ExternalProviders.GITHUB_ENTERPRISE.value, - ], - ) - .order_by("-date_added") - .first() - ) - - if external_actor and external_actor.external_name: - username = external_actor.external_name - return username[1:] if username.startswith("@") else username - - # Method 2: Check CommitAuthor by email matching (like suspect commits does) - # Get all verified emails for this user - user_emails: list[str] = [] - try: - # Both User and RpcUser models have a get_verified_emails method - if hasattr(user, "get_verified_emails"): - verified_emails = user.get_verified_emails() - user_emails.extend([e.email for e in verified_emails]) - except Exception: - # If we can't get verified emails, don't use any - pass - - if user_emails: - # Find CommitAuthors with matching emails that have GitHub external_id - commit_author = ( - CommitAuthor.objects.filter( - organization_id=organization_id, - email__in=[email.lower() for email in user_emails], - external_id__isnull=False, - ) - .exclude(external_id="") - .order_by("-id") - .first() - ) - - if commit_author: - commit_username = commit_author.get_username_from_external_id() - if commit_username: - return commit_username - - return None +# Imported from sentry.seer.utils — kept as a module-level alias for backwards compatibility +from sentry.seer.utils import get_github_username_for_user # noqa: F401 def _call_autofix( @@ -573,6 +514,9 @@ def _call_autofix( "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT ), "stopping_point": stopping_point.value if stopping_point else None, + "assign_pr_to_user": features.has( + "organizations:seer-assign-prs-to-user", group.organization + ), }, "preference": preference.dict() if preference else None, }, @@ -836,7 +780,7 @@ def trigger_autofix( # get github username for user github_username = None if not isinstance(user, AnonymousUser): - github_username = _get_github_username_for_user(user, group.organization.id) + github_username = get_github_username_for_user(user, group.organization.id) try: run_id = _call_autofix( diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index fa0be21574d64c..b5acea024916c8 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -28,6 +28,7 @@ from sentry.seer.models import SeerApiError from sentry.seer.seer_setup import has_seer_access_with_detail from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request +from sentry.seer.utils import get_github_username_for_user from sentry.users.models.user import User as SentryUser from sentry.users.services.user.model import RpcUser from sentry.users.services.user_option import user_option_service @@ -284,6 +285,11 @@ def collect_user_org_context( # Get IP address from http request, if provided user_ip: str | None = request.META.get("REMOTE_ADDR") if request else None + # Resolve GitHub username for PR assignment/review + github_username = get_github_username_for_user(user, organization.id) + + assign_pr_to_user = features.has("organizations:seer-assign-prs-to-user", organization) + return { "org_slug": organization.slug, "user_id": user.id, @@ -291,6 +297,8 @@ def collect_user_org_context( "user_name": user_name, "user_email": user.email, "user_timezone": user_timezone, + "github_username": github_username, + "assign_pr_to_user": assign_pr_to_user, "user_teams": user_teams, "user_projects": user_projects, "all_org_projects": all_org_projects, diff --git a/src/sentry/seer/utils.py b/src/sentry/seer/utils.py index de77746b68db42..9f65dad302d8e6 100644 --- a/src/sentry/seer/utils.py +++ b/src/sentry/seer/utils.py @@ -1,7 +1,14 @@ +from __future__ import annotations + from django.db.models import Q, QuerySet from sentry.constants import ObjectStatus +from sentry.integrations.models.external_actor import ExternalActor +from sentry.integrations.types import ExternalProviders +from sentry.models.commitauthor import CommitAuthor from sentry.models.repository import Repository +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser def filter_repo_by_provider( @@ -21,3 +28,63 @@ def filter_repo_by_provider( name=f"{owner}/{name}", status=ObjectStatus.ACTIVE, ) + + +def get_github_username_for_user(user: User | RpcUser, organization_id: int) -> str | None: + """ + Get GitHub username for a user by checking multiple sources. + + This function attempts to resolve a Sentry user to their GitHub username by: + 1. Checking ExternalActor for explicit user→GitHub mappings + 2. Falling back to CommitAuthor records matched by email (like suspect commits) + 3. Extracting the GitHub username from the CommitAuthor external_id + """ + # Method 1: Check ExternalActor for direct user→GitHub mapping + external_actor: ExternalActor | None = ( + ExternalActor.objects.filter( + user_id=user.id, + organization_id=organization_id, + provider__in=[ + ExternalProviders.GITHUB.value, + ExternalProviders.GITHUB_ENTERPRISE.value, + ], + ) + .order_by("-date_added") + .first() + ) + + if external_actor and external_actor.external_name: + username = external_actor.external_name + return username[1:] if username.startswith("@") else username + + # Method 2: Check CommitAuthor by email matching (like suspect commits does) + # Get all verified emails for this user + user_emails: list[str] = [] + try: + # Both User and RpcUser models have a get_verified_emails method + if hasattr(user, "get_verified_emails"): + verified_emails = user.get_verified_emails() + user_emails.extend([e.email for e in verified_emails]) + except Exception: + # If we can't get verified emails, don't use any + pass + + if user_emails: + # Find CommitAuthors with matching emails that have GitHub external_id + commit_author = ( + CommitAuthor.objects.filter( + organization_id=organization_id, + email__in=[email.lower() for email in user_emails], + external_id__isnull=False, + ) + .exclude(external_id="") + .order_by("-id") + .first() + ) + + if commit_author: + commit_username = commit_author.get_username_from_external_id() + if commit_username: + return commit_username + + return None diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 530b26f235e484..6a7857e6a4d5ce 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -12,7 +12,6 @@ from sentry.seer.autofix.autofix import ( TIMEOUT_SECONDS, _call_autofix, - _get_github_username_for_user, _get_logs_for_event, _get_profile_from_trace_tree, _get_trace_tree_for_event, @@ -26,6 +25,7 @@ from sentry.seer.autofix.types import AutofixSelectRootCausePayload from sentry.seer.explorer.utils import _convert_profile_to_execution_tree from sentry.seer.models import SeerApiError, SeerProjectPreference, SeerRawPreferenceResponse +from sentry.seer.utils import get_github_username_for_user from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase from sentry.testutils.helpers.datetime import before_now from sentry.testutils.helpers.features import with_feature @@ -1239,7 +1239,7 @@ def test_returns_preference_with_empty_repos( class TestCallAutofix(TestCase): - @patch("sentry.seer.autofix.autofix._get_github_username_for_user") + @patch("sentry.seer.autofix.autofix.get_github_username_for_user") @patch("sentry.seer.autofix.autofix.make_autofix_start_request") def test_call_autofix(self, mock_request, mock_get_username) -> None: """Tests the _call_autofix function makes the correct API call.""" @@ -1322,7 +1322,7 @@ def test_call_autofix(self, mock_request, mock_get_username) -> None: class TestGetGithubUsernameForUser(TestCase): - def test_get_github_username_for_user_with_github(self) -> None: + def testget_github_username_for_user_with_github(self) -> None: """Tests getting GitHub username from ExternalActor with GitHub provider.""" from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders @@ -1340,10 +1340,10 @@ def test_get_github_username_for_user_with_github(self) -> None: integration_id=1, ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "testuser" - def test_get_github_username_for_user_with_github_enterprise(self) -> None: + def testget_github_username_for_user_with_github_enterprise(self) -> None: """Tests getting GitHub username from ExternalActor with GitHub Enterprise provider.""" from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders @@ -1361,10 +1361,10 @@ def test_get_github_username_for_user_with_github_enterprise(self) -> None: integration_id=2, ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "gheuser" - def test_get_github_username_for_user_without_at_prefix(self) -> None: + def testget_github_username_for_user_without_at_prefix(self) -> None: """Tests getting GitHub username when external_name doesn't have @ prefix.""" from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders @@ -1382,18 +1382,18 @@ def test_get_github_username_for_user_without_at_prefix(self) -> None: integration_id=3, ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "noprefixuser" - def test_get_github_username_for_user_no_mapping(self) -> None: + def testget_github_username_for_user_no_mapping(self) -> None: """Tests that None is returned when user has no GitHub mapping.""" user = self.create_user() organization = self.create_organization() - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username is None - def test_get_github_username_for_user_non_github_provider(self) -> None: + def testget_github_username_for_user_non_github_provider(self) -> None: """Tests that None is returned when user only has non-GitHub external actors.""" from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders @@ -1411,10 +1411,10 @@ def test_get_github_username_for_user_non_github_provider(self) -> None: integration_id=4, ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username is None - def test_get_github_username_for_user_multiple_mappings(self) -> None: + def testget_github_username_for_user_multiple_mappings(self) -> None: """Tests that most recent GitHub mapping is used when multiple exist.""" from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders @@ -1444,10 +1444,10 @@ def test_get_github_username_for_user_multiple_mappings(self) -> None: date_added=before_now(days=1), ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "newuser" - def test_get_github_username_for_user_from_commit_author(self) -> None: + def testget_github_username_for_user_from_commit_author(self) -> None: """Tests getting GitHub username from CommitAuthor when ExternalActor doesn't exist.""" from sentry.models.commitauthor import CommitAuthor @@ -1463,10 +1463,10 @@ def test_get_github_username_for_user_from_commit_author(self) -> None: external_id="github:githubuser", ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "githubuser" - def test_get_github_username_for_user_from_commit_author_github_enterprise(self) -> None: + def testget_github_username_for_user_from_commit_author_github_enterprise(self) -> None: """Tests getting GitHub Enterprise username from CommitAuthor.""" from sentry.models.commitauthor import CommitAuthor @@ -1482,10 +1482,10 @@ def test_get_github_username_for_user_from_commit_author_github_enterprise(self) external_id="github_enterprise:ghuser", ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "ghuser" - def test_get_github_username_for_user_external_actor_priority(self) -> None: + def testget_github_username_for_user_external_actor_priority(self) -> None: """Tests that ExternalActor is checked before CommitAuthor.""" from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders @@ -1513,10 +1513,10 @@ def test_get_github_username_for_user_external_actor_priority(self) -> None: ) # Should use ExternalActor (higher priority) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "externaluser" - def test_get_github_username_for_user_commit_author_no_external_id(self) -> None: + def testget_github_username_for_user_commit_author_no_external_id(self) -> None: """Tests that None is returned when CommitAuthor exists but has no external_id.""" from sentry.models.commitauthor import CommitAuthor @@ -1532,10 +1532,10 @@ def test_get_github_username_for_user_commit_author_no_external_id(self) -> None external_id=None, ) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username is None - def test_get_github_username_for_user_wrong_organization(self) -> None: + def testget_github_username_for_user_wrong_organization(self) -> None: """Tests that CommitAuthor from different organization is not used.""" from sentry.models.commitauthor import CommitAuthor @@ -1552,10 +1552,10 @@ def test_get_github_username_for_user_wrong_organization(self) -> None: external_id="github:wrongorguser", ) - username = _get_github_username_for_user(user, organization1.id) + username = get_github_username_for_user(user, organization1.id) assert username is None - def test_get_github_username_for_user_unverified_email_not_matched(self) -> None: + def testget_github_username_for_user_unverified_email_not_matched(self) -> None: """Tests that unverified emails don't match CommitAuthor (security requirement).""" from sentry.models.commitauthor import CommitAuthor @@ -1575,10 +1575,10 @@ def test_get_github_username_for_user_unverified_email_not_matched(self) -> None ) # Should NOT match the unverified email (security fix) - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username is None - def test_get_github_username_for_user_verified_secondary_email_matched(self) -> None: + def testget_github_username_for_user_verified_secondary_email_matched(self) -> None: """Tests that verified secondary emails DO match CommitAuthor.""" from sentry.models.commitauthor import CommitAuthor @@ -1598,7 +1598,7 @@ def test_get_github_username_for_user_verified_secondary_email_matched(self) -> ) # Should match the verified secondary email - username = _get_github_username_for_user(user, organization.id) + username = get_github_username_for_user(user, organization.id) assert username == "developeruser"