From 1667ac5534465f6c69e7cdfcfe827fab4208641e Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Fri, 14 Nov 2025 16:56:45 -0500 Subject: [PATCH 1/3] feat(autofix): Add email-based user mapping for PR review requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users lose track of Seer-authored Autofix PRs because Seer is the PR author. This change enables automatic PR review requests to the triggering user by resolving their GitHub username from multiple sources. Changes: - Add shared `get_github_username_for_user()` helper in committers.py - Check ExternalActor for direct user→GitHub mappings (existing behavior) - Fall back to CommitAuthor email matching (like suspect commits) - Extract GitHub username from CommitAuthor.external_id - Add structured logging to track mapping sources - Update autofix to use shared helper instead of duplicate code - Add 6 new test cases for CommitAuthor fallback scenarios When users trigger Autofix, if their email matches a CommitAuthor with a GitHub external_id, Seer will automatically request their review on the PR, making it easier to track and find their Autofix PRs. Fixes AIML-1597 --- src/sentry/seer/autofix/autofix.py | 27 ++---- src/sentry/utils/committers.py | 109 ++++++++++++++++++++++ tests/sentry/seer/autofix/test_autofix.py | 108 +++++++++++++++++++++ 3 files changed, 225 insertions(+), 19 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 8584f35f0f0d1d..4a7cf96f7df0cd 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -17,8 +17,6 @@ from sentry.api.endpoints.organization_trace import OrganizationTraceEndpoint from sentry.api.serializers import EventSerializer, serialize from sentry.constants import DataCategory, ObjectStatus -from sentry.integrations.models.external_actor import ExternalActor -from sentry.integrations.types import ExternalProviders from sentry.issues.grouptype import WebVitalsGroup from sentry.models.group import Group from sentry.models.project import Project @@ -341,25 +339,16 @@ 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.""" - 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() - ) + """ + Get GitHub username for a user by checking multiple sources. - if external_actor and external_actor.external_name: - username = external_actor.external_name - return username[1:] if username.startswith("@") else username + This delegates to the shared helper function that checks: + 1. ExternalActor for direct user→GitHub mappings + 2. CommitAuthor records matched by email (like suspect commits) + """ + from sentry.utils.committers import get_github_username_for_user - return None + return get_github_username_for_user(user, organization_id) def _call_autofix( diff --git a/src/sentry/utils/committers.py b/src/sentry/utils/committers.py index 7ac7b3d8b4c687..75a932dbd18e0a 100644 --- a/src/sentry/utils/committers.py +++ b/src/sentry/utils/committers.py @@ -435,3 +435,112 @@ def get_stacktrace_path_from_event_frame(frame: Mapping[str, Any]) -> str | None frame: Event frame """ return frame.get("munged_filename") or frame.get("filename") or frame.get("abs_path") + + +def get_github_username_for_user(user: Any, organization_id: int) -> str | None: + """ + Get GitHub username for a Sentry 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 + + Args: + user: Sentry User or RpcUser object + organization_id: Organization ID to scope the lookup + + Returns: + GitHub username (without @ prefix) or None if no mapping found + """ + import logging + + from sentry.integrations.models.external_actor import ExternalActor + from sentry.integrations.types import ExternalProviders + from sentry.models.commitauthor import CommitAuthor + from sentry.users.services.user.service import user_service + + logger = logging.getLogger(__name__) + + # 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 + username = username[1:] if username.startswith("@") else username + logger.info( + "github_username_resolved_from_external_actor", + extra={ + "user_id": user.id, + "organization_id": organization_id, + "username": username, + "source": "external_actor", + }, + ) + return 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: + logger.info( + "github_username_resolved_from_commit_author", + extra={ + "user_id": user.id, + "organization_id": organization_id, + "username": commit_username, + "source": "commit_author", + }, + ) + return commit_username + + logger.info( + "github_username_not_found", + extra={ + "user_id": user.id, + "organization_id": organization_id, + "source": "none", + }, + ) + 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: From 91219f4c01e06b43bb07ed138266d16afa444172 Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Fri, 14 Nov 2025 17:04:31 -0500 Subject: [PATCH 2/3] refactor: Move imports to module level and remove logging - Move all imports from inside get_github_username_for_user() to module level - Remove all logging statements from the helper function --- src/sentry/utils/committers.py | 41 ++++------------------------------ 1 file changed, 4 insertions(+), 37 deletions(-) diff --git a/src/sentry/utils/committers.py b/src/sentry/utils/committers.py index 75a932dbd18e0a..a52739b16b89b5 100644 --- a/src/sentry/utils/committers.py +++ b/src/sentry/utils/committers.py @@ -13,7 +13,10 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.commit import CommitSerializer, get_users_for_commits from sentry.api.serializers.models.release import Author, NonMappableUser +from sentry.integrations.models.external_actor import ExternalActor +from sentry.integrations.types import ExternalProviders from sentry.models.commit import Commit +from sentry.models.commitauthor import CommitAuthor from sentry.models.commitfilechange import CommitFileChange from sentry.models.group import Group from sentry.models.groupowner import GroupOwner, GroupOwnerType, SuspectCommitStrategy @@ -453,15 +456,6 @@ def get_github_username_for_user(user: Any, organization_id: int) -> str | None: Returns: GitHub username (without @ prefix) or None if no mapping found """ - import logging - - from sentry.integrations.models.external_actor import ExternalActor - from sentry.integrations.types import ExternalProviders - from sentry.models.commitauthor import CommitAuthor - from sentry.users.services.user.service import user_service - - logger = logging.getLogger(__name__) - # Method 1: Check ExternalActor for direct user→GitHub mapping external_actor: ExternalActor | None = ( ExternalActor.objects.filter( @@ -478,17 +472,7 @@ def get_github_username_for_user(user: Any, organization_id: int) -> str | None: if external_actor and external_actor.external_name: username = external_actor.external_name - username = username[1:] if username.startswith("@") else username - logger.info( - "github_username_resolved_from_external_actor", - extra={ - "user_id": user.id, - "organization_id": organization_id, - "username": username, - "source": "external_actor", - }, - ) - return username + 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 @@ -524,23 +508,6 @@ def get_github_username_for_user(user: Any, organization_id: int) -> str | None: if commit_author: commit_username = commit_author.get_username_from_external_id() if commit_username: - logger.info( - "github_username_resolved_from_commit_author", - extra={ - "user_id": user.id, - "organization_id": organization_id, - "username": commit_username, - "source": "commit_author", - }, - ) return commit_username - logger.info( - "github_username_not_found", - extra={ - "user_id": user.id, - "organization_id": organization_id, - "source": "none", - }, - ) return None From d3ede3f700e354b677f220630fa32dc229b3d8cd Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Fri, 14 Nov 2025 17:10:53 -0500 Subject: [PATCH 3/3] refactor: Move get_github_username_for_user implementation into autofix - Move implementation from committers.py directly into _get_github_username_for_user in autofix.py - Remove now-unused shared helper function from committers.py - Remove unused imports from committers.py - Keep all logic in autofix where it's actually used --- src/sentry/seer/autofix/autofix.py | 68 ++++++++++++++++++++++++-- src/sentry/utils/committers.py | 76 ------------------------------ 2 files changed, 63 insertions(+), 81 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 4a7cf96f7df0cd..a3bfd38a1d470d 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -17,7 +17,10 @@ from sentry.api.endpoints.organization_trace import OrganizationTraceEndpoint from sentry.api.serializers import EventSerializer, serialize from sentry.constants import DataCategory, ObjectStatus +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 @@ -342,13 +345,68 @@ def _get_github_username_for_user(user: User | RpcUser, organization_id: int) -> """ Get GitHub username for a user by checking multiple sources. - This delegates to the shared helper function that checks: - 1. ExternalActor for direct user→GitHub mappings - 2. CommitAuthor records matched by email (like suspect commits) + 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.utils.committers import get_github_username_for_user + 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, + 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 - return get_github_username_for_user(user, organization_id) + # 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 def _call_autofix( diff --git a/src/sentry/utils/committers.py b/src/sentry/utils/committers.py index a52739b16b89b5..7ac7b3d8b4c687 100644 --- a/src/sentry/utils/committers.py +++ b/src/sentry/utils/committers.py @@ -13,10 +13,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.commit import CommitSerializer, get_users_for_commits from sentry.api.serializers.models.release import Author, NonMappableUser -from sentry.integrations.models.external_actor import ExternalActor -from sentry.integrations.types import ExternalProviders from sentry.models.commit import Commit -from sentry.models.commitauthor import CommitAuthor from sentry.models.commitfilechange import CommitFileChange from sentry.models.group import Group from sentry.models.groupowner import GroupOwner, GroupOwnerType, SuspectCommitStrategy @@ -438,76 +435,3 @@ def get_stacktrace_path_from_event_frame(frame: Mapping[str, Any]) -> str | None frame: Event frame """ return frame.get("munged_filename") or frame.get("filename") or frame.get("abs_path") - - -def get_github_username_for_user(user: Any, organization_id: int) -> str | None: - """ - Get GitHub username for a Sentry 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 - - Args: - user: Sentry User or RpcUser object - organization_id: Organization ID to scope the lookup - - Returns: - GitHub username (without @ prefix) or None if no mapping found - """ - # 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 = [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