Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
31 changes: 27 additions & 4 deletions src/sentry/seer/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,6 +107,7 @@
ProfileFlamegraphSuccessResponse,
ProjectMembersResponse,
ReplayMetadataResponse,
RepositoryDefinitionFailure,
RepositoryDefinitionResponse,
TeamMembersResponse,
TraceItemEventsResponse,
Expand Down Expand Up @@ -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,
)
Comment thread
joseph-sentry marked this conversation as resolved.
Resolved
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.
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/seer/sentry_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 66 additions & 7 deletions tests/sentry/seer/agent/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
ExecuteTimeseriesQueryErrorResponse,
ExecuteTimeseriesQuerySuccessResponse,
IssueDetailsResponse,
RepositoryDefinitionFailure,
RepositoryDefinitionResponse,
)
from sentry.services.eventstore.models import Event, GroupEvent
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Comment thread
joseph-sentry marked this conversation as resolved.
Resolved

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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"

Expand All @@ -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"

Expand All @@ -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"
Expand Down
Loading