Repository: getsentry/sentry
Pull request: ref: Allow auto-assignment after manual assignment if called from API
Allow auto-assignment after manual assignment if called from force auto-assignment API (see https://github.com/getsentry/getsentry/pull/11401)

closes https://github.com/getsentry/sentry/issues/52318

Problem statement:
Create an API which will force run auto-assignment
We built a feature which prevents auto-assignment of an issue which was manually assigned.  This is to prevent us from incorrectly auto-assigning an issue when it has been determined that there is a better assignee.  This however can lead to an issue where if issues were accidentally assigned in bulk, that we don't have a way to allow auto-assignment to fix it.  

AC:
- Given a list of group IDs, force auto-assignment to run based on auto-assignment configuration for that project.  
- Limit this API to 100 group IDs per request
- Implement a rate limit of one request per minute per org

Hints from issue discussion:
https://github.com/getsentry/sentry/blob/master/src/sentry/models/projectownership.py#L292-L294

This query checks if a user manually overrode the assignment. Wrap a flag around this for the API  and skip this check to force the auto-assignment.

Commit message: Revert "feat: Make API public"

This reverts commit 7cc5753c6efca04548cc19d22483edc2e4ab101f.

Patch to review:
diff --git a/src/sentry/api/endpoints/organization_force_auto_assignment.py b/src/sentry/api/endpoints/organization_force_auto_assignment.py
new file mode 100644
index 00000000000000..4f32da42492f75
--- /dev/null
+++ b/src/sentry/api/endpoints/organization_force_auto_assignment.py
@@ -0,0 +1,47 @@
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from sentry import ratelimits as ratelimiter
+from sentry.api.base import region_silo_endpoint
+from sentry.api.bases.organization import OrganizationEndpoint
+from sentry.api.serializers import serialize
+from sentry.models import Organization
+from sentry.models.group import Group
+from sentry.models.projectownership import ProjectOwnership
+from sentry.types.ratelimit import RateLimit, RateLimitCategory
+
+
+@region_silo_endpoint
+class OrganizationForceAutoAssignmentEndpoint(OrganizationEndpoint):
+
+    rate_limits = {"PUT": {RateLimitCategory.ORGANIZATION: RateLimit(1, 60)}}  # 1 rpm
+
+    def put(self, request: Request, organization: Organization) -> Response:
+        """
+        Endpoint for forcing autoassignment to run for specified group ids.
+        This is for if a user incorrectly manually assigns a group and wants autoassignment to run.
+        """
+        if ratelimiter.is_limited(  # type: ignore [attr-defined]
+            key=f"org-force-autoassignment:{organization.id}",
+            limit=1,
+            window=60,
+        ):
+            return Response(
+                {"detail": "Rate limit of 1 request per org per minute exceeded."}, status=429
+            )
+
+        group_ids = request.data.get("group_ids")
+        if group_ids and len(group_ids) > 100:
+            return Response(
+                {"detail": "Too many group ids. Number of group ids should be <= 100."}, status=431
+            )
+
+        if group_ids:
+            group_ids = [int(group_id) for group_id in group_ids]
+            groups = Group.objects.filter(id__in=group_ids)
+
+            for group in groups:
+                ProjectOwnership.handle_auto_assignment(project_id=group.project.id, group=group)
+            return Response(serialize(group_ids), status=200)
+
+        return Response({"detail": "Request must include group ids."}, status=400)
diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py
index 92dbb7e6c33132..7c8e0705a6b844 100644
--- a/src/sentry/api/urls.py
+++ b/src/sentry/api/urls.py
@@ -9,6 +9,9 @@
     OrganizationEventsFacetsStatsPerformanceEndpoint,
 )
 from sentry.api.endpoints.organization_events_starfish import OrganizationEventsStarfishEndpoint
+from sentry.api.endpoints.organization_force_auto_assignment import (
+    OrganizationForceAutoAssignmentEndpoint,
+)
 from sentry.api.endpoints.organization_missing_org_members import OrganizationMissingMembersEndpoint
 from sentry.api.endpoints.organization_projects_experiment import (
     OrganizationProjectsExperimentEndpoint,
@@ -1297,6 +1300,11 @@
         OrganizationEventsTraceMetaEndpoint.as_view(),
         name="sentry-api-0-organization-events-trace-meta",
     ),
+    re_path(
+        r"^(?P<organization_slug>[^\/]+)/force-auto-assignment/$",
+        OrganizationForceAutoAssignmentEndpoint.as_view(),
+        name="sentry-api-0-organization-force-auto-assignment",
+    ),
     re_path(
         r"^(?P<organization_slug>[^\/]+)/measurements-meta/$",
         OrganizationMeasurementsMeta.as_view(),
diff --git a/src/sentry/models/groupassignee.py b/src/sentry/models/groupassignee.py
index 491b256cdcfb7c..117f7cac83dcb3 100644
--- a/src/sentry/models/groupassignee.py
+++ b/src/sentry/models/groupassignee.py
@@ -37,6 +37,7 @@ def assign(
         acting_user: User | None = None,
         create_only: bool = False,
         extra: Dict[str, str] | None = None,
+        force_autoassign: bool = False,
     ):
         from sentry import features
         from sentry.integrations.utils import sync_group_assignee_outbound
@@ -69,9 +70,12 @@ def assign(
         )
 
         if not created:
-            affected = not create_only and self.filter(group=group).exclude(
-                **{assignee_type_attr: assigned_to_id}
-            ).update(**{assignee_type_attr: assigned_to_id, other_type: None, "date_added": now})
+            affected = not create_only and (
+                self.filter(group=group)
+                .exclude(**{assignee_type_attr: assigned_to_id})
+                .update(**{assignee_type_attr: assigned_to_id, other_type: None, "date_added": now})
+                or force_autoassign
+            )
         else:
             affected = True
 
diff --git a/src/sentry/models/projectownership.py b/src/sentry/models/projectownership.py
index 1bbc604ea428bd..e30b64da5564f4 100644
--- a/src/sentry/models/projectownership.py
+++ b/src/sentry/models/projectownership.py
@@ -234,7 +234,7 @@ def _get_autoassignment_types(cls, ownership):
         return autoassignment_types
 
     @classmethod
-    def handle_auto_assignment(cls, project_id, event):
+    def handle_auto_assignment(cls, project_id, event=None, group=None):
         """
         Get the auto-assign owner for a project if there are any.
 
@@ -251,6 +251,11 @@ def handle_auto_assignment(cls, project_id, event):
             User,
         )
 
+        force_autoassign = True
+        if event:
+            force_autoassign = False
+            group = event.group
+
         with metrics.timer("projectownership.get_autoassign_owners"):
             ownership = cls.get_ownership_cached(project_id)
             if not ownership:
@@ -262,7 +267,7 @@ def handle_auto_assignment(cls, project_id, event):
 
             # Get the most recent GroupOwner that matches the following order: Suspect Committer, then Ownership Rule, then Code Owner
             issue_owner = GroupOwner.get_autoassigned_owner_cached(
-                event.group.id, project_id, autoassignment_types
+                group.id, project_id, autoassignment_types
             )
             if issue_owner is False:
                 return
@@ -290,11 +295,11 @@ def handle_auto_assignment(cls, project_id, event):
                 }
             )
             activity = Activity.objects.filter(
-                group=event.group, type=ActivityType.ASSIGNED.value
+                group=group, type=ActivityType.ASSIGNED.value
             ).order_by("-datetime")
             if activity:
                 auto_assigned = activity[0].data.get("integration")
-                if not auto_assigned:
+                if not auto_assigned and not force_autoassign:
                     logger.info(
                         "autoassignment.post_manual_assignment",
                         extra={
@@ -308,10 +313,11 @@ def handle_auto_assignment(cls, project_id, event):
                     return
 
             assignment = GroupAssignee.objects.assign(
-                event.group,
+                group,
                 owner,
-                create_only=True,
+                create_only=not force_autoassign,
                 extra=details,
+                force_autoassign=force_autoassign,
             )
 
             if assignment["new_assignment"] or assignment["updated_assignment"]:
@@ -321,15 +327,15 @@ def handle_auto_assignment(cls, project_id, event):
                     else "issueowners.assignment",
                     organization_id=ownership.project.organization_id,
                     project_id=project_id,
-                    group_id=event.group.id,
+                    group_id=group.id,
                 )
                 logger.info(
                     "handle_auto_assignment.success",
                     extra={
-                        "event": event.event_id,
-                        "group": event.group_id,
-                        "project": event.project_id,
-                        "organization": event.project.organization_id,
+                        "event": event.event_id if event else None,
+                        "group": group.id,
+                        "project": group.project.id,
+                        "organization": group.project.organization_id,
                         # owner_id returns a string including the owner type (user or team) and id
                         "assignee": issue_owner.owner_id(),
                         "reason": "created" if assignment["new_assignment"] else "updated",
diff --git a/tests/sentry/api/endpoints/test_organization_force_auto_assignment.py b/tests/sentry/api/endpoints/test_organization_force_auto_assignment.py
new file mode 100644
index 00000000000000..eea9ae9a810b9f
--- /dev/null
+++ b/tests/sentry/api/endpoints/test_organization_force_auto_assignment.py
@@ -0,0 +1,171 @@
+from django.urls import reverse
+
+from sentry.models.groupassignee import GroupAssignee
+from sentry.models.groupowner import GroupOwner, GroupOwnerType
+from sentry.models.projectownership import ProjectOwnership
+from sentry.ownership.grammar import Matcher, Owner, Rule, dump_schema
+from sentry.testutils.cases import APITestCase
+from sentry.testutils.helpers.datetime import before_now, iso_format
+
+ENDPOINT = "sentry-api-0-organization-force-auto-assignment"
+
+
+class OrganizationForceAutoAssignmentEndpointTest(APITestCase):
+    def setUp(self):
+        super().setUp()
+        self.login_as(self.user)
+
+        # Create codeowner rule
+        self.code_mapping = self.create_code_mapping(project=self.project)
+        self.rule = Rule(Matcher("path", "*.py"), [Owner("team", self.team.slug)])
+        self.create_codeowners(
+            self.project, self.code_mapping, raw="*.py @foo", schema=dump_schema([self.rule])
+        )
+
+        # Turn auto-assignment on
+        self.ownership = ProjectOwnership.objects.create(
+            project_id=self.project.id,
+            fallthrough=True,
+            auto_assignment=True,
+            suspect_committer_auto_assignment=True,
+            schema=dump_schema([self.rule]),
+        )
+
+    def python_event_data(self, message: str, path: str, filename: str):
+        return {
+            "message": message,
+            "platform": "python",
+            "fingerprint": [f"group-{message}"],
+            "timestamp": iso_format(before_now(seconds=10)),
+            "stacktrace": {
+                "frames": [
+                    {
+                        "function": "handle_set_commits",
+                        "abs_path": path,
+                        "module": "sentry.api",
+                        "in_app": True,
+                        "lineno": 30,
+                        "filename": filename,
+                    }
+                ]
+            },
+            "tags": {"sentry:release": self.release.version},
+        }
+
+    def test_put_single_group_success(self):
+        # Auto assign group using codeowner
+        event = self.store_event(
+            data=self.python_event_data(
+                message="Message!",
+                path="/usr/src/sentry/src/sentry/api/foo.py",
+                filename="sentry/api/foo.py",
+            ),
+            project_id=self.project.id,
+        )
+        GroupOwner.objects.create(
+            group=event.group,
+            type=GroupOwnerType.CODEOWNERS.value,
+            user_id=None,
+            team_id=self.team.id,
+            project=self.project,
+            organization=self.project.organization,
+            context={"rule": str(self.rule)},
+        )
+        ProjectOwnership.handle_auto_assignment(self.project.id, event)
+        assert len(GroupAssignee.objects.all()) == 1
+        assignee = GroupAssignee.objects.get(group=event.group)
+        assert assignee.team_id == self.team.id
+
+        # Manually assign to someone else
+        assert event.group
+        GroupAssignee.objects.assign(event.group, self.user)
+        assert len(GroupAssignee.objects.all()) == 1
+        assignee = GroupAssignee.objects.get(group=event.group)
+        assert assignee.user_id == self.user.id
+
+        # Force autoassignment of the group
+        url = reverse(ENDPOINT, args=[self.organization.name])
+        response = self.client.put(url, {"group_ids": [event.group.id]})
+        assert response.status_code == 200
+        assert response.data == [event.group_id]
+        assignee = GroupAssignee.objects.get(group=event.group)
+        assert assignee.team_id == self.team.id
+
+    def test_put_multiple_groups_success(self):
+        # Auto assign groups using codeowner
+        groups = []
+        group_ids = []
+        event_data = [
+            {"message": "One!", "path": "/path/to/one.py", "filename": "one.py"},
+            {"message": "Two!", "path": "/path/to/two.py", "filename": "two.py"},
+            {"message": "Three!", "path": "/path/to/three.py", "filename": "three.py"},
+        ]
+        for data in event_data:
+            event = self.store_event(
+                data=self.python_event_data(
+                    message=data["message"], path=data["path"], filename=data["filename"]
+                ),
+                project_id=self.project.id,
+            )
+            GroupOwner.objects.create(
+                group=event.group,
+                type=GroupOwnerType.CODEOWNERS.value,
+                user_id=None,
+                team_id=self.team.id,
+                project=self.project,
+                organization=self.project.organization,
+                context={"rule": str(self.rule)},
+            )
+            if event.group:
+                groups.append(event.group)
+                group_ids.append(event.group.id)
+
+            ProjectOwnership.handle_auto_assignment(self.project.id, event)
+            assignee = GroupAssignee.objects.get(group=event.group)
+            assert assignee.team_id == self.team.id
+
+            # Manually assign to someone else
+            assert event.group
+            GroupAssignee.objects.assign(event.group, self.user)
+            assignee = GroupAssignee.objects.get(group=event.group)
+            assert assignee.user_id == self.user.id
+
+        # Force autoassignment of the group
+        url = reverse(ENDPOINT, args=[self.organization.name])
+        response = self.client.put(url, {"group_ids": group_ids})
+        assert response.status_code == 200
+        assert response.data == group_ids
+        assignees = GroupAssignee.objects.filter(group__in=groups)
+        for assignee in assignees:
+            assert assignee.team_id == self.team.id
+
+    def test_put_too_many_groups(self):
+        # Force autoassignment of over 100 group ids
+        group_ids = [i for i in range(101)]
+        url = reverse(ENDPOINT, args=[self.organization.name])
+        response = self.client.put(url, {"group_ids": group_ids})
+        assert response.status_code == 431
+        assert response.data == {
+            "detail": "Too many group ids. Number of group ids should be <= 100."
+        }
+
+    def test_put_too_many_requests(self):
+        # Call force autoassignment
+        group_ids = [1]
+        url = reverse(ENDPOINT, args=[self.organization.name])
+        self.client.put(url, {"group_ids": group_ids})
+
+        # Call another force autoassignment for the same org within the minute
+        group_ids = [1]
+        url = reverse(ENDPOINT, args=[self.organization.name])
+        response = self.client.put(url, {"group_ids": group_ids})
+        assert response.status_code == 429
+        assert response.data == {"detail": "Rate limit of 1 request per org per minute exceeded."}
+
+        # Call force assignment for another organization
+        new_org = self.create_organization(name="new", owner=self.user)
+        group_ids = [1]
+        url = reverse(ENDPOINT, args=[new_org.name])
+        response = self.client.put(url, {"group_ids": group_ids})
+        assert response.status_code != 429
+        assert response.data != {"detail": "Rate limit of 1 request per org per minute exceeded."}


Code review comments:
diff_hunk: @@ -0,0 +1,47 @@
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from sentry import ratelimits as ratelimiter
+from sentry.api.base import region_silo_endpoint
+from sentry.api.bases.organization import OrganizationEndpoint
+from sentry.api.serializers import serialize
+from sentry.models import Organization
+from sentry.models.group import Group
+from sentry.models.projectownership import ProjectOwnership
+from sentry.types.ratelimit import RateLimit, RateLimitCategory
+
+
+@region_silo_endpoint
+class OrganizationForceAutoAssignmentEndpoint(OrganizationEndpoint):
+
+    rate_limits = {"PUT": {RateLimitCategory.ORGANIZATION: RateLimit(1, 60)}}  # 1 rpm
+
+    def put(self, request: Request, organization: Organization) -> Response:
+        """
+        Endpoint for forcing autoassignment to run for specified group ids.
+        This is for if a user incorrectly manually assigns a group and wants autoassignment to run.
path: src/sentry/api/endpoints/organization_force_auto_assignment.py
text: @user1:
This seems like a parameter addition to an existing api instead of a new one. Can you point me to the assign api?

@author:
There is a manual assign API [here](https://github.com/getsentry/sentry/blob/1cb4428d34d556792e166d0119eca7f41cbe42e7/src/sentry/api/endpoints/group_details.py#L277), but this API is different as we want to call auto-assignment. Auto-assignment is usually called in a task [here](https://github.com/getsentry/sentry/blob/1cb4428d34d556792e166d0119eca7f41cbe42e7/src/sentry/tasks/post_process.py#L981) and not in an API

@user1:
> Auto-assignment is usually called in a task [here](https://github.com/getsentry/sentry/blob/1cb4428d34d556792e166d0119eca7f41cbe42e7/src/sentry/tasks/post_process.py#L981) and not in an API

Thanks for the pointers. You're calling it in an api now so it seems like a good fit for the API. For example can assignedTo param get user:AUTO_ASSIGN or even add a new enum that assignmentMethod

diff_hunk: @@ -0,0 +1,47 @@
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from sentry import ratelimits as ratelimiter
+from sentry.api.base import region_silo_endpoint
+from sentry.api.bases.organization import OrganizationEndpoint
+from sentry.api.serializers import serialize
+from sentry.models import Organization
+from sentry.models.group import Group
+from sentry.models.projectownership import ProjectOwnership
+from sentry.types.ratelimit import RateLimit, RateLimitCategory
+
+
+@region_silo_endpoint
+class OrganizationForceAutoAssignmentEndpoint(OrganizationEndpoint):
path: src/sentry/api/endpoints/organization_force_auto_assignment.py
text: @user1:
This seems like an endpoint that could be called directly by user. Please make sure to include documentation and publish it. Here's an example: https://github.com/getsentry/sentry/blob/master/src/sentry/api/endpoints/organization_member/details.py#L90

@author:
We want the endpoint to be directly called by a user, but do not wish to make it public as this can be a labour-intensive call (see [here](https://sentry.slack.com/archives/C04KZQBNQ2U/p1688594072303679?thread_ts=1687793787.448889&cid=C04KZQBNQ2U)). This API will only be called by users who reach out to support and ask for the functionality.

(cc: @user2 @user3)

@user1:
Thanks for the context. Going towards the goal of API first company we prefer all public APIs to be publicly documented so let's discuss other solutions. Also if it's not public how do they know about it to ask from support?!
1. From the conversation seems like excessive rate limiting is a good idea to address this issue. Does it not?
2. What is the limit? How many orgs can have access to this and why does that limit exists? If you provide more context on the technical challenge maybe we can find a better solution. Hiding it definitely won't work, what if too many orgs find out about it through support or just code? 

@user2:
@author if we do not want to make this endpoint public, we can move this into getsentry. And we can make it only accessible through the UI in  _admin and only allow users with `superuser` access. I would still keep the ratelimit as a precaution against rouge clicks 

@author:
I think this would work. @user4-athena what do you think?

For more context, a user would only want to use this API if they manually bulk assigned groups after auto-assignment, then later wanted auto-assignment to run. In general, users can manually assign groups if they made a mistake in assignment. This API was made in response to a customer who had the first use case and reached out to support asking if this was possible. 

Excessive rate limiting could address this issue, but I think it's a rare enough use case that we don't need this labor intensive API to be public.

@user1:
Makes sense to not be public if it's only accessed by superuser. Please move it to gentsentry and add the right access.