Skip to content

feat(ACI): Make ProjectRuleDetailsEndpoint PUT method backwards compatible#110381

Open
ceorourke wants to merge 19 commits intomasterfrom
ceorourke/project-details-put-backwards
Open

feat(ACI): Make ProjectRuleDetailsEndpoint PUT method backwards compatible#110381
ceorourke wants to merge 19 commits intomasterfrom
ceorourke/project-details-put-backwards

Conversation

@ceorourke
Copy link
Member

@ceorourke ceorourke commented Mar 10, 2026

Follow up to #109926 to transform an issue alert PUT payload into a workflow PUT payload and serialize it as if it were an issue alert rule.

I intentionally skipped a few tests as we do not appear to fail on the same things in workflow engine. We may want to go back and block these things from occurring but that is outside of the scope of this PR. Those tests are:

  • test_cannot_reassign_owner_from_other_team
  • test_team_owner_not_member
  • test_rule_form_owner_perms - note that for workflow engine, it first fails on the malformed condition earlier than it fails on the owner being in a different org. to verify I passed match, key, and value and it ran successfully
  • test_rule_form_missing_action see above, condition is malformed but when fixed we return successfully. maybe it's ok for workflow engine if there is no action
  • test_edit_non_condition_metric - skipped because it uses the every event condition which is not valid in workflow engine

@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Mar 10, 2026
@github-actions
Copy link
Contributor

Backend Test Failures

Failures on 85b4815 in this run:

tests/sentry/api/endpoints/test_project_rule_details.py::UpdateProjectRuleTest::test_update_owner_typelog
tests/sentry/api/endpoints/test_project_rule_details.py:736: in test_update_owner_type
    assert_serializer_results_match(response.data, workflow_response.data)
tests/sentry/api/endpoints/test_project_rule_details.py:118: in assert_serializer_results_match
    assert rule_response.get("actionMatch") == workflow_response.get("actionMatch")
E   AssertionError: assert 'all' == 'any'
E     
E     - any
E     + all

Comment on lines -618 to -621
"key": "foo",
"match": "eq",
"value": "bar",
}
Copy link
Member Author

Choose a reason for hiding this comment

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

these are invalid fields for a first seen event condition, idk why this test was using them so I just changed it to a different condition

except KeyError:
raise ValidationError("Ensure all required fields are filled in.")
except ValueError as e:
raise ValidationError(str(e))
Copy link
Member Author

Choose a reason for hiding this comment

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

This is mainly unsupported conditions

# IssueCategoryFilter stores numeric string choices and must stay as str
if (
f.get("value")
and f.get("id") != "sentry.rules.filters.issue_category.IssueCategoryFilter"
Copy link
Member Author

@ceorourke ceorourke Mar 12, 2026

Choose a reason for hiding this comment

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

This is sort of hacky but outside of changing the value type of one of these it's the only way for the tests to pass and the serializers to be in sync.

self.action_group, self.action = self.create_workflow_action(self.workflow)
self.fake_workflow_id = get_fake_id_from_object_id(self.workflow.id)

# fetch dual written workflow
Copy link
Member Author

@ceorourke ceorourke Mar 12, 2026

Choose a reason for hiding this comment

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

I use the dual written workflow rather than the single written workflow so that it's set up exactly the same as the rule for comparison purposes when editing. I could have created the single written workflow to be the same but this was already here so it was slightly easier to use.

@getsentry getsentry deleted a comment from github-actions bot Mar 12, 2026
@ceorourke ceorourke marked this pull request as ready for review March 13, 2026 17:40
@ceorourke ceorourke requested review from a team as code owners March 13, 2026 17:40
@ceorourke ceorourke requested a review from kcons March 13, 2026 17:40
@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Backend Test Failures

Failures on cd68fe1 in this run:

tests/sentry/api/endpoints/test_project_rule_details.py::UpdateProjectRuleTest::test_edit_condition_metriclog
tests/sentry/api/endpoints/test_project_rule_details.py:1759: in test_edit_condition_metric
    workflow_response = self.get_success_response(
src/sentry/testutils/cases.py:629: in get_success_response
    assert_status_code(response, status_code)
src/sentry/testutils/asserts.py:47: in assert_status_code
    assert minimum <= response.status_code < maximum, (
E   AssertionError: (500, b'{"detail":"Internal Error","errorId":null}')
E   assert 500 < 201
E    +  where 500 = <Response status_code=500, "application/json">.status_code
tests/sentry/api/endpoints/test_project_rule_details.py::UpdateProjectRuleTest::test_simplelog
tests/sentry/api/endpoints/test_project_rule_details.py:686: in test_simple
    workflow_response = self.get_success_response(
src/sentry/testutils/cases.py:629: in get_success_response
    assert_status_code(response, status_code)
src/sentry/testutils/asserts.py:47: in assert_status_code
    assert minimum <= response.status_code < maximum, (
E   AssertionError: (500, b'{"detail":"Internal Error","errorId":null}')
E   assert 500 < 201
E    +  where 500 = <Response status_code=500, "application/json">.status_code

Copy link
Member

@kcons kcons left a comment

Choose a reason for hiding this comment

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

Mostly looks good to me. I like how clean the tests look.


def format_request_data(
data: ProjectRulePostData,
) -> dict[str, Any]:
Copy link
Member

Choose a reason for hiding this comment

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

It's a little bit sad how there is a type for the workflow validation input encoded in our code, but it's not really exposed to mypy unless we manually maintain a typeddict, so we don't get as much static validation here as we could.

... actually, I have a pr brewing, we'll see if it is worth it.

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Owner field not cleared when switching owner types
    • Updated workflow request formatting to always set the selected owner field and explicitly clear the opposite owner field when an owner is provided.
  • ✅ Fixed: Tests compare wrong workflow missing coverage for specific rules
    • Both tests now look up the dual-written workflow ID for the specific rule created in each test instead of reusing the fixture workflow ID.

Create PR

Or push these changes by commenting:

@cursor push 7bbf3e4346
Preview (7bbf3e4346)
diff --git a/src/sentry/api/endpoints/project_rules.py b/src/sentry/api/endpoints/project_rules.py
--- a/src/sentry/api/endpoints/project_rules.py
+++ b/src/sentry/api/endpoints/project_rules.py
@@ -753,8 +753,10 @@
 
         if actor and actor.is_team:
             workflow_payload["owner_team_id"] = actor.id
+            workflow_payload["owner_user_id"] = None
         elif actor:
             workflow_payload["owner_user_id"] = actor.id
+            workflow_payload["owner_team_id"] = None
 
     triggers: dict[str, Any] = {"logicType": "any-short", "conditions": []}
     translated_filter_list = []

diff --git a/tests/sentry/api/endpoints/test_project_rule_details.py b/tests/sentry/api/endpoints/test_project_rule_details.py
--- a/tests/sentry/api/endpoints/test_project_rule_details.py
+++ b/tests/sentry/api/endpoints/test_project_rule_details.py
@@ -1002,12 +1002,15 @@
             self.organization.slug, self.project.slug, rule.id, status_code=200, **payload
         )
         assert_rule_from_payload(rule, payload)
+        fake_dual_written_workflow_id = get_fake_id_from_object_id(
+            AlertRuleWorkflow.objects.get(rule_id=rule.id).workflow.id
+        )
 
         with self.feature("organizations:workflow-engine-rule-serializers"):
             workflow_response = self.get_success_response(
                 self.organization.slug,
                 self.project.slug,
-                self.fake_dual_written_workflow_id,
+                fake_dual_written_workflow_id,
                 status_code=status.HTTP_200_OK,
                 **payload,
             )
@@ -1253,11 +1256,14 @@
                 organization_id=self.organization.id,
             ),
         )
+        fake_dual_written_workflow_id = get_fake_id_from_object_id(
+            AlertRuleWorkflow.objects.get(rule_id=rule.id).workflow.id
+        )
         with self.feature("organizations:workflow-engine-rule-serializers"):
             workflow_response = self.get_success_response(
                 self.organization.slug,
                 self.project.slug,
-                self.fake_dual_written_workflow_id,
+                fake_dual_written_workflow_id,
                 status_code=status.HTTP_200_OK,
                 **payload,
             )

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

status_code=status.HTTP_200_OK,
**payload,
)
assert_serializer_results_match(response.data, workflow_response.data)
Copy link
Contributor

Choose a reason for hiding this comment

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

Tests compare wrong workflow missing coverage for specific rules

Medium Severity

In test_remove_conditions and test_reenable_disabled_rule, new rules are created and modified, but the workflow comparison uses self.fake_dual_written_workflow_id, which is tied to the original self.rule, not the newly created rules. This means test_remove_conditions doesn't actually test removing conditions from the correct workflow, and test_reenable_disabled_rule doesn't test re-enabling a disabled workflow (since the dual-written workflow was never disabled). Both tests pass coincidentally because the same payload is applied to both objects, hiding potential real bugs.

Additional Locations (1)
Fix in Cursor Fix in Web


workflow.refresh_from_db()
return Response(
serialize(workflow, request.user, WorkflowEngineRuleSerializer()),
Copy link
Contributor

Choose a reason for hiding this comment

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

PUT response serializer missing project_slug parameter

Medium Severity

The WorkflowEngineRuleSerializer() in the PUT response is instantiated without project_slug or prepare_component_fields, unlike the GET handler which passes project_slug=project.slug and prepare_component_fields=True. This means the PUT response may serialize the workflow with missing or incorrect project information and unprepared SentryApp component fields, producing an inconsistent response compared to what GET returns for the same workflow.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

That's consistence with the RuleSerializer usage


def format_request_data(
data: ProjectRulePostData,
project: Project,
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused project parameter in format_request_data

Low Severity

The project parameter was added to format_request_data but is never referenced anywhere in the function body (lines 742–802). Both call sites (project_rules.py POST and project_rule_details.py PUT) pass project, but the function only uses data. This is dead code that may signal a missing feature — the PR discussion mentions owner/actor validation that would likely need project context — or simply an oversight.

Fix in Cursor Fix in Web


request_data = format_request_data(cast(ProjectRulePostData, data), project)
if not request_data.get("config", {}).get("frequency"):
request_data["config"] = workflow.config
Copy link
Contributor

Choose a reason for hiding this comment

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

Falsy frequency check silently ignores invalid values

Low Severity

The check if not request_data.get("config", {}).get("frequency") uses a falsy test, so a frequency of 0 (or any other falsy value) silently falls back to workflow.config instead of being passed to the WorkflowValidator for proper rejection. This means an explicitly invalid frequency input gets silently replaced with the saved value rather than producing a validation error.

Fix in Cursor Fix in Web

Comment on lines +241 to 244
return Response(
serialize(workflow, request.user, WorkflowEngineRuleSerializer()),
status=200,
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Updating a workflow rule with an empty action_filters list deletes all filters, causing an IndexError in the serializer which assumes at least one filter exists.
Severity: HIGH

Suggested Fix

Add a check in the WorkflowEngineRuleSerializer's get_attrs method to ensure workflow.prefetched_wdcgs is not empty before attempting to access its first element. If it is empty, handle it gracefully, for example by returning a default value or an empty structure for the related attributes.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/api/endpoints/project_rule_details.py#L241-L244

Potential issue: When a workflow rule is updated via a PUT request and the
`action_filters` payload is empty, the `remove_items_by_api_input` function deletes all
associated `WorkflowDataConditionGroup` entries. Subsequently, when the updated rule is
serialized, the `WorkflowEngineRuleSerializer` attempts to access
`workflow.prefetched_wdcgs[0]`. Because all filters were removed, `prefetched_wdcgs` is
an empty list, leading to an `IndexError: list index out of range` and a 500 Internal
Server Error response to the user. The serializer incorrectly assumes at least one
action filter will always be present.

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.


workflow.refresh_from_db()
return Response(
serialize(workflow, request.user, WorkflowEngineRuleSerializer()),
Copy link
Contributor

Choose a reason for hiding this comment

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

PUT response serializer missing project_slug parameter

Medium Severity

The PUT handler's response creates WorkflowEngineRuleSerializer() with no arguments, while the GET handler creates it with expand, prepare_component_fields=True, and project_slug=project.slug. This means the PUT response may serialize differently than the GET response for the same workflow — notably missing sentry app component field preparation and the project_slug context.

Additional Locations (1)
Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants