From c64dbc1d40836e7c9442f48f5b84bc22091b0472 Mon Sep 17 00:00:00 2001 From: sehr-m <58871345+sehr-m@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:35:03 -0700 Subject: [PATCH] ref(seer): migrate external issue details to one-shot --- .../integrations/utils/external_issues.py | 82 +++-------- tests/sentry/integrations/test_issues.py | 6 +- .../utils/test_external_issues.py | 129 +++++++++++------- tools/flake8_plugin.py | 6 - 4 files changed, 95 insertions(+), 128 deletions(-) diff --git a/src/sentry/integrations/utils/external_issues.py b/src/sentry/integrations/utils/external_issues.py index a32569d252b6..70fa758cb862 100644 --- a/src/sentry/integrations/utils/external_issues.py +++ b/src/sentry/integrations/utils/external_issues.py @@ -5,29 +5,15 @@ from sentry import features from sentry.models.group import Group +from sentry.seer.oneshot import run_oneshot from sentry.seer.seer_setup import has_seer_access -from sentry.seer.signed_seer_api import ( - LlmGenerateRequest, - SeerViewerContext, - make_llm_generate_request, -) from sentry.services.eventstore.models import GroupEvent from sentry.users.models.user import User from sentry.users.services.user import RpcUser -from sentry.utils import json from sentry.utils.safe import safe_execute logger = logging.getLogger(__name__) -SYSTEM_PROMPT = """You are a helpful assistant that generates concise titles and descriptions for issue tickets in external project management tools like Jira, GitHub Issues, and Linear. - -Given information about a Sentry error (title and stack trace / error details), generate: -1. A short, actionable title (3-8 words) suitable for a ticket. Describe the problem clearly. -2. A brief description (1-3 sentences) summarizing the error, its likely cause, and potential impact. - -Do not include Sentry-specific formatting, links, or markdown. Keep the description in plain text. -Return a JSON object with "title" and "description" keys. Return only the JSON, nothing else.""" - MAX_CONTEXT_LENGTH = 2000 @@ -64,58 +50,25 @@ class GeneratedExternalIssueDetails(TypedDict): def _make_generate_external_issue_details_request( - group: Group, event: Any | None = None, viewer_context: SeerViewerContext | None = None + group: Group, event: Any | None = None, user_id: int | None = None ) -> GeneratedExternalIssueDetails | None: - logging_ctx: dict[str, Any] = {"group_id": group.id, "viewer_context": viewer_context} context = _build_event_context(group, event=event) - - body = LlmGenerateRequest( - provider="gemini", - model="flash", - referrer="sentry.external-issue.details-generate", - prompt=f"Generate a title and description for this Sentry error:\n\n{context}", - system_prompt=SYSTEM_PROMPT, - temperature=0.3, - max_tokens=750, - response_schema={ - "type": "object", - "properties": { - "title": {"type": "string"}, - "description": {"type": "string"}, - }, - "required": ["title", "description"], - }, + result = run_oneshot( + "external_issue_details", + {"context": context}, + group.organization, + user_id=user_id, + timeout=10, ) - response = make_llm_generate_request(body, timeout=10, viewer_context=viewer_context) - logging_ctx["status_code"] = response.status - if response.status >= 400: - logger.warning("external_issues.seer_request_failed", extra=logging_ctx) - return None - - try: - data = response.json() - except (json.JSONDecodeError, ValueError): - logger.warning( - "external_issues.seer_response_json_failed", extra=logging_ctx, exc_info=True - ) - return None - content = data.get("content") - try: - content = json.loads(content) - except (json.JSONDecodeError, TypeError, ValueError): - logger.warning( - "external_issues.seer_response_parse_failed", extra=logging_ctx, exc_info=True - ) - return None - - title = content.get("title") - description = content.get("description") - if title and description: + title = result.get("title") + description = result.get("description") + if isinstance(title, str) and isinstance(description, str) and title and description: return {"title": title.strip(), "description": description.strip()} - logging_ctx["title"] = title - logging_ctx["description"] = description - logger.warning("external_issues.invalid_shape", extra=logging_ctx) + logger.warning( + "external_issues.invalid_shape", + extra={"group_id": group.id, "title": title, "description": description}, + ) return None @@ -130,10 +83,7 @@ def maybe_generate_external_issue_details( return empty_result try: - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=user.id) - result = _make_generate_external_issue_details_request( - group, event=event, viewer_context=viewer_context - ) + result = _make_generate_external_issue_details_request(group, event=event, user_id=user.id) except Exception: logger.error("external_issues.generate_issue_details_failed", exc_info=True) return empty_result diff --git a/tests/sentry/integrations/test_issues.py b/tests/sentry/integrations/test_issues.py index ab1c376661b8..d77748b2dc97 100644 --- a/tests/sentry/integrations/test_issues.py +++ b/tests/sentry/integrations/test_issues.py @@ -802,7 +802,7 @@ def test_falls_back_when_ai_returns_empty(self, mock_generate: MagicMock) -> Non title_field = next(f for f in config if f["name"] == "title") assert title_field["default"] == self.group.get_latest_event().title - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_feature_flag_disabled_skips_ai(self, mock_request: MagicMock) -> None: config = self.installation.get_create_issue_config(self.group, self.user) @@ -810,7 +810,7 @@ def test_feature_flag_disabled_skips_ai(self, mock_request: MagicMock) -> None: assert title_field["default"] == self.group.get_latest_event().title mock_request.assert_not_called() - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_hide_ai_features_skips_ai(self, mock_request: MagicMock) -> None: self.group.organization.update_option("sentry:hide_ai_features", True) @@ -821,7 +821,7 @@ def test_hide_ai_features_skips_ai(self, mock_request: MagicMock) -> None: assert title_field["default"] == self.group.get_latest_event().title mock_request.assert_not_called() - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_ai_exception_falls_back(self, mock_request: MagicMock) -> None: mock_request.side_effect = Exception("Connection error") diff --git a/tests/sentry/integrations/utils/test_external_issues.py b/tests/sentry/integrations/utils/test_external_issues.py index 6922d16f8630..9824cd666132 100644 --- a/tests/sentry/integrations/utils/test_external_issues.py +++ b/tests/sentry/integrations/utils/test_external_issues.py @@ -1,16 +1,25 @@ from unittest.mock import MagicMock, patch +import orjson import pytest from django.test import override_settings +from urllib3.response import HTTPResponse from sentry.integrations.utils.external_issues import ( MAX_CONTEXT_LENGTH, GeneratedExternalIssueDetails, + _build_event_context, _make_generate_external_issue_details_request, maybe_generate_external_issue_details, ) from sentry.testutils.cases import TestCase -from sentry.utils import json +from sentry.viewer_context import ( + Actor, + ActorType, + ViewerContext, + decode_viewer_context, + viewer_context_scope, +) class MakeGenerateExternalIssueDetailsRequestTest(TestCase): @@ -18,20 +27,34 @@ def setUp(self) -> None: super().setUp() self.group = self.create_group() - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") - def test_successful_generation(self, mock_request: MagicMock) -> None: - mock_response = MagicMock(status=200) - mock_response.json.return_value = { - "content": json.dumps( + @override_settings(SEER_API_SHARED_SECRET="viewer-context-test-secret") + @patch("sentry.seer.signed_seer_api.seer_autofix_default_connection_pool.urlopen") + def test_successful_generation(self, mock_urlopen: MagicMock) -> None: + event = MagicMock(interfaces={}) + context = _build_event_context(self.group, event=event) + mock_urlopen.return_value = HTTPResponse( + orjson.dumps( { - "title": "Fix NullPointerException in UserService", - "description": "A NullPointerException occurs when accessing user profile data.", + "result": { + "title": "Fix NullPointerException in UserService", + "description": ( + "A NullPointerException occurs when accessing user profile data." + ), + } } - ) - } - mock_request.return_value = mock_response + ), + status=200, + ) - result = _make_generate_external_issue_details_request(self.group) + with viewer_context_scope( + ViewerContext( + actor=Actor(type=ActorType.USER, actor_id=self.user.id), + organization_id=self.group.organization.id, + ) + ): + result = _make_generate_external_issue_details_request( + self.group, event=event, user_id=self.user.id + ) assert result is not None assert result["title"] == "Fix NullPointerException in UserService" @@ -39,15 +62,28 @@ def test_successful_generation(self, mock_request: MagicMock) -> None: result["description"] == "A NullPointerException occurs when accessing user profile data." ) - mock_request.assert_called_once() + request = mock_urlopen.call_args + assert request.args[:2] == ("POST", "/v1/automation/oneshot/run") + assert request.kwargs["timeout"] == 10 + assert orjson.loads(request.kwargs["body"]) == { + "oneshot_id": "external_issue_details", + "payload": {"context": context}, + } + viewer_context = decode_viewer_context( + request.kwargs["headers"]["X-Viewer-Context"], + key="viewer-context-test-secret", + ) + assert viewer_context == ViewerContext( + actor=Actor(type=ActorType.USER, actor_id=self.user.id), + organization_id=self.group.organization.id, + ) - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_values_are_stripped(self, mock_request: MagicMock) -> None: - mock_response = MagicMock(status=200) - mock_response.json.return_value = { - "content": json.dumps({"title": " Title ", "description": " Description "}) + mock_request.return_value = { + "title": " Title ", + "description": " Description ", } - mock_request.return_value = mock_response result = _make_generate_external_issue_details_request(self.group) @@ -55,49 +91,33 @@ def test_values_are_stripped(self, mock_request: MagicMock) -> None: assert result["title"] == "Title" assert result["description"] == "Description" - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_seer_api_error_raises(self, mock_request: MagicMock) -> None: mock_request.side_effect = Exception("Connection error") with pytest.raises(Exception, match="Connection error"): _make_generate_external_issue_details_request(self.group) - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_empty_response_returns_none(self, mock_request: MagicMock) -> None: - mock_response = MagicMock(status=200) - mock_response.json.return_value = {"content": None} - mock_request.return_value = mock_response + mock_request.return_value = {} result = _make_generate_external_issue_details_request(self.group) assert result is None - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") - def test_seer_returns_400(self, mock_request: MagicMock) -> None: - mock_response = MagicMock(status=400) - mock_request.return_value = mock_response - - result = _make_generate_external_issue_details_request(self.group) - - assert result is None - - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_context_is_truncated(self, mock_request: MagicMock) -> None: - mock_response = MagicMock(status=200) - mock_response.json.return_value = { - "content": json.dumps( - {"title": "Generated Title", "description": "Generated Description"} - ) + mock_request.return_value = { + "title": "Generated Title", + "description": "Generated Description", } - mock_request.return_value = mock_response group = self.create_group(message="x" * 3000) _make_generate_external_issue_details_request(group) - call_args = mock_request.call_args - request_body = call_args.args[0] - prompt_prefix = "Generate a title and description for this Sentry error:\n\n" - assert len(request_body["prompt"]) <= MAX_CONTEXT_LENGTH + len(prompt_prefix) + payload = mock_request.call_args.args[1] + assert len(payload["context"]) <= MAX_CONTEXT_LENGTH @override_settings(SENTRY_SELF_HOSTED=False) @@ -106,14 +126,14 @@ def setUp(self) -> None: super().setUp() self.group = self.create_group() - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_feature_flag_disabled_returns_empty(self, mock_request: MagicMock) -> None: result = maybe_generate_external_issue_details(group=self.group, user=self.user) assert result == GeneratedExternalIssueDetails(title=None, description=None) mock_request.assert_not_called() - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_hide_ai_features_returns_empty(self, mock_request: MagicMock) -> None: self.group.organization.update_option("sentry:hide_ai_features", True) @@ -124,7 +144,7 @@ def test_hide_ai_features_returns_empty(self, mock_request: MagicMock) -> None: mock_request.assert_not_called() @override_settings(SENTRY_SELF_HOSTED=True) - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_self_hosted_returns_empty(self, mock_request: MagicMock) -> None: with self.feature("organizations:external-issues-ai-generate"): result = maybe_generate_external_issue_details(group=self.group, user=self.user) @@ -132,7 +152,7 @@ def test_self_hosted_returns_empty(self, mock_request: MagicMock) -> None: assert result == GeneratedExternalIssueDetails(title=None, description=None) mock_request.assert_not_called() - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_exception_returns_empty(self, mock_request: MagicMock) -> None: mock_request.side_effect = Exception("Connection error") @@ -141,13 +161,9 @@ def test_exception_returns_empty(self, mock_request: MagicMock) -> None: assert result == GeneratedExternalIssueDetails(title=None, description=None) - @patch("sentry.integrations.utils.external_issues.make_llm_generate_request") + @patch("sentry.integrations.utils.external_issues.run_oneshot") def test_successful_returns_details(self, mock_request: MagicMock) -> None: - mock_response = MagicMock(status=200) - mock_response.json.return_value = { - "content": json.dumps({"title": "AI Title", "description": "AI Description"}) - } - mock_request.return_value = mock_response + mock_request.return_value = {"title": "AI Title", "description": "AI Description"} with self.feature(["organizations:external-issues-ai-generate"]): result = maybe_generate_external_issue_details(group=self.group, user=self.user) @@ -155,3 +171,10 @@ def test_successful_returns_details(self, mock_request: MagicMock) -> None: assert result == GeneratedExternalIssueDetails( title="AI Title", description="AI Description" ) + mock_request.assert_called_once_with( + "external_issue_details", + {"context": _build_event_context(self.group)}, + self.group.organization, + user_id=self.user.id, + timeout=10, + ) diff --git a/tools/flake8_plugin.py b/tools/flake8_plugin.py index 82a96d830eb9..ca0c2fd71504 100644 --- a/tools/flake8_plugin.py +++ b/tools/flake8_plugin.py @@ -186,12 +186,6 @@ # Existing callers are pinned to their exact function and occurrence count. This is a # shrink-only migration baseline: remove entries as callers move to named Seer one-shots. S030_allowlist: dict[str, dict[tuple[str, str], int]] = { - "src/sentry/integrations/utils/external_issues.py": { - (_S030_MODULE_SCOPE, "import LlmGenerateRequest"): 1, - (_S030_MODULE_SCOPE, "import make_llm_generate_request"): 1, - ("_make_generate_external_issue_details_request", "call LlmGenerateRequest"): 1, - ("_make_generate_external_issue_details_request", "call make_llm_generate_request"): 1, - }, "src/sentry/uptime/seer_assertions.py": { (_S030_MODULE_SCOPE, "import LlmGenerateRequest"): 1, (_S030_MODULE_SCOPE, "import make_llm_generate_request"): 1,