Skip to content
Merged
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
82 changes: 16 additions & 66 deletions src/sentry/integrations/utils/external_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/sentry/integrations/test_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,15 +802,15 @@ 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)

title_field = next(f for f in config if f["name"] == "title")
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)

Expand All @@ -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")

Expand Down
129 changes: 76 additions & 53 deletions tests/sentry/integrations/utils/test_external_issues.py
Original file line number Diff line number Diff line change
@@ -1,103 +1,123 @@
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):
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"
assert (
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)

assert result is not 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)
Expand All @@ -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)

Expand All @@ -124,15 +144,15 @@ 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)

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")

Expand All @@ -141,17 +161,20 @@ 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)

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,
)
6 changes: 0 additions & 6 deletions tools/flake8_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading