From e0e3e45bc6df0b9222fd1f0a8fcf8ee2516a8271 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 6 Nov 2026 16:42:56 -0500 Subject: [PATCH] fix(seer): Find repos by their URL path GitLab has 2 formats for repo slugs: - display format: `My Group / App` - url format: `my-group/app` we were previously requiring the agent to search by display format, but the url format is also valid, so we now have our search look through both formats by checking Repository URLs. --- src/sentry/seer/agent/tools.py | 29 ++++++++++---- tests/sentry/seer/agent/test_tools.py | 55 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index 3c644600f53e..c213ac146df1 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -8,6 +8,7 @@ from django.core.exceptions import BadRequest from django.db import models +from django.db.models import Q from rest_framework.exceptions import ParseError from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType @@ -929,6 +930,25 @@ def rpc_get_profile_flamegraph( ) +def _find_repository( + organization_id: int, repo_full_name: str, providers: list[str] +) -> Repository | None: + """Find a repository by its name or its URL path. They're the same except on GitLab, where the + name is a display name ("My Group / App") and the path is "my-group/app".""" + candidates = Repository.objects.filter( + Q(name=repo_full_name) | Q(url__iendswith=f"/{repo_full_name}"), + organization_id=organization_id, + status=ObjectStatus.ACTIVE, + provider__in=providers, + ).order_by("id") + # A URL can also end this way for a repository further down: ".../parent/my-group/app". + requested = repo_full_name.casefold() + for repo in candidates: + if requested in (repo.name.casefold(), get_repo_url_path(repo).casefold()): + return repo + return None + + def get_repository_definition( *, organization_id: int, @@ -941,7 +961,7 @@ def get_repository_definition( Lookup priority: 1. external_id (GitHub's repo ID - stable across renames) - 2. Current name (exact match) + 2. Current name or URL path Args: organization_id: The ID of the organization @@ -978,12 +998,7 @@ def get_repository_definition( ) return None - repo = Repository.objects.filter( - organization_id=organization_id, - name=repo_full_name, - status=ObjectStatus.ACTIVE, - provider__in=supported_providers, - ).first() + repo = _find_repository(organization_id, repo_full_name, supported_providers) if not repo: logger.info( diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index 43854bed24ac..9d2a5b75b2fe 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -22,6 +22,7 @@ from sentry.models.groupassignee import GroupAssignee from sentry.models.groupowner import GroupOwner, GroupOwnerType, SuspectCommitStrategy from sentry.models.grouprelease import GroupRelease +from sentry.models.organization import Organization from sentry.models.projectownership import ProjectOwnership from sentry.models.repository import Repository from sentry.processing_errors.grouptype import LowValueSpanConfigurationType @@ -56,6 +57,7 @@ ExecuteTimeseriesQueryErrorResponse, ExecuteTimeseriesQuerySuccessResponse, IssueDetailsResponse, + RepositoryDefinitionResponse, ) from sentry.services.eventstore.models import Event, GroupEvent from sentry.testutils.cases import ( @@ -3081,6 +3083,59 @@ def test_get_repository_definition_invalid_format(self) -> None: assert result is None + def _finds(self, organization: Organization, repo_full_name: str) -> bool: + return isinstance( + get_repository_definition( + organization_id=organization.id, repo_full_name=repo_full_name + ), + RepositoryDefinitionResponse, + ) + + def _add_gitlab_repo(self, organization: Organization, **repo_params: Any) -> None: + Repository.objects.create( + organization_id=organization.id, + name="My Group / Sub / App", + url="https://gitlab.com/my-group/sub/app", + provider="integrations:gitlab", + external_id="1", + config={"path": "my-group/sub/app"}, + **repo_params, + ) + + @with_feature("organizations:seer-cursor-origin-support") + def test_finds_a_repo_by_its_name_or_url_path(self) -> None: + for provider, name, url, path in ( + ("github", "acme/app", "https://github.com/acme/app", "acme/app"), + ("github_enterprise", "acme/app", "https://ghe.example.com/acme/app", "acme/app"), + ("gitlab", "My Group / App", "https://gitlab.com/my-group/app", "my-group/app"), + ("cursor_origin", "acme/app", "https://cursor.com/codebase/acme/app", "acme/app"), + ): + organization = self.create_organization() + Repository.objects.create( + organization_id=organization.id, + name=name, + url=url, + provider=f"integrations:{provider}", + external_id="1", + config={"path": path}, + ) + with self.subTest(provider=provider): + assert self._finds(organization, name) + assert self._finds(organization, path) + assert self._finds(organization, path.upper()) + assert not self._finds(organization, f"{path}-2") + + def test_does_not_find_a_repo_whose_url_only_ends_the_same(self) -> None: + self._add_gitlab_repo(self.organization) + + assert not self._finds(self.organization, "sub/app") + + def test_does_not_find_another_organizations_or_an_inactive_repo(self) -> None: + self._add_gitlab_repo(self.create_organization()) + self._add_gitlab_repo(self.organization, status=ObjectStatus.DISABLED) + + assert not self._finds(self.organization, "my-group/sub/app") + def test_get_repository_definition_not_found(self) -> None: """Test that nonexistent repository returns None""" result = get_repository_definition(