diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index 038ec177e2bd..6158e3792a0a 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -47,7 +47,7 @@ get_retention_boundary, ) from sentry.seer.autofix.autofix import get_all_tags_overview -from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS +from sentry.seer.seer_setup import get_supported_scm_providers from sentry.seer.sentry_data_models import EAPTrace from sentry.services.eventstore.models import Event, GroupEvent from sentry.snuba.dataset import Dataset @@ -684,6 +684,9 @@ def get_repository_definition( dict with RepoDefinition fields if found, None otherwise. Includes external_id which should be stored for future lookups. """ + organization = Organization.objects.get_from_cache(id=organization_id) + supported_providers = get_supported_scm_providers(organization) + repo: Repository | None = None if external_id: @@ -691,7 +694,7 @@ def get_repository_definition( organization_id=organization_id, external_id=external_id, status=ObjectStatus.ACTIVE, - provider__in=SEER_SUPPORTED_SCM_PROVIDERS, + provider__in=supported_providers, ).first() if not repo: @@ -707,7 +710,7 @@ def get_repository_definition( organization_id=organization_id, name=repo_full_name, status=ObjectStatus.ACTIVE, - provider__in=SEER_SUPPORTED_SCM_PROVIDERS, + provider__in=supported_providers, ).first() if not repo: diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index bd986916bb2c..4b194c247c88 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -34,7 +34,6 @@ from sentry.net.http import connection_from_url from sentry.projectoptions.defaults import SEER_PROJECT_PREFERENCE_OPTION_KEYS from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus -from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS from sentry.seer.models import ( AutofixHandoffPoint, BranchOverride, @@ -48,6 +47,7 @@ SeerProjectRepository, SeerProjectRepositoryBranchOverride, ) +from sentry.seer.seer_setup import get_supported_scm_providers from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request from sentry.utils.cache import cache from sentry.utils.outcomes import Outcome, track_outcome @@ -830,7 +830,7 @@ def get_autofix_repos_from_project_code_mappings( and repo.integration_id is not None and repo.external_id and repo.provider - and repo.provider in SEER_SUPPORTED_SCM_PROVIDERS + and repo.provider in get_supported_scm_providers(project.organization) ): repo_dict = { "repository_id": repo.id, diff --git a/src/sentry/seer/constants.py b/src/sentry/seer/constants.py index 81e1a0fa791c..a421cf24ea94 100644 --- a/src/sentry/seer/constants.py +++ b/src/sentry/seer/constants.py @@ -6,8 +6,10 @@ SeerSCMProvider = Literal[ "integrations:github", "integrations:github_enterprise", + "integrations:gitlab", "github", "github_enterprise", + "gitlab", ] # Supported repository providers for Seer features @@ -17,3 +19,8 @@ IntegrationProviderSlug.GITHUB.value, IntegrationProviderSlug.GITHUB_ENTERPRISE.value, ] + +SEER_GITLAB_SCM_PROVIDERS = [ + "integrations:gitlab", + IntegrationProviderSlug.GITLAB.value, +] diff --git a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py index 9f5ee71d85b3..b9ed7af3d514 100644 --- a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py +++ b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py @@ -30,8 +30,8 @@ deduplicate_repositories, default_seer_project_preference, ) -from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS from sentry.seer.models import SeerProjectPreference, SeerRepoDefinition +from sentry.seer.seer_setup import get_supported_scm_providers from sentry.seer.utils import filter_repo_by_provider @@ -66,10 +66,11 @@ class RepositorySerializer(CamelSnakeSerializer): provider_raw = serializers.CharField(required=False, allow_null=True) def validate_provider(self, value): - if value not in SEER_SUPPORTED_SCM_PROVIDERS: - supported = ", ".join(sorted(SEER_SUPPORTED_SCM_PROVIDERS)) + supported = get_supported_scm_providers(self.context.get("organization")) + if value not in supported: + supported_str = ", ".join(sorted(supported)) raise serializers.ValidationError( - f'"{value}" is not a supported Seer provider. Supported providers: {supported}' + f'"{value}" is not a supported Seer provider. Supported providers: {supported_str}' ) return value @@ -110,7 +111,7 @@ def to_internal_value(self, data): serialized_repos = [] for repo_data in repos_data: - repo_serializer = RepositorySerializer(data=repo_data) + repo_serializer = RepositorySerializer(data=repo_data, context=self.context) if not repo_serializer.is_valid(): raise serializers.ValidationError( {f"project_{project_id_str}": repo_serializer.errors} diff --git a/src/sentry/seer/endpoints/project_seer_preferences.py b/src/sentry/seer/endpoints/project_seer_preferences.py index 8ff6db282acc..3ffa7d6bf3e0 100644 --- a/src/sentry/seer/endpoints/project_seer_preferences.py +++ b/src/sentry/seer/endpoints/project_seer_preferences.py @@ -90,7 +90,10 @@ class ProjectSeerPreferencesEndpoint(ProjectEndpoint): ) def post(self, request: Request, project: Project) -> Response: - serializer = ProjectSeerPreferencesSerializer(data=request.data) + serializer = ProjectSeerPreferencesSerializer( + data=request.data, + context={"organization": project.organization}, + ) serializer.is_valid(raise_exception=True) for repo_data in serializer.validated_data.get("repositories", []): diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index ff536aa0f0ea..30f6e279b36e 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -112,11 +112,12 @@ clear_preference_automation_handoff, read_preference_from_sentry_db, ) -from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS, SeerSCMProvider +from sentry.seer.constants import SeerSCMProvider from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils from sentry.seer.fetch_issues.utils import NoProjectsForRepoError, get_repo_and_projects from sentry.seer.issue_detection import create_issue_occurrence +from sentry.seer.seer_setup import get_supported_scm_providers from sentry.seer.utils import filter_repo_by_provider from sentry.sentry_apps.metrics import SentryAppEventType from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization @@ -704,7 +705,8 @@ def validate_repo( if not repo: return {"valid": False, "reason": "repository_not_found"} - if repo.provider not in SEER_SUPPORTED_SCM_PROVIDERS: + organization = Organization.objects.get_from_cache(id=organization_id) + if repo.provider not in get_supported_scm_providers(organization): return {"valid": False, "reason": "unsupported_provider"} return {"valid": True, "integration_id": repo.integration_id} @@ -737,7 +739,8 @@ def get_repo_installation_id( if not repo: return {"error": "repository_not_found"} - if repo.provider not in SEER_SUPPORTED_SCM_PROVIDERS: + organization = Organization.objects.get_from_cache(id=organization_id) + if repo.provider not in get_supported_scm_providers(organization): return {"error": "unsupported_provider"} if repo.integration_id is None: @@ -816,15 +819,25 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s external_id=item["external_id"], ) + org_ids = {item["organization_id"] for item in repository_integrations} + orgs_by_id = {org.id: org for org in Organization.objects.filter(id__in=org_ids)} + supported_by_org: dict[int, set[str]] = { + org_id: set(get_supported_scm_providers(org)) for org_id, org in orgs_by_id.items() + } + all_supported_providers: set[str] = set() + for providers in supported_by_org.values(): + all_supported_providers.update(providers) + existing_repos = Repository.objects.filter( - q_objects, status=ObjectStatus.ACTIVE, provider__in=SEER_SUPPORTED_SCM_PROVIDERS + q_objects, status=ObjectStatus.ACTIVE, provider__in=all_supported_providers ).values_list("organization_id", "provider", "integration_id", "external_id") existing_map: dict[tuple, int | None] = {} for org_id, provider, integration_id, external_id in existing_repos: + if provider not in supported_by_org.get(org_id, set()): + continue key = (org_id, provider, external_id) - # If multiple repos match (shouldn't happen), keep the first one if key not in existing_map: existing_map[key] = integration_id diff --git a/src/sentry/seer/seer_setup.py b/src/sentry/seer/seer_setup.py index 944375854f9c..1b5bf05d951c 100644 --- a/src/sentry/seer/seer_setup.py +++ b/src/sentry/seer/seer_setup.py @@ -3,10 +3,18 @@ from sentry import features from sentry.models.organization import Organization from sentry.organizations.services.organization.model import RpcOrganization +from sentry.seer.constants import SEER_GITLAB_SCM_PROVIDERS, SEER_SUPPORTED_SCM_PROVIDERS from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser +def get_supported_scm_providers(organization: Organization | None = None) -> list[str]: + providers = list(SEER_SUPPORTED_SCM_PROVIDERS) + if organization is not None and features.has("organizations:seer-gitlab-support", organization): + providers.extend(SEER_GITLAB_SCM_PROVIDERS) + return providers + + def has_seer_access( organization: Organization | RpcOrganization, actor: User | AnonymousUser | RpcUser | None = None, diff --git a/tests/sentry/autofix/test_utils.py b/tests/sentry/autofix/test_utils.py index 6e8f6e72c4d5..5f5e2399e2e6 100644 --- a/tests/sentry/autofix/test_utils.py +++ b/tests/sentry/autofix/test_utils.py @@ -20,6 +20,7 @@ ) from sentry.seer.models import SeerPermissionError from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.options import override_options @@ -74,6 +75,32 @@ def test_filters_out_unsupported_providers(self) -> None: assert len(repos) == 1 assert repos[0]["provider"] == "integrations:github" + @with_feature("organizations:seer-gitlab-support") + def test_includes_gitlab_repos_with_feature_flag(self) -> None: + project = self.create_project() + github_repo = self.create_repo( + name="getsentry/sentry", + provider="integrations:github", + external_id="123", + integration_id=234, + ) + self.create_code_mapping(project=project, repo=github_repo) + + gitlab_repo = self.create_repo( + name="getsentry/sentry-gitlab", + provider="integrations:gitlab", + external_id="456", + integration_id=345, + ) + self.create_code_mapping( + project=project, repo=gitlab_repo, stack_root="gitlab/", source_root="src/gitlab/" + ) + + repos = get_autofix_repos_from_project_code_mappings(project) + assert len(repos) == 2 + providers = {r["provider"] for r in repos} + assert providers == {"integrations:github", "integrations:gitlab"} + def test_filters_out_disabled_repos(self) -> None: project = self.create_project() active_repo = self.create_repo( diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index 38063dcd4e69..ca153727e76f 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -5,6 +5,7 @@ from sentry.models.repository import Repository from sentry.seer.models.project_repository import SeerProjectRepository from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature class ProjectSeerPreferencesEndpointTest(APITestCase): @@ -471,3 +472,67 @@ def test_post_rejects_unsupported_repo_provider(self) -> None: assert response.status_code == 400 assert SeerProjectRepository.objects.filter(project=self.project).count() == 0 + + @with_feature("organizations:seer-gitlab-support") + def test_post_accepts_gitlab_repo_with_feature_flag(self) -> None: + gitlab_repo = self.create_repo( + project=self.project, + name="getsentry/sentry-gitlab", + provider="integrations:gitlab", + external_id="789", + integration_id=456, + ) + + request_data = { + "repositories": [ + { + "organization_id": self.org.id, + "integration_id": "456", + "provider": "integrations:gitlab", + "owner": "getsentry", + "name": "sentry-gitlab", + "external_id": "789", + } + ], + } + + response = self.client.post(self.url, data=request_data) + + assert response.status_code == 204 + seer_repos = list( + SeerProjectRepository.objects.filter(project=self.project).select_related("repository") + ) + assert len(seer_repos) == 1 + assert seer_repos[0].repository_id == gitlab_repo.id + + @with_feature("organizations:seer-gitlab-support") + def test_post_accepts_gitlab_bare_provider_with_feature_flag(self) -> None: + gitlab_repo = self.create_repo( + project=self.project, + name="getsentry/sentry-gitlab", + provider="integrations:gitlab", + external_id="789", + integration_id=456, + ) + + request_data = { + "repositories": [ + { + "organization_id": self.org.id, + "integration_id": "456", + "provider": "gitlab", + "owner": "getsentry", + "name": "sentry-gitlab", + "external_id": "789", + } + ], + } + + response = self.client.post(self.url, data=request_data) + + assert response.status_code == 204 + seer_repos = list( + SeerProjectRepository.objects.filter(project=self.project).select_related("repository") + ) + assert len(seer_repos) == 1 + assert seer_repos[0].repository_id == gitlab_repo.id