Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 8 additions & 19 deletions src/sentry/seer/autofix/autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
109 changes: 109 additions & 0 deletions src/sentry/utils/committers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
)
Copy link

@semgrep-code-getsentry semgrep-code-getsentry bot Nov 14, 2025

Choose a reason for hiding this comment

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

Risk: Affected versions of django are vulnerable to Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). The ORM methods QuerySet.filter(), QuerySet.exclude(), QuerySet.get() and the Q() class can be tricked into SQL injection when you pass a specially crafted dictionary via **kwargs that includes a malicious _connector entry. This bypasses the normal query parameterization and lets an attacker inject arbitrary SQL into the WHERE clause.

Fix: Upgrade this library to at least version 5.2.8 at sentry/uv.lock:305.

Reference(s): GHSA-frmv-pr5f-9mcr, CVE-2025-64459

🎉 Removed in commit d3ede3f 🎉

.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="")
Copy link

@semgrep-code-getsentry semgrep-code-getsentry bot Nov 14, 2025

Choose a reason for hiding this comment

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

Risk: Affected versions of django are vulnerable to Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). The ORM methods QuerySet.filter(), QuerySet.exclude(), QuerySet.get() and the Q() class can be tricked into SQL injection when you pass a specially crafted dictionary via **kwargs that includes a malicious _connector entry. This bypasses the normal query parameterization and lets an attacker inject arbitrary SQL into the WHERE clause.

Fix: Upgrade this library to at least version 5.2.8 at sentry/uv.lock:305.

Reference(s): GHSA-frmv-pr5f-9mcr, CVE-2025-64459

Fixed in commit 91219f4

Copy link

@semgrep-code-getsentry semgrep-code-getsentry bot Nov 14, 2025

Choose a reason for hiding this comment

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

Risk: Affected versions of django are vulnerable to Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). The ORM methods QuerySet.filter(), QuerySet.exclude(), QuerySet.get() and the Q() class can be tricked into SQL injection when you pass a specially crafted dictionary via **kwargs that includes a malicious _connector entry. This bypasses the normal query parameterization and lets an attacker inject arbitrary SQL into the WHERE clause.

Fix: Upgrade this library to at least version 5.2.8 at sentry/uv.lock:305.

Reference(s): GHSA-frmv-pr5f-9mcr, CVE-2025-64459

🍰 Removed in commit d3ede3f 🍰

.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
108 changes: 108 additions & 0 deletions tests/sentry/seer/autofix/test_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]")
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="[email protected]",
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="[email protected]")
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="[email protected]",
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="[email protected]")
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="[email protected]",
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="[email protected]")
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="[email protected]",
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="[email protected]")
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="[email protected]",
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:
Expand Down
Loading