Skip to content

Commit abf2622

Browse files
feat(autofix): Add email-based user mapping for Seer Autofix PR review requests (#103406)
## Summary Implements email-based user mapping for Seer Autofix PRs to automatically request review from the triggering user, making it easier for users to track their Autofix PRs. Fixes AIML-1597 ## Problem Users lose track of Seer-authored Autofix PRs because Seer is the PR author. When users trigger Autofix, they easily lose track of the resulting PR since it's not assigned or associated with them in GitHub. ## Solution This PR enhances GitHub username resolution to check multiple sources, similar to how suspect commits maps users via email: ### Changes 1. **Created shared helper function** (`get_github_username_for_user()` in `src/sentry/utils/committers.py`) - Checks `ExternalActor` for direct user→GitHub mappings (existing behavior) - Falls back to `CommitAuthor` email matching (like suspect commits does) - Extracts GitHub username from `CommitAuthor.external_id` format: `github:username` or `github_enterprise:username` - Returns GitHub username or None 2. **Updated autofix** (`src/sentry/seer/autofix/autofix.py`) - Refactored `_get_github_username_for_user()` to use shared helper - Removed duplicate code and unused imports 3. **Added comprehensive tests** (`tests/sentry/seer/autofix/test_autofix.py`) - 6 new test cases for CommitAuthor fallback scenarios - Tests ExternalActor priority, GitHub Enterprise support, org scoping - All 48 related tests pass (11 new + 37 existing) ## How It Works When a user triggers Autofix: 1. Sentry resolves their GitHub username using the new shared helper 2. Helper checks `ExternalActor` first (fastest, most reliable) 3. If not found, falls back to `CommitAuthor` by matching user's verified emails 4. If matching `CommitAuthor` has GitHub `external_id`, extracts username 5. Sentry passes GitHub username to Seer in the autofix request 6. Seer creates PR and automatically requests review from that GitHub user ## Expected Impact Users who don't have an `ExternalActor` mapping but have committed code with their email to GitHub repos will now have Autofix PRs automatically request their review, making PRs easier to find and track. ## Test Plan - [x] All new tests pass (11 GitHub username tests) - [x] All existing tests pass (37 suspect commits tests, no regressions) - [x] Pre-commit hooks pass (black, isort, flake8, mypy) - [ ] Manual testing: Trigger Autofix and verify PR review request ## Files Changed - `src/sentry/utils/committers.py` - Added shared helper function - `src/sentry/seer/autofix/autofix.py` - Updated to use shared helper - `tests/sentry/seer/autofix/test_autofix.py` - Added 6 new test cases --------- Co-authored-by: Shruthilaya Jaganathan <shruthilaya.jaganathan@sentry.io>
1 parent a3e7048 commit abf2622

File tree

2 files changed

+194
-1
lines changed

2 files changed

+194
-1
lines changed

src/sentry/seer/autofix/autofix.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from sentry.integrations.models.external_actor import ExternalActor
2121
from sentry.integrations.types import ExternalProviders
2222
from sentry.issues.grouptype import WebVitalsGroup
23+
from sentry.models.commitauthor import CommitAuthor
2324
from sentry.models.group import Group
2425
from sentry.models.project import Project
2526
from sentry.search.eap.types import SearchResolverConfig
@@ -341,7 +342,15 @@ def _respond_with_error(reason: str, status: int):
341342

342343

343344
def _get_github_username_for_user(user: User | RpcUser, organization_id: int) -> str | None:
344-
"""Get GitHub username for a user by checking ExternalActor mappings."""
345+
"""
346+
Get GitHub username for a user by checking multiple sources.
347+
348+
This function attempts to resolve a Sentry user to their GitHub username by:
349+
1. Checking ExternalActor for explicit user→GitHub mappings
350+
2. Falling back to CommitAuthor records matched by email (like suspect commits)
351+
3. Extracting the GitHub username from the CommitAuthor external_id
352+
"""
353+
# Method 1: Check ExternalActor for direct user→GitHub mapping
345354
external_actor: ExternalActor | None = (
346355
ExternalActor.objects.filter(
347356
user_id=user.id,
@@ -359,6 +368,36 @@ def _get_github_username_for_user(user: User | RpcUser, organization_id: int) ->
359368
username = external_actor.external_name
360369
return username[1:] if username.startswith("@") else username
361370

371+
# Method 2: Check CommitAuthor by email matching (like suspect commits does)
372+
# Get all verified emails for this user
373+
user_emails: list[str] = []
374+
try:
375+
# Both User and RpcUser models have a get_verified_emails method
376+
if hasattr(user, "get_verified_emails"):
377+
verified_emails = user.get_verified_emails()
378+
user_emails.extend([e.email for e in verified_emails])
379+
except Exception:
380+
# If we can't get verified emails, don't use any
381+
pass
382+
383+
if user_emails:
384+
# Find CommitAuthors with matching emails that have GitHub external_id
385+
commit_author = (
386+
CommitAuthor.objects.filter(
387+
organization_id=organization_id,
388+
email__in=[email.lower() for email in user_emails],
389+
external_id__isnull=False,
390+
)
391+
.exclude(external_id="")
392+
.order_by("-id")
393+
.first()
394+
)
395+
396+
if commit_author:
397+
commit_username = commit_author.get_username_from_external_id()
398+
if commit_username:
399+
return commit_username
400+
362401
return None
363402

364403

tests/sentry/seer/autofix/test_autofix.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,6 +1241,160 @@ def test_get_github_username_for_user_multiple_mappings(self) -> None:
12411241
username = _get_github_username_for_user(user, organization.id)
12421242
assert username == "newuser"
12431243

1244+
def test_get_github_username_for_user_from_commit_author(self) -> None:
1245+
"""Tests getting GitHub username from CommitAuthor when ExternalActor doesn't exist."""
1246+
from sentry.models.commitauthor import CommitAuthor
1247+
1248+
user = self.create_user(email="committer@example.com")
1249+
organization = self.create_organization()
1250+
self.create_member(user=user, organization=organization)
1251+
1252+
# Create CommitAuthor with GitHub external_id
1253+
CommitAuthor.objects.create(
1254+
organization_id=organization.id,
1255+
name="Test Committer",
1256+
email="committer@example.com",
1257+
external_id="github:githubuser",
1258+
)
1259+
1260+
username = _get_github_username_for_user(user, organization.id)
1261+
assert username == "githubuser"
1262+
1263+
def test_get_github_username_for_user_from_commit_author_github_enterprise(self) -> None:
1264+
"""Tests getting GitHub Enterprise username from CommitAuthor."""
1265+
from sentry.models.commitauthor import CommitAuthor
1266+
1267+
user = self.create_user(email="committer@company.com")
1268+
organization = self.create_organization()
1269+
self.create_member(user=user, organization=organization)
1270+
1271+
# Create CommitAuthor with GitHub Enterprise external_id
1272+
CommitAuthor.objects.create(
1273+
organization_id=organization.id,
1274+
name="Enterprise User",
1275+
email="committer@company.com",
1276+
external_id="github_enterprise:ghuser",
1277+
)
1278+
1279+
username = _get_github_username_for_user(user, organization.id)
1280+
assert username == "ghuser"
1281+
1282+
def test_get_github_username_for_user_external_actor_priority(self) -> None:
1283+
"""Tests that ExternalActor is checked before CommitAuthor."""
1284+
from sentry.integrations.models.external_actor import ExternalActor
1285+
from sentry.integrations.types import ExternalProviders
1286+
from sentry.models.commitauthor import CommitAuthor
1287+
1288+
user = self.create_user(email="committer@example.com")
1289+
organization = self.create_organization()
1290+
self.create_member(user=user, organization=organization)
1291+
1292+
# Create both ExternalActor and CommitAuthor
1293+
ExternalActor.objects.create(
1294+
user_id=user.id,
1295+
organization=organization,
1296+
provider=ExternalProviders.GITHUB.value,
1297+
external_name="@externaluser",
1298+
external_id="ext123",
1299+
integration_id=7,
1300+
)
1301+
1302+
CommitAuthor.objects.create(
1303+
organization_id=organization.id,
1304+
name="Commit User",
1305+
email="committer@example.com",
1306+
external_id="github:commituser",
1307+
)
1308+
1309+
# Should use ExternalActor (higher priority)
1310+
username = _get_github_username_for_user(user, organization.id)
1311+
assert username == "externaluser"
1312+
1313+
def test_get_github_username_for_user_commit_author_no_external_id(self) -> None:
1314+
"""Tests that None is returned when CommitAuthor exists but has no external_id."""
1315+
from sentry.models.commitauthor import CommitAuthor
1316+
1317+
user = self.create_user(email="committer@example.com")
1318+
organization = self.create_organization()
1319+
self.create_member(user=user, organization=organization)
1320+
1321+
# Create CommitAuthor without external_id
1322+
CommitAuthor.objects.create(
1323+
organization_id=organization.id,
1324+
name="No External ID",
1325+
email="committer@example.com",
1326+
external_id=None,
1327+
)
1328+
1329+
username = _get_github_username_for_user(user, organization.id)
1330+
assert username is None
1331+
1332+
def test_get_github_username_for_user_wrong_organization(self) -> None:
1333+
"""Tests that CommitAuthor from different organization is not used."""
1334+
from sentry.models.commitauthor import CommitAuthor
1335+
1336+
user = self.create_user(email="committer@example.com")
1337+
organization1 = self.create_organization()
1338+
organization2 = self.create_organization()
1339+
self.create_member(user=user, organization=organization1)
1340+
1341+
# Create CommitAuthor in different organization
1342+
CommitAuthor.objects.create(
1343+
organization_id=organization2.id,
1344+
name="Wrong Org User",
1345+
email="committer@example.com",
1346+
external_id="github:wrongorguser",
1347+
)
1348+
1349+
username = _get_github_username_for_user(user, organization1.id)
1350+
assert username is None
1351+
1352+
def test_get_github_username_for_user_unverified_email_not_matched(self) -> None:
1353+
"""Tests that unverified emails don't match CommitAuthor (security requirement)."""
1354+
from sentry.models.commitauthor import CommitAuthor
1355+
1356+
user = self.create_user(email="verified@example.com")
1357+
organization = self.create_organization()
1358+
self.create_member(user=user, organization=organization)
1359+
1360+
# Add an unverified email to the user
1361+
self.create_useremail(user=user, email="unverified@example.com", is_verified=False)
1362+
1363+
# Create CommitAuthor that matches the UNVERIFIED email
1364+
CommitAuthor.objects.create(
1365+
organization_id=organization.id,
1366+
name="unverified",
1367+
email="unverified@example.com",
1368+
external_id="github:unverified",
1369+
)
1370+
1371+
# Should NOT match the unverified email (security fix)
1372+
username = _get_github_username_for_user(user, organization.id)
1373+
assert username is None
1374+
1375+
def test_get_github_username_for_user_verified_secondary_email_matched(self) -> None:
1376+
"""Tests that verified secondary emails DO match CommitAuthor."""
1377+
from sentry.models.commitauthor import CommitAuthor
1378+
1379+
user = self.create_user(email="primary@example.com")
1380+
organization = self.create_organization()
1381+
self.create_member(user=user, organization=organization)
1382+
1383+
# Add a verified secondary email
1384+
self.create_useremail(user=user, email="secondary@example.com", is_verified=True)
1385+
1386+
# Create CommitAuthor that matches the verified secondary email
1387+
CommitAuthor.objects.create(
1388+
organization_id=organization.id,
1389+
name="Developer",
1390+
email="secondary@example.com",
1391+
external_id="github:developeruser",
1392+
)
1393+
1394+
# Should match the verified secondary email
1395+
username = _get_github_username_for_user(user, organization.id)
1396+
assert username == "developeruser"
1397+
12441398

12451399
class TestRespondWithError(TestCase):
12461400
def test_respond_with_error(self) -> None:

0 commit comments

Comments
 (0)