diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 8584f35f0f0d1d..a3bfd38a1d470d 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -20,6 +20,7 @@ from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.types import ExternalProviders from sentry.issues.grouptype import WebVitalsGroup +from sentry.models.commitauthor import CommitAuthor from sentry.models.group import Group from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig @@ -341,7 +342,17 @@ 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 ExternalActor mappings.""" + """ + 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 + """ + from sentry.users.services.user.service import user_service + + # Method 1: Check ExternalActor for direct user→GitHub mapping external_actor: ExternalActor | None = ( ExternalActor.objects.filter( user_id=user.id, @@ -359,6 +370,42 @@ def _get_github_username_for_user(user: User | RpcUser, organization_id: int) -> 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 = [user.email] if hasattr(user, "email") and user.email else [] + try: + # For RpcUser, get verified emails directly from the object + if hasattr(user, "get_verified_emails"): + verified_emails = user.get_verified_emails() + user_emails.extend([e.email for e in verified_emails]) + else: + # For ORM User, fetch from service + user_details = user_service.get_user(user_id=user.id) + if user_details and hasattr(user_details, "get_verified_emails"): + verified_emails = user_details.get_verified_emails() + user_emails.extend([e.email for e in verified_emails]) + except Exception: + # If we can't get verified emails, continue with just the primary email + 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 41494c7fe8e082..47a767957f41cd 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1234,6 +1234,114 @@ def test_get_github_username_for_user_multiple_mappings(self) -> None: username = _get_github_username_for_user(user, organization.id) assert username == "newuser" + def test_get_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 + + user = self.create_user(email="committer@example.com") + organization = self.create_organization() + self.create_member(user=user, organization=organization) + + # Create CommitAuthor with GitHub external_id + CommitAuthor.objects.create( + organization_id=organization.id, + name="Test Committer", + email="committer@example.com", + external_id="github:githubuser", + ) + + 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: + """Tests getting GitHub Enterprise username from CommitAuthor.""" + from sentry.models.commitauthor import CommitAuthor + + user = self.create_user(email="committer@company.com") + organization = self.create_organization() + self.create_member(user=user, organization=organization) + + # Create CommitAuthor with GitHub Enterprise external_id + CommitAuthor.objects.create( + organization_id=organization.id, + name="Enterprise User", + email="committer@company.com", + external_id="github_enterprise:ghuser", + ) + + username = _get_github_username_for_user(user, organization.id) + assert username == "ghuser" + + def test_get_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 + from sentry.models.commitauthor import CommitAuthor + + user = self.create_user(email="committer@example.com") + organization = self.create_organization() + self.create_member(user=user, organization=organization) + + # Create both ExternalActor and CommitAuthor + ExternalActor.objects.create( + user_id=user.id, + organization=organization, + provider=ExternalProviders.GITHUB.value, + external_name="@externaluser", + external_id="ext123", + integration_id=7, + ) + + CommitAuthor.objects.create( + organization_id=organization.id, + name="Commit User", + email="committer@example.com", + external_id="github:commituser", + ) + + # Should use ExternalActor (higher priority) + 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: + """Tests that None is returned when CommitAuthor exists but has no external_id.""" + from sentry.models.commitauthor import CommitAuthor + + user = self.create_user(email="committer@example.com") + organization = self.create_organization() + self.create_member(user=user, organization=organization) + + # Create CommitAuthor without external_id + CommitAuthor.objects.create( + organization_id=organization.id, + name="No External ID", + email="committer@example.com", + external_id=None, + ) + + username = _get_github_username_for_user(user, organization.id) + assert username is None + + def test_get_github_username_for_user_wrong_organization(self) -> None: + """Tests that CommitAuthor from different organization is not used.""" + from sentry.models.commitauthor import CommitAuthor + + user = self.create_user(email="committer@example.com") + organization1 = self.create_organization() + organization2 = self.create_organization() + self.create_member(user=user, organization=organization1) + + # Create CommitAuthor in different organization + CommitAuthor.objects.create( + organization_id=organization2.id, + name="Wrong Org User", + email="committer@example.com", + external_id="github:wrongorguser", + ) + + username = _get_github_username_for_user(user, organization1.id) + assert username is None + class TestRespondWithError(TestCase): def test_respond_with_error(self) -> None: