diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py index 6aaeeb4f131b85..8134c9f1bcd447 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from typing import Any import jsonschema @@ -104,6 +105,10 @@ **VCS_ERROR_MESSAGES, } +_SENTRY_CLI_UA_PREFIX = "sentry-cli/" +_GRADLE_PLUGIN_UA_PREFIX = "sentry-gradle-plugin/" +_FASTLANE_PLUGIN_UA_PREFIX = "sentry-fastlane-plugin/" + _COMPACT_FIELDS = {"display_name", "image_file_name", "group", "description"} _COMPACT_IMAGE_LIST_KEYS = ("images", "added", "removed", "unchanged", "skipped") _COMPACT_PAIR_LIST_KEYS = ("changed", "renamed", "errored") @@ -134,6 +139,32 @@ def build_snapshot_image_response( ) +@dataclass +class _ToolVersions: + cli_version: str | None = None + gradle_plugin_version: str | None = None + fastlane_plugin_version: str | None = None + + +def _parse_tool_versions(user_agent: str) -> _ToolVersions: + """Extract tool versions from a user-agent string. + + Formats: + ``sentry-cli/X.Y.Z`` + ``sentry-cli/X.Y.Z sentry-gradle-plugin/A.B.C`` + ``sentry-cli/X.Y.Z sentry-fastlane-plugin/A.B.C`` + """ + versions = _ToolVersions() + for part in user_agent.split(): + if part.startswith(_SENTRY_CLI_UA_PREFIX): + versions.cli_version = part[len(_SENTRY_CLI_UA_PREFIX) :] or None + elif part.startswith(_GRADLE_PLUGIN_UA_PREFIX): + versions.gradle_plugin_version = part[len(_GRADLE_PLUGIN_UA_PREFIX) :] or None + elif part.startswith(_FASTLANE_PLUGIN_UA_PREFIX): + versions.fastlane_plugin_version = part[len(_FASTLANE_PLUGIN_UA_PREFIX) :] or None + return versions + + def validate_preprod_snapshot_post_schema( request_body: bytes, ) -> tuple[dict[str, Any], str | None]: @@ -613,11 +644,16 @@ def post(self, request: Request, project: Project) -> Response: }, ) + tool_versions = _parse_tool_versions(request.META.get("HTTP_USER_AGENT", "")) + artifact = PreprodArtifact.objects.create( project=project, state=PreprodArtifact.ArtifactState.UPLOADED, app_id=app_id, commit_comparison=commit_comparison, + cli_version=tool_versions.cli_version, + gradle_plugin_version=tool_versions.gradle_plugin_version, + fastlane_plugin_version=tool_versions.fastlane_plugin_version, ) manifest_key = f"{project.organization_id}/{project.id}/{artifact.id}/manifest.json" diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index 6a6f5efdfce66a..77da36e7513fd4 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -61,6 +61,110 @@ def test_successful_snapshot_upload(self) -> None: assert snapshot_metrics.preprod_artifact == artifact assert snapshot_metrics.image_count == 1 + def test_snapshot_upload_stores_cli_version_from_user_agent(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post( + url, data, format="json", HTTP_USER_AGENT="sentry-cli/2.40.0" + ) + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version == "2.40.0" + + def test_snapshot_upload_stores_gradle_plugin_version(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post( + url, + data, + format="json", + HTTP_USER_AGENT="sentry-cli/2.40.0 sentry-gradle-plugin/5.0.0", + ) + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version == "2.40.0" + assert artifact.gradle_plugin_version == "5.0.0" + assert artifact.fastlane_plugin_version is None + + def test_snapshot_upload_stores_fastlane_plugin_version(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post( + url, + data, + format="json", + HTTP_USER_AGENT="sentry-cli/2.40.0 sentry-fastlane-plugin/2.5.1", + ) + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version == "2.40.0" + assert artifact.fastlane_plugin_version == "2.5.1" + assert artifact.gradle_plugin_version is None + + def test_snapshot_upload_no_cli_version_for_unknown_user_agent(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post(url, data, format="json", HTTP_USER_AGENT="curl/8.0") + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version is None + def test_snapshot_upload_creates_commit_comparison(self) -> None: url = self._get_create_url() data = {