From cf1477f97e682315a054ddc15a967441a50951d7 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 1 May 2026 16:40:13 -0700 Subject: [PATCH 1/3] fix(sync_repos): Handle string JSON in VSTS and error dicts in GitLab Guard against non-dict exc.json in message_from_error and validate that GitLab search_projects returns project dicts, not error keys. Fixes SENTRY-5NAY Fixes SENTRY-5NBH Co-Authored-By: Claude Opus 4 --- src/sentry/integrations/base.py | 2 +- src/sentry/integrations/gitlab/integration.py | 4 ++ .../source_code_management/test_sync_repos.py | 48 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index 5f9ebe1d376c..ba6c2aa1ea27 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -527,7 +527,7 @@ def message_from_error(self, exc: Exception) -> str: elif isinstance(exc, UnsupportedResponseType): return ERR_UNSUPPORTED_RESPONSE_TYPE.format(content_type=exc.content_type) elif isinstance(exc, ApiError): - if exc.json: + if exc.json and isinstance(exc.json, dict): msg = self.error_message_from_json(exc.json) or "unknown error" else: msg = "unknown error" diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index ab6608d764bd..ff874c83b304 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -186,6 +186,10 @@ def get_repositories( # Note: gitlab projects are the same things as repos everywhere else group = self.get_group_id() resp = self.get_client().search_projects(group, query) + if resp and not isinstance(resp[0], dict): + raise IntegrationError( + "Expected list of projects from GitLab, got unexpected response" + ) instance = self.model.metadata["instance"] return [ { diff --git a/tests/sentry/integrations/source_code_management/test_sync_repos.py b/tests/sentry/integrations/source_code_management/test_sync_repos.py index 8f5c3498ce60..e808030db5ac 100644 --- a/tests/sentry/integrations/source_code_management/test_sync_repos.py +++ b/tests/sentry/integrations/source_code_management/test_sync_repos.py @@ -572,6 +572,39 @@ def test_creates_new_repos_for_gitlab(self) -> None: assert len(repos) == 2 assert repos[0].provider == "integrations:gitlab" + @responses.activate + def test_error_dict_response_raises_integration_error(self) -> None: + integration = self.create_provider_integration( + provider="gitlab", + name="Example Gitlab 2", + external_id="example2.gitlab.com:group-y", + metadata={ + "instance": "example.gitlab.com", + "base_url": "https://example.gitlab.com", + "domain_name": "example.gitlab.com/group-y", + "verify_ssl": False, + "group_id": 1, + "webhook_secret": "secret123", + }, + ) + identity = Identity.objects.create( + idp=self.create_identity_provider(type="gitlab", config={}), + user=self.user, + external_id="gitlab456", + data={"access_token": "123456789"}, + ) + integration.add_organization(self.organization, self.user, identity.id) + installation = integration.get_installation(organization_id=self.organization.id) + + responses.add( + responses.GET, + "https://example.gitlab.com/api/v4/groups/1/projects?search=&simple=True&include_subgroups=False&page=1&per_page=100&order_by=last_activity_at", + json={"status": "error", "error": "group not accessible"}, + ) + + with pytest.raises(IntegrationError, match="Expected list of projects"): + installation.get_repositories() + @control_silo_test class SyncReposForOrgBitbucketTestCase(TestCase): @@ -762,6 +795,21 @@ def _wrap_identity_not_valid(*args: object, **kwargs: object) -> None: with assume_test_silo_mode(SiloMode.CELL): assert Repository.objects.count() == 0 + def test_vsts_message_from_error_handles_string_json(self) -> None: + integration = self.create_provider_integration( + provider="vsts", + external_id="vsts-account-id-2", + name="MyVSTSAccount", + metadata={"domain_name": "https://myvstsaccount.visualstudio.com/"}, + ) + integration.add_organization(self.organization, self.user) + installation = integration.get_installation(organization_id=self.organization.id) + + exc = ApiForbiddenError("ADO OAuth tokens disabled") + exc.json = "ADO OAuth tokens disabled" + msg = installation.message_from_error(exc) + assert "unknown error" in msg + @control_silo_test class SyncReposForOrgBrokenIdentityTestCase(TestCase): From 9cf360206bba4ed609e0f6dd8e8c93db1e00247e Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 4 May 2026 11:10:27 -0700 Subject: [PATCH 2/3] fix(sync_repos): Add comment explaining GitLab error dict guard --- src/sentry/integrations/gitlab/integration.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index ff874c83b304..2ca39e53ffd8 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -186,6 +186,10 @@ def get_repositories( # Note: gitlab projects are the same things as repos everywhere else group = self.get_group_id() resp = self.get_client().search_projects(group, query) + # GitLab returns {"status": "error", ...} when the group is + # inaccessible. The pagination layer turns that dict into a list + # of its keys (e.g. ["status", "error"]), which would crash the + # list comprehension below with TypeError on str["id"]. if resp and not isinstance(resp[0], dict): raise IntegrationError( "Expected list of projects from GitLab, got unexpected response" From 9c25304aca38e61cbf0dab31e48b4db3ffad4e52 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 4 May 2026 11:34:48 -0700 Subject: [PATCH 3/3] fix(sync_repos): Fix mypy errors in tests --- .../integrations/source_code_management/test_sync_repos.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/sentry/integrations/source_code_management/test_sync_repos.py b/tests/sentry/integrations/source_code_management/test_sync_repos.py index e808030db5ac..57cda27f154b 100644 --- a/tests/sentry/integrations/source_code_management/test_sync_repos.py +++ b/tests/sentry/integrations/source_code_management/test_sync_repos.py @@ -10,6 +10,7 @@ from sentry.constants import ObjectStatus from sentry.integrations.github.integration import GitHubIntegrationProvider from sentry.integrations.github_enterprise.integration import GitHubEnterpriseIntegrationProvider +from sentry.integrations.gitlab.integration import GitlabIntegration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.source_code_management.sync_repos import ( sync_repos_for_org, @@ -602,6 +603,7 @@ def test_error_dict_response_raises_integration_error(self) -> None: json={"status": "error", "error": "group not accessible"}, ) + assert isinstance(installation, GitlabIntegration) with pytest.raises(IntegrationError, match="Expected list of projects"): installation.get_repositories() @@ -806,7 +808,7 @@ def test_vsts_message_from_error_handles_string_json(self) -> None: installation = integration.get_installation(organization_id=self.organization.id) exc = ApiForbiddenError("ADO OAuth tokens disabled") - exc.json = "ADO OAuth tokens disabled" + exc.json = "ADO OAuth tokens disabled" # type: ignore[assignment] msg = installation.message_from_error(exc) assert "unknown error" in msg