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
61 changes: 61 additions & 0 deletions src/sentry/api/serializers/models/seer_night_shift_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any, TypedDict

from django.db.models import prefetch_related_objects

from sentry.api.serializers import Serializer, register
from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue


class SeerNightShiftRunIssueResponse(TypedDict):
id: str
groupId: str
action: str
seerRunId: str | None
dateAdded: str


class SeerNightShiftRunResponse(TypedDict):
id: str
dateAdded: str
triageStrategy: str
errorMessage: str | None
extras: dict[str, Any]
issues: list[SeerNightShiftRunIssueResponse]


@register(SeerNightShiftRun)
class SeerNightShiftRunSerializer(Serializer):
def get_attrs(
self, item_list: Sequence[SeerNightShiftRun], user: Any, **kwargs: Any
) -> dict[SeerNightShiftRun, dict[str, Any]]:
prefetch_related_objects(item_list, "issues")
return {}

def serialize(
self,
obj: SeerNightShiftRun,
attrs: Mapping[str, Any],
user: Any,
**kwargs: Any,
) -> SeerNightShiftRunResponse:
return {
"id": str(obj.id),
"dateAdded": obj.date_added.isoformat(),
"triageStrategy": obj.triage_strategy,
"errorMessage": obj.error_message,
"extras": obj.extras or {},
"issues": [_serialize_issue(i) for i in obj.issues.all()],
}
Comment thread
cursor[bot] marked this conversation as resolved.


def _serialize_issue(issue: SeerNightShiftRunIssue) -> SeerNightShiftRunIssueResponse:
return {
"id": str(issue.id),
"groupId": str(issue.group_id),
"action": issue.action,
"seerRunId": issue.seer_run_id,
"dateAdded": issue.date_added.isoformat(),
}
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@
from sentry.seer.endpoints.organization_seer_onboarding_check import OrganizationSeerOnboardingCheck
from sentry.seer.endpoints.organization_seer_rpc import OrganizationSeerRpcEndpoint
from sentry.seer.endpoints.organization_seer_setup_check import OrganizationSeerSetupCheckEndpoint
from sentry.seer.endpoints.organization_seer_workflows import OrganizationSeerWorkflowsEndpoint
from sentry.seer.endpoints.organization_trace_summary import OrganizationTraceSummaryEndpoint
from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint
from sentry.seer.endpoints.search_agent_start import SearchAgentStartEndpoint
Expand Down Expand Up @@ -2434,6 +2435,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationSeerExplorerRunsEndpoint.as_view(),
name="sentry-api-0-organization-seer-explorer-runs",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/seer/workflows/$",
OrganizationSeerWorkflowsEndpoint.as_view(),
name="sentry-api-0-organization-seer-workflows",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/seer/explorer-pr-groups/$",
OrganizationSeerExplorerPRGroupsEndpoint.as_view(),
Expand Down
47 changes: 47 additions & 0 deletions src/sentry/seer/endpoints/organization_seer_workflows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

from rest_framework.exceptions import NotFound
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
from sentry.api.paginator import OffsetPaginator
from sentry.api.serializers import serialize
from sentry.api.serializers.models.seer_night_shift_run import ( # noqa: F401 -- registers serializer
SeerNightShiftRunSerializer,
)
from sentry.models.organization import Organization
from sentry.seer.models.night_shift import SeerNightShiftRun


class OrganizationSeerWorkflowsPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read"],
}


@cell_silo_endpoint
class OrganizationSeerWorkflowsEndpoint(OrganizationEndpoint):
publish_status = {
"GET": ApiPublishStatus.PRIVATE,
}
owner = ApiOwner.ML_AI
permission_classes = (OrganizationSeerWorkflowsPermission,)

def get(self, request: Request, organization: Organization) -> Response:
if not features.has("organizations:seer-night-shift", organization):
raise NotFound

queryset = SeerNightShiftRun.objects.filter(organization_id=organization.id)

return self.paginate(
request=request,
queryset=queryset,
order_by="-date_added",
on_results=lambda x: serialize(x, request.user),
paginator_cls=OffsetPaginator,
)
2 changes: 2 additions & 0 deletions src/sentry/tasks/seer/night_shift/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ def run_night_shift_for_org(
candidates, agent_run_id = agentic_triage_strategy(
eligible_projects, organization, resolved_max_candidates
)
if agent_run_id is not None:
run.update(extras={**run.extras, "agent_run_id": agent_run_id})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'll add a real FK once our other Seer models are finally added.


if candidates:
SeerNightShiftRunIssue.objects.bulk_create(
Expand Down
1 change: 1 addition & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/seer/setup-check/'
| '/organizations/$organizationIdOrSlug/seer/supergroups/$supergroupId/'
| '/organizations/$organizationIdOrSlug/seer/supergroups/by-group/'
| '/organizations/$organizationIdOrSlug/seer/workflows/'
| '/organizations/$organizationIdOrSlug/sent-first-event/'
| '/organizations/$organizationIdOrSlug/sentry-app-components/'
| '/organizations/$organizationIdOrSlug/sentry-app-installations/'
Expand Down
79 changes: 79 additions & 0 deletions tests/sentry/seer/endpoints/test_organization_seer_workflows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue
from sentry.testutils.cases import APITestCase


class OrganizationSeerWorkflowsTest(APITestCase):
endpoint = "sentry-api-0-organization-seer-workflows"

def setUp(self) -> None:
super().setUp()
self.login_as(user=self.user)

def test_feature_flag_disabled_returns_404(self) -> None:
SeerNightShiftRun.objects.create(
organization=self.organization,
triage_strategy="agentic",
)
self.get_error_response(self.organization.slug, status_code=404)

def test_returns_runs_for_org_with_nested_issues(self) -> None:
group = self.create_group()
run = SeerNightShiftRun.objects.create(
organization=self.organization,
triage_strategy="agentic",
extras={"foo": "bar"},
)
issue = SeerNightShiftRunIssue.objects.create(
run=run,
group=group,
action="autofix_triggered",
seer_run_id="seer-123",
)

with self.feature("organizations:seer-night-shift"):
response = self.get_success_response(self.organization.slug)

assert len(response.data) == 1
assert response.data[0]["id"] == str(run.id)
assert response.data[0]["triageStrategy"] == "agentic"
assert response.data[0]["errorMessage"] is None
assert response.data[0]["extras"] == {"foo": "bar"}
assert len(response.data[0]["issues"]) == 1

issue_data = response.data[0]["issues"][0]
assert issue_data["id"] == str(issue.id)
assert issue_data["groupId"] == str(group.id)
assert issue_data["action"] == "autofix_triggered"
assert issue_data["seerRunId"] == "seer-123"

def test_runs_ordered_by_date_added_desc(self) -> None:
older = SeerNightShiftRun.objects.create(
organization=self.organization,
triage_strategy="agentic",
)
newer = SeerNightShiftRun.objects.create(
organization=self.organization,
triage_strategy="simple",
)

with self.feature("organizations:seer-night-shift"):
response = self.get_success_response(self.organization.slug)

assert [r["id"] for r in response.data] == [str(newer.id), str(older.id)]

def test_runs_scoped_to_requesting_org(self) -> None:
other_org = self.create_organization()
SeerNightShiftRun.objects.create(
organization=other_org,
triage_strategy="agentic",
)
own_run = SeerNightShiftRun.objects.create(
organization=self.organization,
triage_strategy="agentic",
)

with self.feature("organizations:seer-night-shift"):
response = self.get_success_response(self.organization.slug)

assert len(response.data) == 1
assert response.data[0]["id"] == str(own_run.id)
1 change: 1 addition & 0 deletions tests/sentry/tasks/seer/test_night_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def test_selects_candidates_and_skips_triggered(self) -> None:
run = SeerNightShiftRun.objects.get(organization=org)
assert run.triage_strategy == "agentic_triage"
assert run.error_message is None
assert run.extras == {"agent_run_id": 1}

issues = list(SeerNightShiftRunIssue.objects.filter(run=run))
assert len(issues) == 2
Expand Down
Loading