From 70efd7da60aa0b7a1bdc840c2b4871631cf36a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 19:52:32 +0000 Subject: [PATCH 1/4] fix(copilot): don't show permissions modal for Copilot licensing 403s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GitHub Copilot returns a 403 with "not licensed to use Copilot", the user's account lacks an active Copilot subscription — not a GitHub App permissions issue. Previously, all Copilot 403s were treated as `github_app_permissions` failures, which caused the "Update GitHub App Permissions" modal to be shown incorrectly. Now, 403 responses containing "not licensed" are surfaced as a `generic` failure with a clear licensing message, so the permissions modal is only shown for actual GitHub App permission issues. https://claude.ai/code/session_01AjW2wHdpAqzdugENctTaib --- src/sentry/seer/autofix/coding_agent.py | 25 ++++++---- .../test_organization_coding_agents.py | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 912573c87debe8..41ca453f98a1ab 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -329,17 +329,20 @@ def _launch_agents_for_repos( if isinstance(e, ApiError): url_part = f" ({e.url})" if e.url else "" if e.code == 403 and client is not None: - failure_type = "github_app_permissions" - error_message = f"The Sentry GitHub App installation does not have the required permissions for {repo_name}. Please update your GitHub App permissions to include 'contents:write'." - if repo and repo.integration_id: - try: - sentry_integration = integration_service.get_integration( - integration_id=int(repo.integration_id) - ) - if sentry_integration: - github_installation_id = sentry_integration.external_id - except Exception: - sentry_sdk.capture_exception(level="warning") + if e.text and "not licensed" in e.text: + error_message = "Your GitHub account does not have an active Copilot license. Please check your GitHub Copilot subscription." + else: + failure_type = "github_app_permissions" + error_message = f"The Sentry GitHub App installation does not have the required permissions for {repo_name}. Please update your GitHub App permissions to include 'contents:write'." + if repo and repo.integration_id: + try: + sentry_integration = integration_service.get_integration( + integration_id=int(repo.integration_id) + ) + if sentry_integration: + github_installation_id = sentry_integration.external_id + except Exception: + sentry_sdk.capture_exception(level="warning") elif e.code == 401: error_message = f"Failed to make request to coding agent{url_part}. Please check that your API credentials are correct: {e.code} Error: {e.text}" else: diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index c511ceb191ccb2..7129feddc5f17f 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -1189,6 +1189,56 @@ def test_copilot_403_returns_github_app_permissions_failure_type( failure = response.data["failures"][0] assert failure["failure_type"] == "github_app_permissions" + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") + @patch("sentry.seer.autofix.coding_agent.get_autofix_state") + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") + @patch("sentry.seer.autofix.coding_agent.GithubCopilotAgentClient") + @patch("sentry.seer.autofix.coding_agent.github_copilot_identity_service") + def test_copilot_not_licensed_403_returns_generic_failure_type( + self, + mock_identity_service, + mock_copilot_client_class, + mock_get_preferences, + mock_get_prompt, + mock_get_autofix_state, + mock_get_providers, + ): + """Test POST endpoint returns failure_type=generic for Copilot 403s with a licensing error. + + When GitHub Copilot returns a 403 with "not licensed to use Copilot", the user's + account lacks an active Copilot subscription. This is distinct from a GitHub App + permissions issue, so we should NOT show the permissions modal. + """ + mock_get_providers.return_value = ["github"] + mock_get_prompt.return_value = "Test prompt" + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) + mock_identity_service.get_access_token_for_user.return_value = "test-copilot-token" + + mock_client_instance = MagicMock() + mock_copilot_client_class.return_value = mock_client_instance + mock_client_instance.launch.side_effect = ApiError( + "unauthorized: not licensed to use Copilot", code=403 + ) + + mock_get_autofix_state.return_value = self._create_mock_autofix_state() + + data = {"provider": "github_copilot", "run_id": 123} + + with ( + self.feature("organizations:seer-coding-agent-integrations"), + self.feature("organizations:integrations-github-copilot-agent"), + patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer"), + ): + response = self.get_success_response(self.organization.slug, method="post", **data) + assert response.data["success"] is True + assert response.data["failed_count"] >= 1 + failure = response.data["failures"][0] + assert failure["failure_type"] == "generic" + assert "not licensed" in failure["error_message"] or "Copilot license" in failure["error_message"] + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") From edadb7d65d8ece2df73ae170be4a94490ba332ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 20:15:52 +0000 Subject: [PATCH 2/4] fix(pre-commit): apply ruff format to test assertion https://claude.ai/code/session_01AjW2wHdpAqzdugENctTaib --- .../api/endpoints/test_organization_coding_agents.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index 7129feddc5f17f..a99b57b8692be8 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -1237,7 +1237,10 @@ def test_copilot_not_licensed_403_returns_generic_failure_type( assert response.data["failed_count"] >= 1 failure = response.data["failures"][0] assert failure["failure_type"] == "generic" - assert "not licensed" in failure["error_message"] or "Copilot license" in failure["error_message"] + assert ( + "not licensed" in failure["error_message"] + or "Copilot license" in failure["error_message"] + ) @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") From 1e3f670f6b85ed55fa8de8ae509520017770626c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 21:40:07 +0000 Subject: [PATCH 3/4] fix(copilot): apply same licensing 403 fix to explorer handoff path The same Copilot 403 handling bug existed in the explorer path (coding_agent_handoff.py). Licensing errors ("not licensed to use Copilot") were incorrectly classified as github_app_permissions, showing the permissions modal instead of a licensing message. https://claude.ai/code/session_01AjW2wHdpAqzdugENctTaib --- .../seer/explorer/coding_agent_handoff.py | 25 +++++----- .../explorer/test_coding_agent_handoff.py | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/sentry/seer/explorer/coding_agent_handoff.py b/src/sentry/seer/explorer/coding_agent_handoff.py index d87c9f682a06c3..c83fec9a6df0f6 100644 --- a/src/sentry/seer/explorer/coding_agent_handoff.py +++ b/src/sentry/seer/explorer/coding_agent_handoff.py @@ -149,17 +149,20 @@ def launch_coding_agents( error_message = "Failed to launch coding agent" github_installation_id: str | None = None if isinstance(e, ApiError) and e.code == 403 and is_github_copilot: - failure_type = "github_app_permissions" - error_message = f"The Sentry GitHub App installation does not have the required permissions for {repo_name}. Please update your GitHub App permissions to include 'contents:write'." - try: - github_integrations = integration_service.get_integrations( - organization_id=organization.id, - providers=["github"], - ) - if github_integrations: - github_installation_id = github_integrations[0].external_id - except Exception: - sentry_sdk.capture_exception(level="warning") + if e.text and "not licensed" in e.text: + error_message = "Your GitHub account does not have an active Copilot license. Please check your GitHub Copilot subscription." + else: + failure_type = "github_app_permissions" + error_message = f"The Sentry GitHub App installation does not have the required permissions for {repo_name}. Please update your GitHub App permissions to include 'contents:write'." + try: + github_integrations = integration_service.get_integrations( + organization_id=organization.id, + providers=["github"], + ) + if github_integrations: + github_installation_id = github_integrations[0].external_id + except Exception: + sentry_sdk.capture_exception(level="warning") failure: dict = { "repo_name": repo_name, diff --git a/tests/sentry/seer/explorer/test_coding_agent_handoff.py b/tests/sentry/seer/explorer/test_coding_agent_handoff.py index f8fbe437cbda8d..f318330c756538 100644 --- a/tests/sentry/seer/explorer/test_coding_agent_handoff.py +++ b/tests/sentry/seer/explorer/test_coding_agent_handoff.py @@ -4,6 +4,7 @@ from rest_framework.exceptions import PermissionDenied from sentry.seer.explorer.coding_agent_handoff import launch_coding_agents +from sentry.shared_integrations.exceptions import ApiError from sentry.testutils.cases import TestCase @@ -135,3 +136,48 @@ def test_branch_name_is_sanitized(self, mock_features, mock_validate, mock_store # Verify launch was called with a sanitized branch name launch_request = mock_installation.launch.call_args[0][0] assert launch_request.branch_name.startswith("my-fix-") + + @patch("sentry.seer.explorer.coding_agent_handoff.store_coding_agent_states_to_seer") + @patch("sentry.seer.explorer.coding_agent_handoff.GithubCopilotAgentClient") + @patch("sentry.seer.explorer.coding_agent_handoff.github_copilot_identity_service") + @patch("sentry.seer.explorer.coding_agent_handoff.features.has") + def test_copilot_not_licensed_403_returns_generic_failure_type( + self, + mock_features, + mock_identity_service, + mock_copilot_client_class, + mock_store, + ): + """Test that Copilot 403 'not licensed' errors return generic failure_type. + + When GitHub Copilot returns a 403 with "not licensed to use Copilot", the user's + account lacks an active Copilot subscription. This is distinct from a GitHub App + permissions issue, so we should NOT show the permissions modal. + """ + mock_features.return_value = True + mock_identity_service.get_access_token_for_user.return_value = "test-token" + + mock_client_instance = MagicMock() + mock_copilot_client_class.return_value = mock_client_instance + mock_client_instance.launch.side_effect = ApiError( + "unauthorized: not licensed to use Copilot", code=403 + ) + + result = launch_coding_agents( + organization=self.organization, + integration_id=None, + run_id=self.run_id, + prompt="Fix the bug", + repos=["owner/repo"], + provider="github_copilot", + user_id=1, + ) + + assert len(result["successes"]) == 0 + assert len(result["failures"]) == 1 + failure = result["failures"][0] + assert failure["failure_type"] == "generic" + assert ( + "not licensed" in failure["error_message"] + or "Copilot license" in failure["error_message"] + ) From ea3f52e643942a9383432f52b8e4b0bad35ca581 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 23:03:43 +0000 Subject: [PATCH 4/4] fix(copilot): make success field reflect whether any agents launched success: True when all repos fail is misleading. Change success to be len(successes) > 0 so it's False when no agents launched, and update test assertions accordingly. https://claude.ai/code/session_01AjW2wHdpAqzdugENctTaib --- .../api/endpoints/organization_coding_agents.py | 2 +- .../api/endpoints/test_organization_coding_agents.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/organization_coding_agents.py b/src/sentry/integrations/api/endpoints/organization_coding_agents.py index a14876d15136bc..96328e6cd8cb68 100644 --- a/src/sentry/integrations/api/endpoints/organization_coding_agents.py +++ b/src/sentry/integrations/api/endpoints/organization_coding_agents.py @@ -159,7 +159,7 @@ def post(self, request: Request, organization: Organization) -> Response: failures = results["failures"] response_data: LaunchResponse = { - "success": True, + "success": len(successes) > 0, "launched_count": len(successes), "failed_count": len(failures), } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index a99b57b8692be8..46e6150c0fcb94 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -1029,8 +1029,8 @@ def test_all_repos_fail_returns_failures( with self.feature("organizations:seer-coding-agent-integrations"): response = self.get_success_response(self.organization.slug, method="post", **data) - # Should succeed but with all failures - assert response.data["success"] is True + # All repos failed, so success is False (but HTTP 200 is still returned) + assert response.data["success"] is False assert response.data["launched_count"] == 0 assert response.data["failed_count"] == 2 # Should have failure details @@ -1136,7 +1136,7 @@ def test_org_installation_403_returns_generic_failure_type( with self.feature("organizations:seer-coding-agent-integrations"): response = self.get_success_response(self.organization.slug, method="post", **data) - assert response.data["success"] is True + assert response.data["success"] is False assert response.data["failed_count"] >= 1 failure = response.data["failures"][0] assert failure["failure_type"] == "generic" @@ -1184,7 +1184,7 @@ def test_copilot_403_returns_github_app_permissions_failure_type( patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer"), ): response = self.get_success_response(self.organization.slug, method="post", **data) - assert response.data["success"] is True + assert response.data["success"] is False assert response.data["failed_count"] >= 1 failure = response.data["failures"][0] assert failure["failure_type"] == "github_app_permissions" @@ -1233,7 +1233,7 @@ def test_copilot_not_licensed_403_returns_generic_failure_type( patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer"), ): response = self.get_success_response(self.organization.slug, method="post", **data) - assert response.data["success"] is True + assert response.data["success"] is False assert response.data["failed_count"] >= 1 failure = response.data["failures"][0] assert failure["failure_type"] == "generic" @@ -1280,7 +1280,7 @@ def test_non_403_error_returns_generic_failure_type( with self.feature("organizations:seer-coding-agent-integrations"): response = self.get_success_response(self.organization.slug, method="post", **data) - assert response.data["success"] is True + assert response.data["success"] is False assert response.data["failed_count"] >= 1 assert "failures" in response.data failure = response.data["failures"][0]