diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index c213ac146df1..46adc0fd3d8b 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -25,6 +25,7 @@ from sentry.api.serializers.models.groupactionlogentry import get_serialized_activity_items from sentry.api.utils import MAX_STATS_PERIOD, default_start_end_dates, get_date_range_from_params from sentry.constants import ALL_ACCESS_PROJECT_ID, ObjectStatus +from sentry.integrations.services.integration import integration_service from sentry.issues.action_log.types import GroupActionType, GroupActorType from sentry.issues.formatting.formatter import Format from sentry.issues.formatting.limits import LIMITS_DEFAULT, LIMITS_LOW @@ -106,6 +107,7 @@ ProfileFlamegraphSuccessResponse, ProjectMembersResponse, ReplayMetadataResponse, + RepositoryDefinitionFailure, RepositoryDefinitionResponse, TeamMembersResponse, TraceItemEventsResponse, @@ -949,12 +951,29 @@ def _find_repository( return None +def _repository_lookup_failure( + organization_id: int, supported_providers: list[str] +) -> RepositoryDefinitionFailure | None: + """Why a lookup found nothing, when it's known.""" + integrations = integration_service.get_integrations( + organization_id=organization_id, + providers=supported_providers, + status=ObjectStatus.ACTIVE, + org_integration_status=ObjectStatus.ACTIVE, + limit=1, + ) + if not integrations: + return RepositoryDefinitionFailure(failure_reason="no_scm_integration") + return None + + def get_repository_definition( *, organization_id: int, repo_full_name: str, external_id: str | None = None, -) -> RepositoryDefinitionResponse | None: + report_failure_reason: bool = False, +) -> RepositoryDefinitionResponse | RepositoryDefinitionFailure | None: """ Look up a repository that the org has access to. Returns full RepoDefinition if found and accessible via code mappings, None otherwise. @@ -968,6 +987,9 @@ def get_repository_definition( repo_full_name: Full repository name in format "owner/repo-name" (e.g., "getsentry/seer") external_id: Optional external repository ID (e.g., GitHub repo ID). Stable across renames. If provided, this is used for lookup instead of name. + report_failure_reason: When nothing is found, say why in the response when it's known, + instead of returning None. Opt-in because callers that don't ask would read + that response as a repository. Returns: dict with RepoDefinition fields if found, None otherwise. Includes external_id @@ -996,9 +1018,8 @@ def get_repository_definition( "seer.rpc.invalid_repo_name_format", extra={"repo_full_name": repo_full_name}, ) - return None - - repo = _find_repository(organization_id, repo_full_name, supported_providers) + else: + repo = _find_repository(organization_id, repo_full_name, supported_providers) if not repo: logger.info( @@ -1009,6 +1030,8 @@ def get_repository_definition( "external_id": external_id, }, ) + if report_failure_reason: + return _repository_lookup_failure(organization_id, supported_providers) return None # Use the actual repo name from the database, not the requested name. diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index 273ffe408b59..a0a5c12f338e 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -275,6 +275,10 @@ class RepositoryDefinitionResponse(BaseModel): external_id: str | None +class RepositoryDefinitionFailure(BaseModel): + failure_reason: Literal["no_scm_integration"] + + class EventFilterKeyEntry(BaseModel): type: str diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index 9d2a5b75b2fe..7e8947b2234b 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -57,6 +57,7 @@ ExecuteTimeseriesQueryErrorResponse, ExecuteTimeseriesQuerySuccessResponse, IssueDetailsResponse, + RepositoryDefinitionFailure, RepositoryDefinitionResponse, ) from sentry.services.eventstore.models import Event, GroupEvent @@ -3065,7 +3066,7 @@ def test_get_repository_definition_success(self) -> None: repo_full_name="getsentry/seer", ) - assert result is not None + assert isinstance(result, RepositoryDefinitionResponse) assert result.repository_id == repo.id assert result.organization_id == self.organization.id assert result.integration_id == "123" @@ -3136,6 +3137,64 @@ def test_does_not_find_another_organizations_or_an_inactive_repo(self) -> None: assert not self._finds(self.organization, "my-group/sub/app") + def _lookup(self, organization: Organization, repo_full_name: str) -> Any: + return get_repository_definition( + organization_id=organization.id, + repo_full_name=repo_full_name, + report_failure_reason=True, + ) + + def _connect(self, organization: Organization, provider: str) -> None: + self.create_integration( + organization=organization, + provider=provider, + external_id=f"{provider}:{organization.id}", + name=provider, + ) + + @with_feature("organizations:seer-cursor-origin-support") + def test_failure_when_no_integrations(self) -> None: + no_integration = RepositoryDefinitionFailure(failure_reason="no_scm_integration") + assert self._lookup(self.organization, "acme-labs/app") == no_integration + assert self._lookup(self.organization, "invalid-format") == no_integration + + # A provider Seer can't read code from doesn't count. + self._connect(self.organization, "bitbucket") + assert self._lookup(self.organization, "acme-labs/app") == no_integration + + def test_failure_when_the_only_integrations_are_inactive(self) -> None: + self.create_integration( + organization=self.organization, + provider="github", + external_id="github:disabled-for-org", + oi_params={"status": ObjectStatus.DISABLED}, + ) + self.create_integration( + organization=self.organization, + provider="gitlab", + external_id="gitlab:pending-deletion", + status=ObjectStatus.PENDING_DELETION, + ) + + assert self._lookup(self.organization, "acme-labs/app") == RepositoryDefinitionFailure( + failure_reason="no_scm_integration" + ) + + def test_cursor_origin_does_not_count_without_seer_support(self) -> None: + self._connect(self.organization, "cursor_origin") + + assert self._lookup(self.organization, "acme-labs/app") == RepositoryDefinitionFailure( + failure_reason="no_scm_integration" + ) + + @with_feature("organizations:seer-cursor-origin-support") + def test_no_failure_reason_when_source_control_is_connected(self) -> None: + for provider in ("github", "github_enterprise", "gitlab", "cursor_origin"): + organization = self.create_organization() + self._connect(organization, provider) + with self.subTest(provider=provider): + assert self._lookup(organization, "acme-labs/app") is None + def test_get_repository_definition_not_found(self) -> None: """Test that nonexistent repository returns None""" result = get_repository_definition( @@ -3198,7 +3257,7 @@ def test_get_repository_definition_no_integration_id(self) -> None: repo_full_name="getsentry/seer", ) - assert result is not None + assert isinstance(result, RepositoryDefinitionResponse) assert result.integration_id is None def test_get_repository_definition_unsupported_provider(self) -> None: @@ -3235,7 +3294,7 @@ def test_get_repository_definition_github_enterprise(self) -> None: repo_full_name="getsentry/seer", ) - assert result is not None + assert isinstance(result, RepositoryDefinitionResponse) assert result.provider == "integrations:github_enterprise" def test_get_repository_definition_multiple_providers(self) -> None: @@ -3264,7 +3323,7 @@ def test_get_repository_definition_multiple_providers(self) -> None: repo_full_name="getsentry/seer", ) - assert result is not None + assert isinstance(result, RepositoryDefinitionResponse) # Should return the first matching repo (in this case, GitHub) assert result.provider in [ "integrations:github", @@ -3299,7 +3358,7 @@ def test_get_repository_definition_filters_unsupported_with_supported(self) -> N repo_full_name="getsentry/seer", ) - assert result is not None + assert isinstance(result, RepositoryDefinitionResponse) assert result.provider == "integrations:github" assert result.external_id == "12345678" @@ -3319,7 +3378,7 @@ def test_get_repository_definition_multipart_name(self) -> None: repo_full_name="getsentry/project/seer", ) - assert result is not None + assert isinstance(result, RepositoryDefinitionResponse) assert result.owner == "getsentry" assert result.name == "project/seer" @@ -3341,7 +3400,7 @@ def test_get_repository_definition_by_external_id(self) -> None: external_id="12345678", ) - assert result is not None + assert isinstance(result, RepositoryDefinitionResponse) # Should return the CURRENT name from the database assert result.owner == "getsentry" assert result.name == "new-name"