Skip to content

Commit d9692c2

Browse files
feat(seer): Expose configurable workflows through the API (#128140)
Adds `GET /organizations/{org}/seer/workflows/configs/` to list saved, non-legacy Seer automation workflows for automation cards, including paused workflows and those with no runs. The typed response is `{"configs": [...]}`, with pagination links in headers and room for collection metadata. Access is gated by `seer-automations`. GET never seeds defaults; default creation belongs in explicit onboarding. `projects` uses `[-1]` for all projects, `[]` for none, or numeric IDs restricted to active projects the caller can access. `environments` uses `[]` for all environments or a list of names. Strategy-specific filters and the saved output policy are also exposed. Custom titles/descriptions, next-run timing, and token/cost metrics need follow-up support. --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 4867fcb commit d9692c2

4 files changed

Lines changed: 228 additions & 0 deletions

File tree

‎src/sentry/api/urls.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,9 @@
604604
from sentry.seer.endpoints.organization_seer_rpc import OrganizationSeerRpcEndpoint
605605
from sentry.seer.endpoints.organization_seer_runs import OrganizationSeerRunsEndpoint
606606
from sentry.seer.endpoints.organization_seer_setup_check import OrganizationSeerSetupCheckEndpoint
607+
from sentry.seer.endpoints.organization_seer_workflow_configs import (
608+
OrganizationSeerWorkflowConfigsEndpoint,
609+
)
607610
from sentry.seer.endpoints.organization_seer_workflows import OrganizationSeerWorkflowsEndpoint
608611
from sentry.seer.endpoints.project_seer_agentic_triage import ProjectSeerAgenticTriageEndpoint
609612
from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint
@@ -2623,6 +2626,11 @@ def create_group_urls(
26232626
OrganizationSeerRunsEndpoint.as_view(),
26242627
name="sentry-api-0-organization-seer-runs",
26252628
),
2629+
re_path(
2630+
r"^(?P<organization_id_or_slug>[^/]+)/seer/workflows/configs/$",
2631+
OrganizationSeerWorkflowConfigsEndpoint.as_view(),
2632+
name="sentry-api-0-organization-seer-workflow-configs",
2633+
),
26262634
re_path(
26272635
r"^(?P<organization_id_or_slug>[^/]+)/seer/workflows/runs/$",
26282636
OrganizationSeerWorkflowsEndpoint.as_view(),
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from collections import defaultdict
2+
from collections.abc import Sequence
3+
from datetime import datetime
4+
from functools import partial
5+
from typing import Any, TypedDict
6+
7+
from drf_spectacular.utils import extend_schema
8+
from rest_framework.exceptions import NotFound
9+
from rest_framework.request import Request
10+
from rest_framework.response import Response
11+
12+
from sentry import features
13+
from sentry.api.api_owners import ApiOwner
14+
from sentry.api.api_publish_status import ApiPublishStatus
15+
from sentry.api.base import cell_silo_endpoint
16+
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
17+
from sentry.api.paginator import OffsetPaginator
18+
from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case
19+
from sentry.apidocs.parameters import CursorQueryParam, GlobalParams
20+
from sentry.constants import ALL_ACCESS_PROJECT_ID
21+
from sentry.models.organization import Organization
22+
from sentry.seer.models.workflow import (
23+
SEER_CONFIGURABLE_WORKFLOW_STRATEGIES,
24+
SeerWorkflowConfig,
25+
SeerWorkflowConfigProject,
26+
)
27+
from sentry.seer.workflows.workflow import SeerWorkflow
28+
29+
30+
class SeerWorkflowConfigResponse(TypedDict):
31+
id: str
32+
strategy: str
33+
enabled: bool
34+
schedule: str
35+
version: int
36+
projects: list[int]
37+
environments: list[str]
38+
filters: dict[str, Any]
39+
outputPolicy: dict[str, Any]
40+
seededDefault: str | None
41+
dateAdded: datetime
42+
dateUpdated: datetime
43+
44+
45+
class SeerWorkflowConfigsResponse(TypedDict):
46+
configs: list[SeerWorkflowConfigResponse]
47+
48+
49+
@cell_silo_endpoint
50+
@extend_schema(tags=["Seer"])
51+
class OrganizationSeerWorkflowConfigsEndpoint(OrganizationEndpoint):
52+
owner = ApiOwner.ML_AI
53+
publish_status = {"GET": ApiPublishStatus.PRIVATE}
54+
permission_classes = (OrganizationPermission,)
55+
56+
@extend_schema(
57+
operation_id="List Seer automation workflows",
58+
parameters=[GlobalParams.ORG_ID_OR_SLUG, CursorQueryParam],
59+
responses={200: SeerWorkflowConfigsResponse},
60+
)
61+
def get(self, request: Request, organization: Organization) -> Response:
62+
"""List saved Seer automation workflows.
63+
64+
The ``configs`` list contains the current page; pagination links are in headers.
65+
``projects`` is ``[-1]`` for all projects, ``[]`` for no projects, or a list
66+
of selected active project IDs accessible to the caller. An empty
67+
``environments`` list means all environments; otherwise it contains names.
68+
``filters`` contains strategy-specific filters. ``seededDefault`` records
69+
the seeded strategy even after edits.
70+
``outputPolicy`` is the saved, opaque policy document.
71+
"""
72+
if not features.has("organizations:seer-automations", organization, actor=request.user):
73+
raise NotFound
74+
75+
projects = self.get_projects(request, organization, include_all_accessible=True)
76+
configs = SeerWorkflowConfig.objects.filter(
77+
organization=organization, strategy__in=SEER_CONFIGURABLE_WORKFLOW_STRATEGIES
78+
)
79+
return self.paginate(
80+
request=request,
81+
queryset=configs,
82+
order_by="id",
83+
paginator_cls=OffsetPaginator,
84+
on_results=partial(
85+
serialize_workflow_configs, accessible_project_ids=[p.id for p in projects]
86+
),
87+
)
88+
89+
90+
def serialize_workflow_configs(
91+
configs: Sequence[SeerWorkflowConfig], *, accessible_project_ids: Sequence[int]
92+
) -> SeerWorkflowConfigsResponse:
93+
selected_projects: dict[int, list[int]] = defaultdict(list)
94+
links = SeerWorkflowConfigProject.objects.filter(
95+
config__in=configs, project_id__in=accessible_project_ids
96+
).order_by("project_id")
97+
for config_id, project_id in links.values_list("config_id", "project_id"):
98+
selected_projects[config_id].append(project_id)
99+
100+
results: list[SeerWorkflowConfigResponse] = []
101+
for config in configs:
102+
workflow = SeerWorkflow(config)
103+
filters = workflow.filters
104+
results.append(
105+
{
106+
"id": str(config.id),
107+
"strategy": config.strategy,
108+
"enabled": config.enabled,
109+
"schedule": config.schedule,
110+
"version": config.version,
111+
"projects": (
112+
[ALL_ACCESS_PROJECT_ID]
113+
if filters.all_projects
114+
else selected_projects[config.id]
115+
),
116+
"environments": filters.environments,
117+
"filters": convert_dict_key_case(
118+
{
119+
key: value
120+
for key, value in config.filters.items()
121+
if key not in {"all_projects", "environments"}
122+
},
123+
snake_to_camel_case,
124+
),
125+
"outputPolicy": config.output_policy,
126+
"seededDefault": workflow.extras.seeded_default,
127+
"dateAdded": config.date_added,
128+
"dateUpdated": config.date_updated,
129+
}
130+
)
131+
return {"configs": results}

‎static/app/utils/api/knownSentryApiUrls.generated.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ export type KnownSentryApiUrls =
395395
| '/organizations/$organizationIdOrSlug/seer/setup-check/'
396396
| '/organizations/$organizationIdOrSlug/seer/supergroups/$supergroupId/'
397397
| '/organizations/$organizationIdOrSlug/seer/supergroups/by-group/'
398+
| '/organizations/$organizationIdOrSlug/seer/workflows/configs/'
398399
| '/organizations/$organizationIdOrSlug/seer/workflows/runs/'
399400
| '/organizations/$organizationIdOrSlug/sent-first-event/'
400401
| '/organizations/$organizationIdOrSlug/sentry-app-components/'

‎tests/sentry/seer/endpoints/test_organization_seer_workflows.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from django.test import override_settings
44

5+
from sentry.constants import ObjectStatus
56
from sentry.hybridcloud.models.outbox import CellOutbox
67
from sentry.hybridcloud.outbox.category import OutboxCategory
78
from sentry.models.pullrequest import PullRequestLifecycleState
@@ -21,6 +22,7 @@
2122
from sentry.seer.workflows.runs import create_workflow_run
2223
from sentry.testutils.cases import APITestCase
2324
from sentry.testutils.factories import Factories
25+
from sentry.testutils.helpers.features import with_feature
2426

2527

2628
@override_settings(SENTRY_SELF_HOSTED=False)
@@ -717,3 +719,89 @@ def result(self):
717719
],
718720
},
719721
}
722+
723+
724+
@override_settings(SENTRY_SELF_HOSTED=False)
725+
@with_feature("organizations:seer-automations")
726+
class OrganizationSeerWorkflowConfigsTest(APITestCase):
727+
endpoint = "sentry-api-0-organization-seer-workflow-configs"
728+
729+
def setUp(self) -> None:
730+
super().setUp()
731+
self.login_as(user=self.user)
732+
733+
def test_feature_disabled_does_not_seed_defaults(self) -> None:
734+
with self.feature({"organizations:seer-automations": False}):
735+
self.get_error_response(self.organization.slug, status_code=404)
736+
737+
assert not SeerWorkflowConfig.objects.filter(organization=self.organization).exists()
738+
739+
def test_empty_organization_does_not_seed_defaults(self) -> None:
740+
response = self.get_success_response(self.organization.slug)
741+
742+
assert 'rel="next"' in response.headers["Link"]
743+
assert response.data == {"configs": []}
744+
assert not SeerWorkflowConfig.objects.filter(organization=self.organization).exists()
745+
746+
def test_lists_saved_configs_for_organization(self) -> None:
747+
project = self.create_project()
748+
config = self.create_config(
749+
enabled=False,
750+
version=4,
751+
filters={"environments": ["production"], "min_event_count": 10},
752+
output_policy={"review_mode": "manual"},
753+
extras={"seeded_default": "agentic_triage_v2"},
754+
)
755+
Factories.create_seer_workflow_config_project(config, project)
756+
empty = self.create_config()
757+
Factories.create_seer_workflow_config(
758+
self.organization, strategy=SeerWorkflowStrategy.AGENTIC_TRIAGE
759+
)
760+
Factories.create_seer_workflow_config(
761+
self.create_organization(owner=self.user),
762+
strategy=SeerWorkflowStrategy.AGENTIC_TRIAGE_V2,
763+
)
764+
765+
response = self.get_success_response(self.organization.slug)
766+
767+
assert [item["id"] for item in response.data["configs"]] == [str(config.id), str(empty.id)]
768+
assert response.data["configs"][0] == {
769+
"id": str(config.id),
770+
"strategy": "agentic_triage_v2",
771+
"enabled": False,
772+
"schedule": "daily",
773+
"version": 4,
774+
"projects": [project.id],
775+
"environments": ["production"],
776+
"filters": {"minEventCount": 10},
777+
"outputPolicy": {"review_mode": "manual"},
778+
"seededDefault": "agentic_triage_v2",
779+
"dateAdded": config.date_added,
780+
"dateUpdated": config.date_updated,
781+
}
782+
assert response.data["configs"][1]["projects"] == []
783+
784+
def test_scope_only_includes_accessible_active_projects(self) -> None:
785+
self.organization.flags.allow_joinleave = False
786+
self.organization.save()
787+
team = self.create_team(organization=self.organization)
788+
member = self.create_user()
789+
self.create_member(organization=self.organization, user=member, role="member", teams=[team])
790+
accessible = self.create_project(teams=[team])
791+
hidden = self.create_project(teams=[self.create_team(organization=self.organization)])
792+
inactive = self.create_project(teams=[team], status=ObjectStatus.PENDING_DELETION)
793+
foreign = self.create_project(organization=self.create_organization())
794+
selected = self.create_config()
795+
for project in (accessible, hidden, inactive, foreign):
796+
Factories.create_seer_workflow_config_project(selected, project)
797+
self.login_as(member)
798+
799+
response = self.get_success_response(self.organization.slug)
800+
801+
assert len(response.data["configs"]) == 1
802+
assert response.data["configs"][0]["projects"] == [accessible.id]
803+
804+
def create_config(self, **kwargs) -> SeerWorkflowConfig:
805+
return Factories.create_seer_workflow_config(
806+
self.organization, strategy=SeerWorkflowStrategy.AGENTIC_TRIAGE_V2, **kwargs
807+
)

0 commit comments

Comments
 (0)