Skip to content

Commit 8a6d955

Browse files
committed
ref(seer): Move get_github_username_for_user to shared utils
Extract the GitHub username resolution function from autofix.py to seer/utils.py so it can be reused by the explorer flow. The old name is re-exported from autofix.py for backwards compatibility. No behavior change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Agent transcript: https://claudescope.sentry.dev/share/8BmYM4cKcaUl38fiIA-xxoHkUFgXmsXGQ2qrpAgJuKs
1 parent 16cbe0f commit 8a6d955

File tree

3 files changed

+98
-90
lines changed

3 files changed

+98
-90
lines changed

src/sentry/seer/autofix/autofix.py

Lines changed: 3 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,12 @@
1515
from sentry.api.endpoints.organization_trace import OrganizationTraceEndpoint
1616
from sentry.api.serializers import EventSerializer, serialize
1717
from sentry.constants import ENABLE_SEER_CODING_DEFAULT, DataCategory, ObjectStatus
18-
from sentry.integrations.models.external_actor import ExternalActor
1918
from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig
20-
from sentry.integrations.types import ExternalProviders
2119
from sentry.issues.auto_source_code_config.code_mapping import (
2220
convert_stacktrace_frame_path_to_source_path,
2321
get_sorted_code_mapping_configs,
2422
)
2523
from sentry.issues.grouptype import WebVitalsGroup
26-
from sentry.models.commitauthor import CommitAuthor
2724
from sentry.models.group import Group
2825
from sentry.models.organization import Organization
2926
from sentry.models.project import Project
@@ -458,64 +455,8 @@ def _respond_with_error(reason: str, status: int):
458455
)
459456

460457

461-
def _get_github_username_for_user(user: User | RpcUser, organization_id: int) -> str | None:
462-
"""
463-
Get GitHub username for a user by checking multiple sources.
464-
465-
This function attempts to resolve a Sentry user to their GitHub username by:
466-
1. Checking ExternalActor for explicit user→GitHub mappings
467-
2. Falling back to CommitAuthor records matched by email (like suspect commits)
468-
3. Extracting the GitHub username from the CommitAuthor external_id
469-
"""
470-
# Method 1: Check ExternalActor for direct user→GitHub mapping
471-
external_actor: ExternalActor | None = (
472-
ExternalActor.objects.filter(
473-
user_id=user.id,
474-
organization_id=organization_id,
475-
provider__in=[
476-
ExternalProviders.GITHUB.value,
477-
ExternalProviders.GITHUB_ENTERPRISE.value,
478-
],
479-
)
480-
.order_by("-date_added")
481-
.first()
482-
)
483-
484-
if external_actor and external_actor.external_name:
485-
username = external_actor.external_name
486-
return username[1:] if username.startswith("@") else username
487-
488-
# Method 2: Check CommitAuthor by email matching (like suspect commits does)
489-
# Get all verified emails for this user
490-
user_emails: list[str] = []
491-
try:
492-
# Both User and RpcUser models have a get_verified_emails method
493-
if hasattr(user, "get_verified_emails"):
494-
verified_emails = user.get_verified_emails()
495-
user_emails.extend([e.email for e in verified_emails])
496-
except Exception:
497-
# If we can't get verified emails, don't use any
498-
pass
499-
500-
if user_emails:
501-
# Find CommitAuthors with matching emails that have GitHub external_id
502-
commit_author = (
503-
CommitAuthor.objects.filter(
504-
organization_id=organization_id,
505-
email__in=[email.lower() for email in user_emails],
506-
external_id__isnull=False,
507-
)
508-
.exclude(external_id="")
509-
.order_by("-id")
510-
.first()
511-
)
512-
513-
if commit_author:
514-
commit_username = commit_author.get_username_from_external_id()
515-
if commit_username:
516-
return commit_username
517-
518-
return None
458+
# Imported from sentry.seer.utils — kept as a module-level alias for backwards compatibility
459+
from sentry.seer.utils import get_github_username_for_user # noqa: F401
519460

520461

521462
def _call_autofix(
@@ -836,7 +777,7 @@ def trigger_autofix(
836777
# get github username for user
837778
github_username = None
838779
if not isinstance(user, AnonymousUser):
839-
github_username = _get_github_username_for_user(user, group.organization.id)
780+
github_username = get_github_username_for_user(user, group.organization.id)
840781

841782
try:
842783
run_id = _call_autofix(

src/sentry/seer/utils.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
from __future__ import annotations
2+
13
from django.db.models import Q, QuerySet
24

35
from sentry.constants import ObjectStatus
6+
from sentry.integrations.models.external_actor import ExternalActor
7+
from sentry.integrations.types import ExternalProviders
8+
from sentry.models.commitauthor import CommitAuthor
49
from sentry.models.repository import Repository
10+
from sentry.users.models.user import User
11+
from sentry.users.services.user.model import RpcUser
512

613

714
def filter_repo_by_provider(
@@ -21,3 +28,63 @@ def filter_repo_by_provider(
2128
name=f"{owner}/{name}",
2229
status=ObjectStatus.ACTIVE,
2330
)
31+
32+
33+
def get_github_username_for_user(user: User | RpcUser, organization_id: int) -> str | None:
34+
"""
35+
Get GitHub username for a user by checking multiple sources.
36+
37+
This function attempts to resolve a Sentry user to their GitHub username by:
38+
1. Checking ExternalActor for explicit user->GitHub mappings
39+
2. Falling back to CommitAuthor records matched by email (like suspect commits)
40+
3. Extracting the GitHub username from the CommitAuthor external_id
41+
"""
42+
# Method 1: Check ExternalActor for direct user->GitHub mapping
43+
external_actor: ExternalActor | None = (
44+
ExternalActor.objects.filter(
45+
user_id=user.id,
46+
organization_id=organization_id,
47+
provider__in=[
48+
ExternalProviders.GITHUB.value,
49+
ExternalProviders.GITHUB_ENTERPRISE.value,
50+
],
51+
)
52+
.order_by("-date_added")
53+
.first()
54+
)
55+
56+
if external_actor and external_actor.external_name:
57+
username = external_actor.external_name
58+
return username[1:] if username.startswith("@") else username
59+
60+
# Method 2: Check CommitAuthor by email matching (like suspect commits does)
61+
# Get all verified emails for this user
62+
user_emails: list[str] = []
63+
try:
64+
# Both User and RpcUser models have a get_verified_emails method
65+
if hasattr(user, "get_verified_emails"):
66+
verified_emails = user.get_verified_emails()
67+
user_emails.extend([e.email for e in verified_emails])
68+
except Exception:
69+
# If we can't get verified emails, don't use any
70+
pass
71+
72+
if user_emails:
73+
# Find CommitAuthors with matching emails that have GitHub external_id
74+
commit_author = (
75+
CommitAuthor.objects.filter(
76+
organization_id=organization_id,
77+
email__in=[email.lower() for email in user_emails],
78+
external_id__isnull=False,
79+
)
80+
.exclude(external_id="")
81+
.order_by("-id")
82+
.first()
83+
)
84+
85+
if commit_author:
86+
commit_username = commit_author.get_username_from_external_id()
87+
if commit_username:
88+
return commit_username
89+
90+
return None

tests/sentry/seer/autofix/test_autofix.py

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from sentry.seer.autofix.autofix import (
1313
TIMEOUT_SECONDS,
1414
_call_autofix,
15-
_get_github_username_for_user,
1615
_get_logs_for_event,
1716
_get_profile_from_trace_tree,
1817
_get_trace_tree_for_event,
@@ -26,6 +25,7 @@
2625
from sentry.seer.autofix.types import AutofixSelectRootCausePayload
2726
from sentry.seer.explorer.utils import _convert_profile_to_execution_tree
2827
from sentry.seer.models import SeerApiError, SeerProjectPreference, SeerRawPreferenceResponse
28+
from sentry.seer.utils import get_github_username_for_user
2929
from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase
3030
from sentry.testutils.helpers.datetime import before_now
3131
from sentry.testutils.helpers.features import with_feature
@@ -1239,7 +1239,7 @@ def test_returns_preference_with_empty_repos(
12391239

12401240

12411241
class TestCallAutofix(TestCase):
1242-
@patch("sentry.seer.autofix.autofix._get_github_username_for_user")
1242+
@patch("sentry.seer.autofix.autofix.get_github_username_for_user")
12431243
@patch("sentry.seer.autofix.autofix.make_autofix_start_request")
12441244
def test_call_autofix(self, mock_request, mock_get_username) -> None:
12451245
"""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:
13221322

13231323

13241324
class TestGetGithubUsernameForUser(TestCase):
1325-
def test_get_github_username_for_user_with_github(self) -> None:
1325+
def testget_github_username_for_user_with_github(self) -> None:
13261326
"""Tests getting GitHub username from ExternalActor with GitHub provider."""
13271327
from sentry.integrations.models.external_actor import ExternalActor
13281328
from sentry.integrations.types import ExternalProviders
@@ -1340,10 +1340,10 @@ def test_get_github_username_for_user_with_github(self) -> None:
13401340
integration_id=1,
13411341
)
13421342

1343-
username = _get_github_username_for_user(user, organization.id)
1343+
username = get_github_username_for_user(user, organization.id)
13441344
assert username == "testuser"
13451345

1346-
def test_get_github_username_for_user_with_github_enterprise(self) -> None:
1346+
def testget_github_username_for_user_with_github_enterprise(self) -> None:
13471347
"""Tests getting GitHub username from ExternalActor with GitHub Enterprise provider."""
13481348
from sentry.integrations.models.external_actor import ExternalActor
13491349
from sentry.integrations.types import ExternalProviders
@@ -1361,10 +1361,10 @@ def test_get_github_username_for_user_with_github_enterprise(self) -> None:
13611361
integration_id=2,
13621362
)
13631363

1364-
username = _get_github_username_for_user(user, organization.id)
1364+
username = get_github_username_for_user(user, organization.id)
13651365
assert username == "gheuser"
13661366

1367-
def test_get_github_username_for_user_without_at_prefix(self) -> None:
1367+
def testget_github_username_for_user_without_at_prefix(self) -> None:
13681368
"""Tests getting GitHub username when external_name doesn't have @ prefix."""
13691369
from sentry.integrations.models.external_actor import ExternalActor
13701370
from sentry.integrations.types import ExternalProviders
@@ -1382,18 +1382,18 @@ def test_get_github_username_for_user_without_at_prefix(self) -> None:
13821382
integration_id=3,
13831383
)
13841384

1385-
username = _get_github_username_for_user(user, organization.id)
1385+
username = get_github_username_for_user(user, organization.id)
13861386
assert username == "noprefixuser"
13871387

1388-
def test_get_github_username_for_user_no_mapping(self) -> None:
1388+
def testget_github_username_for_user_no_mapping(self) -> None:
13891389
"""Tests that None is returned when user has no GitHub mapping."""
13901390
user = self.create_user()
13911391
organization = self.create_organization()
13921392

1393-
username = _get_github_username_for_user(user, organization.id)
1393+
username = get_github_username_for_user(user, organization.id)
13941394
assert username is None
13951395

1396-
def test_get_github_username_for_user_non_github_provider(self) -> None:
1396+
def testget_github_username_for_user_non_github_provider(self) -> None:
13971397
"""Tests that None is returned when user only has non-GitHub external actors."""
13981398
from sentry.integrations.models.external_actor import ExternalActor
13991399
from sentry.integrations.types import ExternalProviders
@@ -1411,10 +1411,10 @@ def test_get_github_username_for_user_non_github_provider(self) -> None:
14111411
integration_id=4,
14121412
)
14131413

1414-
username = _get_github_username_for_user(user, organization.id)
1414+
username = get_github_username_for_user(user, organization.id)
14151415
assert username is None
14161416

1417-
def test_get_github_username_for_user_multiple_mappings(self) -> None:
1417+
def testget_github_username_for_user_multiple_mappings(self) -> None:
14181418
"""Tests that most recent GitHub mapping is used when multiple exist."""
14191419
from sentry.integrations.models.external_actor import ExternalActor
14201420
from sentry.integrations.types import ExternalProviders
@@ -1444,10 +1444,10 @@ def test_get_github_username_for_user_multiple_mappings(self) -> None:
14441444
date_added=before_now(days=1),
14451445
)
14461446

1447-
username = _get_github_username_for_user(user, organization.id)
1447+
username = get_github_username_for_user(user, organization.id)
14481448
assert username == "newuser"
14491449

1450-
def test_get_github_username_for_user_from_commit_author(self) -> None:
1450+
def testget_github_username_for_user_from_commit_author(self) -> None:
14511451
"""Tests getting GitHub username from CommitAuthor when ExternalActor doesn't exist."""
14521452
from sentry.models.commitauthor import CommitAuthor
14531453

@@ -1463,10 +1463,10 @@ def test_get_github_username_for_user_from_commit_author(self) -> None:
14631463
external_id="github:githubuser",
14641464
)
14651465

1466-
username = _get_github_username_for_user(user, organization.id)
1466+
username = get_github_username_for_user(user, organization.id)
14671467
assert username == "githubuser"
14681468

1469-
def test_get_github_username_for_user_from_commit_author_github_enterprise(self) -> None:
1469+
def testget_github_username_for_user_from_commit_author_github_enterprise(self) -> None:
14701470
"""Tests getting GitHub Enterprise username from CommitAuthor."""
14711471
from sentry.models.commitauthor import CommitAuthor
14721472

@@ -1482,10 +1482,10 @@ def test_get_github_username_for_user_from_commit_author_github_enterprise(self)
14821482
external_id="github_enterprise:ghuser",
14831483
)
14841484

1485-
username = _get_github_username_for_user(user, organization.id)
1485+
username = get_github_username_for_user(user, organization.id)
14861486
assert username == "ghuser"
14871487

1488-
def test_get_github_username_for_user_external_actor_priority(self) -> None:
1488+
def testget_github_username_for_user_external_actor_priority(self) -> None:
14891489
"""Tests that ExternalActor is checked before CommitAuthor."""
14901490
from sentry.integrations.models.external_actor import ExternalActor
14911491
from sentry.integrations.types import ExternalProviders
@@ -1513,10 +1513,10 @@ def test_get_github_username_for_user_external_actor_priority(self) -> None:
15131513
)
15141514

15151515
# Should use ExternalActor (higher priority)
1516-
username = _get_github_username_for_user(user, organization.id)
1516+
username = get_github_username_for_user(user, organization.id)
15171517
assert username == "externaluser"
15181518

1519-
def test_get_github_username_for_user_commit_author_no_external_id(self) -> None:
1519+
def testget_github_username_for_user_commit_author_no_external_id(self) -> None:
15201520
"""Tests that None is returned when CommitAuthor exists but has no external_id."""
15211521
from sentry.models.commitauthor import CommitAuthor
15221522

@@ -1532,10 +1532,10 @@ def test_get_github_username_for_user_commit_author_no_external_id(self) -> None
15321532
external_id=None,
15331533
)
15341534

1535-
username = _get_github_username_for_user(user, organization.id)
1535+
username = get_github_username_for_user(user, organization.id)
15361536
assert username is None
15371537

1538-
def test_get_github_username_for_user_wrong_organization(self) -> None:
1538+
def testget_github_username_for_user_wrong_organization(self) -> None:
15391539
"""Tests that CommitAuthor from different organization is not used."""
15401540
from sentry.models.commitauthor import CommitAuthor
15411541

@@ -1552,10 +1552,10 @@ def test_get_github_username_for_user_wrong_organization(self) -> None:
15521552
external_id="github:wrongorguser",
15531553
)
15541554

1555-
username = _get_github_username_for_user(user, organization1.id)
1555+
username = get_github_username_for_user(user, organization1.id)
15561556
assert username is None
15571557

1558-
def test_get_github_username_for_user_unverified_email_not_matched(self) -> None:
1558+
def testget_github_username_for_user_unverified_email_not_matched(self) -> None:
15591559
"""Tests that unverified emails don't match CommitAuthor (security requirement)."""
15601560
from sentry.models.commitauthor import CommitAuthor
15611561

@@ -1575,10 +1575,10 @@ def test_get_github_username_for_user_unverified_email_not_matched(self) -> None
15751575
)
15761576

15771577
# Should NOT match the unverified email (security fix)
1578-
username = _get_github_username_for_user(user, organization.id)
1578+
username = get_github_username_for_user(user, organization.id)
15791579
assert username is None
15801580

1581-
def test_get_github_username_for_user_verified_secondary_email_matched(self) -> None:
1581+
def testget_github_username_for_user_verified_secondary_email_matched(self) -> None:
15821582
"""Tests that verified secondary emails DO match CommitAuthor."""
15831583
from sentry.models.commitauthor import CommitAuthor
15841584

@@ -1598,7 +1598,7 @@ def test_get_github_username_for_user_verified_secondary_email_matched(self) ->
15981598
)
15991599

16001600
# Should match the verified secondary email
1601-
username = _get_github_username_for_user(user, organization.id)
1601+
username = get_github_username_for_user(user, organization.id)
16021602
assert username == "developeruser"
16031603

16041604

0 commit comments

Comments
 (0)