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
8 changes: 6 additions & 2 deletions src/sentry/integrations/github/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,12 @@ def is_rate_limited_error(self, exc: ApiError) -> bool:
return False

def is_broken_integration_error(self, exc: Exception) -> HaltReason | None:
if isinstance(exc, ApiForbiddenError) and "suspended" in str(exc):
return "installation_suspended"
if isinstance(exc, ApiForbiddenError):
if self.is_rate_limited_error(exc):
return "rate_limited"
if "suspended" in str(exc):
return "installation_suspended"
return "unauthorized"
return super().is_broken_integration_error(exc)

def message_from_error(self, exc: Exception) -> str:
Comment on lines 224 to 234
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The ApiForbiddenError handling for repo auto-sync was not applied to GitHubEnterpriseIntegration, causing syncs to fail for GHE instead of silently halting on certain permission errors.
Severity: MEDIUM

Suggested Fix

Update GitHubEnterpriseIntegration.is_broken_integration_error in src/sentry/integrations/github_enterprise/integration.py to mirror the logic in GitHubIntegration.is_broken_integration_error. It should handle ApiForbiddenError by returning 'unauthorized' to ensure the sync process halts gracefully.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/sentry/integrations/github/integration.py#L223-L234

Potential issue: The fix to silently halt on `ApiForbiddenError` during repository
auto-sync was only applied to `GitHubIntegration` and not its counterpart,
`GitHubEnterpriseIntegration`. For GitHub Enterprise, an `ApiForbiddenError` that is not
related to a suspended account (e.g., an IP allowlist block) will not be correctly
handled by `is_broken_integration_error`. The error propagates up to `sync_repos.py`,
where it is re-raised, causing the sync process to fail for the integration instead of
being silently halted.

Did we get this right? 👍 / 👎 to inform future reviews.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -879,8 +879,11 @@ def test_api_unauthorized(self) -> None:
== "unauthorized"
)

def test_api_forbidden_not_terminal(self) -> None:
assert self.installation.is_broken_integration_error(ApiForbiddenError("forbidden")) is None
def test_api_forbidden_returns_unauthorized(self) -> None:
assert (
self.installation.is_broken_integration_error(ApiForbiddenError("forbidden"))
== "unauthorized"
)

def test_api_forbidden_suspended_returns_installation_suspended(self) -> None:
exc = ApiForbiddenError('{"message":"This installation has been suspended"}')
Expand Down Expand Up @@ -956,6 +959,46 @@ def test_rate_limited_via_is_rate_limited_error(self) -> None:
assert self.installation.is_broken_integration_error(exc) == "rate_limited"


@control_silo_test
@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1")
class GitHubIsBrokenIntegrationErrorTestCase(IntegrationTestCase):
"""Tests for the GitHubIntegration.is_broken_integration_error override."""

provider = GitHubIntegrationProvider
base_url = "https://api.github.com"
key = "github"

def setUp(self) -> None:
super().setUp()
self.installation = self.integration.get_installation(organization_id=self.organization.id)

def test_forbidden_ip_allow_list_returns_unauthorized(self, _: MagicMock) -> None:
exc = ApiForbiddenError(
'{"message":"the org has an IP allow list enabled, '
'and your IP address is not permitted"}'
)
assert self.installation.is_broken_integration_error(exc) == "unauthorized"

def test_forbidden_suspended_returns_installation_suspended(self, _: MagicMock) -> None:
exc = ApiForbiddenError('{"message":"This installation has been suspended"}')
assert self.installation.is_broken_integration_error(exc) == "installation_suspended"

def test_forbidden_generic_returns_unauthorized(self, _: MagicMock) -> None:
exc = ApiForbiddenError("some other 403")
assert self.installation.is_broken_integration_error(exc) == "unauthorized"

def test_rate_limited_forbidden_returns_rate_limited(self, _: MagicMock) -> None:
exc = ApiForbiddenError('{"message":"API rate limit exceeded"}')
assert self.installation.is_broken_integration_error(exc) == "rate_limited"

def test_base_class_cases_still_work(self, _: MagicMock) -> None:
assert (
self.installation.is_broken_integration_error(ApiUnauthorized("bad token"))
== "unauthorized"
)
assert self.installation.is_broken_integration_error(RuntimeError("boom")) is None


@control_silo_test
@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1")
class SyncReposForOrgNewErrorHandlingTestCase(IntegrationTestCase):
Expand Down
Loading