Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/sentry/objectstore/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from sentry.objectstore.service import ClientBuilder

attachments = ClientBuilder("attachments")
app_icons = ClientBuilder("app-icons")
140 changes: 140 additions & 0 deletions src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from __future__ import annotations

import logging

from django.http import HttpResponse
from rest_framework.request import Request

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint
from sentry.models.project import Project
from sentry.objectstore import app_icons
from sentry.objectstore.service import ClientError

logger = logging.getLogger(__name__)


def detect_image_content_type(image_data: bytes) -> str:
"""
Detect the content type of an image from its magic bytes.
Returns the appropriate MIME type or a default if unknown.
"""
if not image_data:
return "application/octet-stream"

# Check magic bytes for common image formats
if image_data[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
elif image_data[:3] == b"\xff\xd8\xff":
return "image/jpeg"
elif image_data[:4] == b"RIFF" and image_data[8:12] == b"WEBP":
return "image/webp"
elif image_data[:2] in (b"BM", b"BA", b"CI", b"CP", b"IC", b"PT"):
return "image/bmp"
elif image_data[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
elif image_data[:4] == b"\x00\x00\x01\x00":
return "image/x-icon"
elif len(image_data) >= 12 and image_data[4:12] in (b"ftypavif", b"ftypavis"):
return "image/avif"
elif len(image_data) >= 12 and image_data[4:12] in (
b"ftypheic",
b"ftypheix",
b"ftyphevc",
b"ftyphevx",
):
return "image/heic"

# Default to generic binary if we can't detect the type
logger.warning(
"Could not detect image content type from magic bytes",
extra={"first_bytes": image_data[:16].hex() if len(image_data) >= 16 else image_data.hex()},
)
return "application/octet-stream"


@region_silo_endpoint
class ProjectPreprodArtifactIconEndpoint(ProjectEndpoint):
owner = ApiOwner.EMERGE_TOOLS
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}

def get(
self,
request: Request,
project: Project,
app_icon_id: str,
) -> HttpResponse:
organization_id = project.organization_id
project_id = project.id

# object_key = f"{organization_id}/{project_id}/{app_icon_id}"
logger.info(
"Retrieving app icon from objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"app_icon_id": app_icon_id,
},
)
client = app_icons.for_project(organization_id, project_id)

try:
result = client.get(app_icon_id)
# Read the entire stream at once
image_data = result.payload.read()

# Detect content type from the image data
content_type = detect_image_content_type(image_data)

logger.info(
"Retrieved app icon from objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"app_icon_id": app_icon_id,
"size_bytes": len(image_data),
"content_type": content_type,
},
)
return HttpResponse(image_data, content_type=content_type)

except ClientError as e:
if e.status == 404:
logger.warning(
"App icon not found in objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"app_icon_id": app_icon_id,
},
)

# Upload failed, return appropriate error
return HttpResponse({"error": "Not found"}, status=404)

logger.warning(
"Failed to retrieve app icon from objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"app_icon_id": app_icon_id,
"error": str(e),
"status": e.status,
},
)
return HttpResponse({"error": "Failed to retrieve app icon"}, status=500)

except Exception:
logger.exception(
"Unexpected error retrieving app icon",
extra={
"organization_id": organization_id,
"project_id": project_id,
"app_icon_id": app_icon_id,
},
)
return HttpResponse({"error": "Internal server error"}, status=500)
8 changes: 8 additions & 0 deletions src/sentry/preprod/api/endpoints/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from django.urls import re_path

from sentry.preprod.api.endpoints.project_preprod_artifact_icon import (
ProjectPreprodArtifactIconEndpoint,
)
from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import (
ProjectPreprodArtifactSizeAnalysisCompareEndpoint,
)
Expand Down Expand Up @@ -81,6 +84,11 @@
ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(),
name="sentry-api-0-installable-preprod-artifact-download",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/app-icons/(?P<app_icon_id>[^/]+)/$",
ProjectPreprodArtifactIconEndpoint.as_view(),
name="sentry-api-0-project-preprod-app-icon",
),
# Size analysis
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/preprodartifacts/size-analysis/compare/(?P<head_artifact_id>[^/]+)/(?P<base_artifact_id>[^/]+)/$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class BuildDetailsAppInfo(BaseModel):
platform: Platform | None = None
is_installable: bool
build_configuration: str | None = None
app_icon_id: str | None = None


class BuildDetailsVcsInfo(BaseModel):
Expand Down Expand Up @@ -152,6 +153,7 @@ def transform_preprod_artifact_to_build_details(
build_configuration=(
artifact.build_configuration.name if artifact.build_configuration else None
),
app_icon_id=artifact.app_icon_id,
)

vcs_info = BuildDetailsVcsInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {t} from 'sentry/locale';
import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10';
import {getFormattedDate} from 'sentry/utils/dates';
import {unreachable} from 'sentry/utils/unreachable';
import useOrganization from 'sentry/utils/useOrganization';
import {openInstallModal} from 'sentry/views/preprod/components/installModal';
import {
BuildDetailsSizeAnalysisState,
Expand Down Expand Up @@ -70,14 +71,21 @@ interface BuildDetailsSidebarAppInfoProps {
}

export function BuildDetailsSidebarAppInfo(props: BuildDetailsSidebarAppInfoProps) {
const organization = useOrganization();
const labels = getLabels(props.appInfo.platform ?? undefined);

let iconUrl = null;
if (props.appInfo.app_icon_id) {
iconUrl = `/api/0/projects/${organization.slug}/${props.projectId}/files/app-icons/${props.appInfo.app_icon_id}/`;
}

return (
<Flex direction="column" gap="xl">
<Flex align="center" gap="sm">
<AppIcon>
{iconUrl && <img src={iconUrl} alt="App Icon" width={24} height={24} />}

Choose a reason for hiding this comment

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

[BestPractice]

Missing accessibility attributes: The <img> element should include proper accessibility attributes for better screen reader support:

Suggested change
{iconUrl && <img src={iconUrl} alt="App Icon" width={24} height={24} />}
{iconUrl && <img src={iconUrl} alt={props.appInfo.name ? `${props.appInfo.name} app icon` : 'App icon'} width={24} height={24} />}

Committable suggestion

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Context for Agents
[**BestPractice**]

Missing accessibility attributes: The `<img>` element should include proper accessibility attributes for better screen reader support:

```suggestion
        {iconUrl && <img src={iconUrl} alt={props.appInfo.name ? `${props.appInfo.name} app icon` : 'App icon'} width={24} height={24} />}
```

⚡ **Committable suggestion**

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

File: static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx
Line: 85

{!iconUrl && (
<AppIconPlaceholder>{props.appInfo.name?.charAt(0) || ''}</AppIconPlaceholder>
</AppIcon>
)}
Comment on lines +85 to +88

Choose a reason for hiding this comment

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

[BestPractice]

Missing error handling for image loading: If the constructed iconUrl is invalid or the image fails to load, users will see a broken image. Add error handling to fallback to the placeholder:

const [imageError, setImageError] = useState(false);

// In the JSX:
{iconUrl && !imageError && (
  <img 
    src={iconUrl} 
    alt="App Icon" 
    width={24} 
    height={24}
    onError={() => setImageError(true)}
  />
)}
{(!iconUrl || imageError) && (
  <AppIconPlaceholder>{props.appInfo.name?.charAt(0) || ''}</AppIconPlaceholder>
)}

Note: You'll need to add import {useState} from 'react'; at the top of the file.

Context for Agents
[**BestPractice**]

Missing error handling for image loading: If the constructed `iconUrl` is invalid or the image fails to load, users will see a broken image. Add error handling to fallback to the placeholder:

```typescript
const [imageError, setImageError] = useState(false);

// In the JSX:
{iconUrl && !imageError && (
  <img 
    src={iconUrl} 
    alt="App Icon" 
    width={24} 
    height={24}
    onError={() => setImageError(true)}
  />
)}
{(!iconUrl || imageError) && (
  <AppIconPlaceholder>{props.appInfo.name?.charAt(0) || ''}</AppIconPlaceholder>
)}
```

Note: You'll need to add `import {useState} from 'react';` at the top of the file.

File: static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx
Line: 88

{props.appInfo.name && <Heading as="h3">{props.appInfo.name}</Heading>}
</Flex>

Expand Down Expand Up @@ -187,19 +195,16 @@ export function BuildDetailsSidebarAppInfo(props: BuildDetailsSidebarAppInfoProp
);
}

const AppIcon = styled('div')`
const AppIconPlaceholder = styled('div')`
width: 24px;
height: 24px;
border-radius: 4px;
background: #ff6600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;

const AppIconPlaceholder = styled('div')`
color: white;
background: ${p => p.theme.purple400};
color: ${p => p.theme.white};
font-weight: ${p => p.theme.fontWeight.bold};
font-size: ${p => p.theme.fontSize.sm};
`;
Expand Down
1 change: 1 addition & 0 deletions static/app/views/preprod/types/buildDetailsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface BuildDetailsApiResponse {
}

export interface BuildDetailsAppInfo {
app_icon_id?: string | null;
app_id?: string | null;
artifact_type?: BuildDetailsArtifactType | null;
build_configuration?: string | null;
Expand Down