Repository navigation
feat(seer): Report an unconnected SCM account or missing repo on lookups #130316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,7 +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.integrations.services.integration import RpcIntegration, 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 | ||
|
|
@@ -932,6 +932,23 @@ def rpc_get_profile_flamegraph( | |
| ) | ||
|
|
||
|
|
||
| def _name_segments(name: str) -> list[str]: | ||
| return [segment.strip().casefold() for segment in name.split("/") if segment.strip()] | ||
|
|
||
|
|
||
| def _is_under_account(repo_segments: list[str], integration: RpcIntegration) -> bool: | ||
| """Whether a repository name starts with the integration's account, by the account's name or | ||
| its URL path. GitLab needs both: its repository names can start with the group's display name | ||
| ("My Group / Sub") or its path ("my-group/sub").""" | ||
| domain_name = integration.metadata.get("domain_name", "") | ||
| url_path = domain_name.removeprefix("https://").partition("/")[2] | ||
| for account_name in (integration.name, url_path): | ||
| account = _name_segments(account_name) | ||
| if 0 < len(account) < len(repo_segments) and repo_segments[: len(account)] == account: | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def _find_repository( | ||
| organization_id: int, repo_full_name: str, providers: list[str] | ||
| ) -> Repository | None: | ||
|
|
@@ -952,7 +969,7 @@ def _find_repository( | |
|
|
||
|
|
||
| def _repository_lookup_failure( | ||
| organization_id: int, supported_providers: list[str] | ||
| organization_id: int, repo_full_name: str, supported_providers: list[str] | ||
| ) -> RepositoryDefinitionFailure | None: | ||
| """Why a lookup found nothing, when it's known.""" | ||
| integrations = integration_service.get_integrations( | ||
|
|
@@ -963,7 +980,20 @@ def _repository_lookup_failure( | |
| ) | ||
| if not integrations: | ||
| return RepositoryDefinitionFailure(failure_reason="no_scm_integration") | ||
| return None | ||
|
|
||
| repo_segments = _name_segments(repo_full_name) | ||
| # A name with no owner can't be under any account, so there's nothing to say about it. | ||
| if len(repo_segments) < 2: | ||
| return None | ||
| under_connected_account = any( | ||
| _is_under_account(repo_segments, integration) for integration in integrations | ||
| ) | ||
| return RepositoryDefinitionFailure( | ||
| failure_reason=( | ||
| "repo_not_in_sentry" if under_connected_account else "scm_account_not_connected" | ||
| ), | ||
| connected_accounts=[integration.name for integration in integrations], | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖
|
||
| ) | ||
|
|
||
|
|
||
| def get_repository_definition( | ||
|
|
@@ -987,8 +1017,10 @@ def get_repository_definition( | |
| 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. | ||
| instead of returning None: no source control connected, the repository's | ||
| account isn't connected, or its account is connected but the repository isn't | ||
| in Sentry. 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 | ||
|
|
@@ -1030,7 +1062,7 @@ def get_repository_definition( | |
| }, | ||
| ) | ||
| if report_failure_reason: | ||
| return _repository_lookup_failure(organization_id, supported_providers) | ||
| return _repository_lookup_failure(organization_id, repo_full_name, supported_providers) | ||
| return None | ||
|
|
||
| # Use the actual repo name from the database, not the requested name. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3049,6 +3049,16 @@ def test_get_recommended_event_fallback_if_no_events_with_spans_in_clamped_range | |
| assert result.event_id == event2.event_id | ||
|
|
||
|
|
||
| # Each provider Seer reads code from: its integration's name and domain, and the path its | ||
| # repository names start with. | ||
| SEER_SCM_ACCOUNTS = [ | ||
| ("github", "acme-labs", "github.com/acme-labs", "acme-labs"), | ||
| ("github_enterprise", "acme-labs", "ghe.example.com/acme-labs", "acme-labs"), | ||
| ("gitlab", "My Group / Sub", "gitlab.com/my-group/sub", "my-group/sub"), | ||
| ("cursor_origin", "acme-labs", "https://cursor.com/codebase/acme-labs", "acme-labs"), | ||
| ] | ||
|
|
||
|
|
||
| class TestGetRepositoryDefinition(APITransactionTestCase): | ||
| def test_get_repository_definition_success(self) -> None: | ||
| """Test successful repository lookup""" | ||
|
|
@@ -3144,12 +3154,15 @@ def _lookup(self, organization: Organization, repo_full_name: str) -> Any: | |
| report_failure_reason=True, | ||
| ) | ||
|
|
||
| def _connect(self, organization: Organization, provider: str) -> None: | ||
| def _connect( | ||
| self, organization: Organization, provider: str, name: str = "", domain_name: str = "" | ||
| ) -> None: | ||
| self.create_integration( | ||
| organization=organization, | ||
| provider=provider, | ||
| external_id=f"{provider}:{organization.id}", | ||
| name=provider, | ||
| name=name, | ||
| metadata={"domain_name": domain_name}, | ||
| ) | ||
|
|
||
| @with_feature("organizations:seer-cursor-origin-support") | ||
|
|
@@ -3170,12 +3183,52 @@ def test_cursor_origin_does_not_count_without_seer_support(self) -> None: | |
| ) | ||
|
|
||
| @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"): | ||
| def test_failure_for_each_provider(self) -> None: | ||
| for provider, name, domain_name, path in SEER_SCM_ACCOUNTS: | ||
| organization = self.create_organization() | ||
| self._connect(organization, provider) | ||
| self._connect(organization, provider, name, domain_name) | ||
| not_connected = RepositoryDefinitionFailure( | ||
| failure_reason="scm_account_not_connected", connected_accounts=[name] | ||
| ) | ||
| not_in_sentry = RepositoryDefinitionFailure( | ||
| failure_reason="repo_not_in_sentry", connected_accounts=[name] | ||
| ) | ||
| with self.subTest(provider=provider): | ||
| assert self._lookup(organization, "acme-labs/app") is None | ||
| assert self._lookup(organization, "other-org/app") == not_connected | ||
| # One that only starts the same isn't that account. | ||
| assert self._lookup(organization, f"{path}-2/app") == not_connected | ||
| assert self._lookup(organization, f"{path}/app") == not_in_sentry | ||
| assert self._lookup(organization, f"{path.upper()}/app") == not_in_sentry | ||
| # A name with no owner can't be under any account. | ||
| assert self._lookup(organization, "invalid-format") is None | ||
|
|
||
| def test_failure_for_gitlab_group_names(self) -> None: | ||
| self._connect(self.organization, "gitlab", "My Group / Sub", "gitlab.com/my-group/sub") | ||
|
|
||
| for repo_name in ("My Group / Sub / App", "my-group/sub/nested/app"): | ||
| result = self._lookup(self.organization, repo_name) | ||
| assert result.failure_reason == "repo_not_in_sentry" | ||
| for repo_name in ("my-group/other/app", "my-group/app"): | ||
| result = self._lookup(self.organization, repo_name) | ||
| assert result.failure_reason == "scm_account_not_connected" | ||
|
Comment on lines
+3211
to
+3213
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Not-connected GitLab cases repeat
|
||
|
|
||
| def test_failure_when_the_account_has_no_url(self) -> None: | ||
| self._connect(self.organization, "github", "acme-labs") | ||
|
|
||
| result = self._lookup(self.organization, "other-org/app") | ||
| assert result.failure_reason == "scm_account_not_connected" | ||
|
|
||
| @with_feature("organizations:seer-cursor-origin-support") | ||
| def test_failure_checks_every_connected_account(self) -> None: | ||
| for provider, name, domain_name, _ in SEER_SCM_ACCOUNTS: | ||
| self._connect(self.organization, provider, name, domain_name) | ||
|
|
||
| result = self._lookup(self.organization, "my-group/sub/app") | ||
| assert result.failure_reason == "repo_not_in_sentry" | ||
| assert set(result.connected_accounts) == {"acme-labs", "My Group / Sub"} | ||
|
|
||
| result = self._lookup(self.organization, "other-org/app") | ||
| assert result.failure_reason == "scm_account_not_connected" | ||
|
|
||
| def test_get_repository_definition_only_reports_the_reason_when_asked(self) -> None: | ||
| assert ( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Classifier normalizes names more loosely than the lookup, so misses get wrong reasons
_name_segmentsstrips whitespace around each/and casefolds._find_repositorydoes neither: it matchesnameexactly and compares aurlsuffix. So a lookup that misses only because of how the name is written still gets a confident failure reason, and the reason is wrong:My Group/Sub/App, where Sentry storesMy Group / Sub / Appwith URL pathmy-group/sub/app._find_repositorymisses it, becauseQ(name=...)is exact and the URL doesn't end with that string. Here,["my group", "sub", "app"]starts with the integration nameMy Group / Sub, so the response isrepo_not_in_sentry, but the repo is in Sentry. This input is realistic. Seer's own_normalize_repo_full_name(seer/automation/explorer/tools/utils.py) says the LLM passes the stripped form, and that Seer only recovers from it when it already has anexternal_idhint.github.com/acme-labs/app.url__iendswithmatches the repo as a candidate, but the Python check rejects it. Then["github.com", "acme-labs", "app"]doesn't start withacme-labs, so the response isscm_account_not_connected. The agent would then ask the user to install the app on an account where it's already installed, for a repo Sentry already has. That is the misleading advice this PR is meant to prevent.Fix: normalize the requested name once and use the result for both the lookup and the classification.
_find_repositorycould also tryQ(name__iexact=" / ".join(segments)), since GitLab'sname_with_namespacealways joins with/, and compare segment-normalized names in its Python check._repository_lookup_failurereturnNonewhen the first segment is a connected integration's host. In that case the reason isn't actually known, andNonematches the function's "when it's known" contract.Written by an AI agent and included in this review by its author.