diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index ac3d6d53b060..deb3ef2cf46b 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -8,13 +8,17 @@ import orjson import sentry_sdk from django.contrib.auth.models import AnonymousUser +from django.db import router, transaction from django.utils import timezone +from django.utils.timezone import now from rest_framework.response import Response from sentry import features, quotas, tagstore from sentry.api.endpoints.organization_trace import OrganizationTraceEndpoint from sentry.api.serializers import EventSerializer, serialize from sentry.constants import ENABLE_SEER_CODING_DEFAULT, DataCategory, ObjectStatus +from sentry.hybridcloud.models.outbox import CellOutbox, outbox_context +from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig from sentry.issues.auto_source_code_config.code_mapping import ( convert_stacktrace_frame_path_to_source_path, @@ -41,6 +45,7 @@ read_preference_from_sentry_db, ) from sentry.seer.models import SeerProjectPreference +from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus, SeerRunType from sentry.seer.signed_seer_api import SeerViewerContext from sentry.seer.utils import get_github_username_for_user from sentry.services import eventstore @@ -473,53 +478,76 @@ def _call_autofix( stopping_point: AutofixStoppingPoint | None = None, github_username: str | None = None, ): - body = orjson.dumps( - { - "organization_id": group.organization.id, - "project_id": group.project.id, - "preference": preference.dict(), - "repos": [repo.dict() for repo in preference.repositories], - "issue": { - "id": group.id, - "title": group.title, - "short_id": group.qualified_short_id, - "first_seen": group.first_seen.isoformat(), - "events": [serialized_event], - }, - "profile": profile, - "trace_tree": trace_tree, - "logs": logs, - "tags_overview": tags_overview, - "instruction": instruction, - "timeout_secs": timeout_secs, - "last_updated": datetime.now().isoformat(), - "invoking_user": ( - { - "id": user.id, - "display_name": user.get_display_name(), - "github_username": github_username, - } - if not isinstance(user, AnonymousUser) - else None + body_dict = { + "organization_id": group.organization.id, + "project_id": group.project.id, + "preference": preference.dict(), + "repos": [repo.dict() for repo in preference.repositories], + "issue": { + "id": group.id, + "title": group.title, + "short_id": group.qualified_short_id, + "first_seen": group.first_seen.isoformat(), + "events": [serialized_event], + }, + "profile": profile, + "trace_tree": trace_tree, + "logs": logs, + "tags_overview": tags_overview, + "instruction": instruction, + "timeout_secs": timeout_secs, + "last_updated": datetime.now().isoformat(), + "invoking_user": ( + { + "id": user.id, + "display_name": user.get_display_name(), + "github_username": github_username, + } + if not isinstance(user, AnonymousUser) + else None + ), + "options": { + "comment_on_pr_with_url": pr_to_comment_on_url, + "auto_run_source": auto_run_source, + "referrer": referrer.value, + "disable_coding_step": not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT ), - "options": { - "comment_on_pr_with_url": pr_to_comment_on_url, - "auto_run_source": auto_run_source, - "referrer": referrer.value, - "disable_coding_step": not group.organization.get_option( - "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT - ), - "stopping_point": stopping_point.value if stopping_point else None, - }, + "stopping_point": stopping_point.value if stopping_point else None, }, - option=orjson.OPT_NON_STR_KEYS, - ) + } viewer_context = SeerViewerContext(organization_id=group.organization.id) if not isinstance(user, AnonymousUser): viewer_context["user_id"] = user.id - response = make_autofix_start_request(body, viewer_context=viewer_context) + if features.has("organizations:seer-run-mirror", group.organization): + with outbox_context(transaction.atomic(using=router.db_for_write(SeerRun)), flush=True): + run = SeerRun.objects.create( + organization=group.organization, + user_id=user.id if not isinstance(user, AnonymousUser) else None, + type=SeerRunType.AUTOFIX, + last_triggered_at=now(), + ) + CellOutbox( + shard_scope=OutboxScope.ORGANIZATION_SCOPE, + shard_identifier=group.organization.id, + category=OutboxCategory.SEER_RUN_CREATE, + object_identifier=run.id, + payload={ + "body": body_dict, + "viewer_context": dict(viewer_context), + }, + ).save() + run.refresh_from_db() + if run.mirror_status != SeerRunMirrorStatus.LIVE or run.seer_run_state_id is None: + raise Exception("Seer run mirror failed to materialize") + return run.seer_run_state_id + + response = make_autofix_start_request( + orjson.dumps(body_dict, option=orjson.OPT_NON_STR_KEYS), + viewer_context=viewer_context, + ) if response.status >= 400: raise Exception(f"Seer request failed with status {response.status}") diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 6c21bc99e93a..6ce3451bba0b 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1181,6 +1181,47 @@ def test_call_autofix(self, mock_request, mock_get_username) -> None: ) assert body["options"]["disable_coding_step"] is False + @patch("sentry.receivers.outbox.cell.make_autofix_start_request") + def test_outbox_path_creates_run_and_returns_run_id(self, mock_request: Mock) -> None: + mock_request.return_value = Mock(status=200, json=Mock(return_value={"run_id": 42})) + + group = self.create_group() + preference = SeerProjectPreference( + organization_id=group.organization.id, + project_id=group.project.id, + repositories=[], + ) + + with self.feature("organizations:seer-run-mirror"): + run_id = _call_autofix( + user=self.user, + group=group, + preference=preference, + serialized_event={"event_id": "test-event"}, + profile=None, + trace_tree=None, + logs=None, + tags_overview=None, + referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, + ) + + assert run_id == 42 + + from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus, SeerRunType + + run = SeerRun.objects.get(organization_id=group.organization.id) + assert run.type == SeerRunType.AUTOFIX + assert run.mirror_status == SeerRunMirrorStatus.LIVE + assert run.seer_run_state_id == 42 + assert run.user_id == self.user.id + + sent_body = mock_request.call_args[0][0] + body = orjson.loads(sent_body) + assert body["organization_id"] == group.organization.id + assert body["project_id"] == group.project.id + assert "external_idempotency_key" in body + assert body["external_idempotency_key"] == str(run.uuid) + class TestGetGithubUsernameForUser(TestCase): def test_get_github_username_for_user_with_github(self) -> None: