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(