Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -432,9 +432,29 @@ def put(
},
)

can_run_distro, _ = should_run_distribution(head_artifact)
can_run_distro, distro_skip_reason = should_run_distribution(head_artifact)
if can_run_distro:
requested_features.append(PreprodFeature.BUILD_DISTRIBUTION)
else:
if distro_skip_reason == "quota":
distro_error_code = PreprodArtifact.InstallableAppErrorCode.NO_QUOTA
distro_error_message = "Distribution quota exceeded"
elif distro_skip_reason == "disabled":
distro_error_code = PreprodArtifact.InstallableAppErrorCode.SKIPPED
distro_error_message = "Distribution disabled for this project"
else:
distro_error_code = PreprodArtifact.InstallableAppErrorCode.SKIPPED
distro_error_message = "Distribution filtered out by project settings"

Comment on lines +439 to +448
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: When an artifact is rerun, the reset_artifact_data function does not clear distribution-specific error fields, leaving stale error data after a successful retry.
Severity: MEDIUM

Suggested Fix

Update the reset_artifact_data function in preprod_artifact_rerun_analysis.py to also set installable_app_error_code and installable_app_error_message to None. This will ensure that stale error information is cleared before a rerun attempt.

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/preprod/api/endpoints/project_preprod_artifact_update.py#L439-L448

Potential issue: When an artifact distribution fails and is subsequently rerun, the
function `reset_artifact_data` is called. This function clears general artifact error
fields like `error_code` and `error_message`, but it fails to clear
distribution-specific error fields, namely `installable_app_error_code` and
`installable_app_error_message`. If the rerun is successful, the distribution task sets
`installable_app_file_id`, but the stale error fields from the previous failed attempt
persist. This results in an inconsistent state where the artifact appears to have both
succeeded and failed simultaneously.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, might be good to add this to:

def reset_artifact_data(preprod_artifact: PreprodArtifact) -> None:

head_artifact.installable_app_error_code = distro_error_code
head_artifact.installable_app_error_message = distro_error_message
head_artifact.save(
update_fields=[
"installable_app_error_code",
"installable_app_error_message",
"date_updated",
]
)

return Response(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ class DistributionInfo(BaseModel):
is_installable: bool
download_count: int
release_notes: str | None = None
error_code: str | None = None
error_message: str | None = None


class StatusCheckResultSuccess(BaseModel):
Expand Down Expand Up @@ -303,10 +305,18 @@ def transform_preprod_artifact_to_build_details(

app_info = create_build_details_app_info(artifact)
is_installable = is_installable_artifact(artifact)

error_code_str = None
if artifact.installable_app_error_code is not None:
error_code_map = dict(PreprodArtifact.InstallableAppErrorCode.as_choices())
error_code_str = error_code_map.get(artifact.installable_app_error_code)

distribution_info = DistributionInfo(
is_installable=is_installable,
download_count=(get_download_count_for_artifact(artifact) if is_installable else 0),
release_notes=(artifact.extras.get("release_notes") if artifact.extras else None),
error_code=error_code_str,
error_message=artifact.installable_app_error_message,
)

vcs_info = BuildDetailsVcsInfo(
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/preprod/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,16 @@ class InstallableAppErrorCode(IntEnum):
"""No quota available for distribution."""
SKIPPED = 2
"""Distribution was not requested on this build."""
PROCESSING_ERROR = 3
"""Distribution failed due to a processing error."""

@classmethod
def as_choices(cls) -> tuple[tuple[int, str], ...]:
return (
(cls.UNKNOWN, "unknown"),
(cls.NO_QUOTA, "no_quota"),
(cls.SKIPPED, "skipped"),
(cls.PROCESSING_ERROR, "processing_error"),
)

__relocation_scope__ = RelocationScope.Excluded
Expand Down
16 changes: 3 additions & 13 deletions tests/sentry/preprod/api/endpoints/test_builds.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.urls import reverse
from django.utils.functional import cached_property

from sentry.preprod.models import PreprodArtifactSizeMetrics
from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.datetime import before_now
from sentry.testutils.helpers.features import with_feature
Expand Down Expand Up @@ -96,6 +96,8 @@ def test_one_build(self) -> None:
"download_count": 0,
"is_installable": False,
"release_notes": None,
"error_code": None,
"error_message": None,
},
"vcs_info": {
"head_sha": None,
Expand Down Expand Up @@ -502,8 +504,6 @@ def test_query_git_pr_number(self) -> None:

@with_feature("organizations:preprod-frontend-routes")
def test_query_platform_name_apple(self) -> None:
from sentry.preprod.models import PreprodArtifact

self.create_preprod_artifact(
app_id="ios.app", artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE
)
Expand All @@ -519,8 +519,6 @@ def test_query_platform_name_apple(self) -> None:

@with_feature("organizations:preprod-frontend-routes")
def test_query_platform_name_android(self) -> None:
from sentry.preprod.models import PreprodArtifact

self.create_preprod_artifact(
app_id="ios.app", artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE
)
Expand All @@ -540,8 +538,6 @@ def test_query_platform_name_android(self) -> None:

@with_feature("organizations:preprod-frontend-routes")
def test_query_platform_name_in(self) -> None:
from sentry.preprod.models import PreprodArtifact

self.create_preprod_artifact(
app_id="ios.app", artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE
)
Expand All @@ -561,8 +557,6 @@ def test_query_platform_name_in(self) -> None:

@with_feature("organizations:preprod-frontend-routes")
def test_query_platform_name_not_in(self) -> None:
from sentry.preprod.models import PreprodArtifact

self.create_preprod_artifact(
app_id="ios.app", artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE
)
Expand Down Expand Up @@ -823,8 +817,6 @@ def test_free_text_search_multiple_matches(self) -> None:

@with_feature("organizations:preprod-frontend-routes")
def test_free_text_search_with_structured_filter(self) -> None:
from sentry.preprod.models import PreprodArtifact

cc = self.create_commit_comparison(
organization=self.organization, head_ref="feature/awesome"
)
Expand Down Expand Up @@ -933,7 +925,6 @@ def test_queryset_for_query_with_filter(self) -> None:

def test_queryset_for_query_platform_filter(self) -> None:
from sentry.preprod.artifact_search import queryset_for_query
from sentry.preprod.models import PreprodArtifact

ios_artifact = self.create_preprod_artifact(
app_id="ios.app", artifact_type=PreprodArtifact.ArtifactType.XCARCHIVE
Expand Down Expand Up @@ -1000,7 +991,6 @@ def test_artifact_matches_query_empty_query(self) -> None:

def test_artifact_matches_query_complex_filter(self) -> None:
from sentry.preprod.artifact_search import artifact_matches_query
from sentry.preprod.models import PreprodArtifact

cc = self.create_commit_comparison(
organization=self.organization, head_ref="feature/test", pr_number=123
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,53 @@ def test_update_preprod_artifact_filters_distribution_when_no_quota(
assert "size_analysis" in resp_data["requestedFeatures"]
assert "build_distribution" not in resp_data["requestedFeatures"]

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"])
def test_update_no_error_code_when_distribution_can_run(self) -> None:
data = {"artifact_type": 1}
response = self._make_request(data)

assert response.status_code == 200
self.preprod_artifact.refresh_from_db()
assert self.preprod_artifact.installable_app_error_code is None
assert self.preprod_artifact.installable_app_error_message is None

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"])
@patch("sentry.preprod.quotas.has_installable_quota")
def test_update_sets_error_code_no_quota(self, mock_has_installable_quota) -> None:
mock_has_installable_quota.return_value = False

data = {"artifact_type": 1}
response = self._make_request(data)

assert response.status_code == 200
self.preprod_artifact.refresh_from_db()
assert (
self.preprod_artifact.installable_app_error_code
== PreprodArtifact.InstallableAppErrorCode.NO_QUOTA
)
assert self.preprod_artifact.installable_app_error_message == "Distribution quota exceeded"

@override_settings(LAUNCHPAD_RPC_SHARED_SECRET=["test-secret-key"])
def test_update_sets_error_code_skipped_when_filtered(self) -> None:
self.preprod_artifact.app_id = "com.my.app"
self.preprod_artifact.save()

self.project.update_option(DISTRIBUTION_ENABLED_QUERY_KEY, "app_id:com.other.app")

data = {"artifact_type": 1}
response = self._make_request(data)

assert response.status_code == 200
self.preprod_artifact.refresh_from_db()
assert (
self.preprod_artifact.installable_app_error_code
== PreprodArtifact.InstallableAppErrorCode.SKIPPED
)
assert (
self.preprod_artifact.installable_app_error_message
== "Distribution filtered out by project settings"
)


class FindOrCreateReleaseTest(TestCase):
def test_exact_version_matching_prevents_incorrect_matches(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ def test_get_build_details_distribution_info(self) -> None:
assert distribution_info["download_count"] == 5
assert distribution_info["release_notes"] == "Build notes"

def test_get_build_details_distribution_error_fields(self) -> None:
self.preprod_artifact.installable_app_error_code = (
PreprodArtifact.InstallableAppErrorCode.NO_QUOTA
)
self.preprod_artifact.installable_app_error_message = "quota"
self.preprod_artifact.save()

url = self._get_url()
response = self.client.get(
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
)

assert response.status_code == 200
distribution_info = response.json()["distribution_info"]
assert distribution_info["error_code"] == "no_quota"
assert distribution_info["error_message"] == "quota"

def test_get_build_details_not_found(self) -> None:
url = self._get_url(artifact_id=999999)
response = self.client.get(
Expand Down
Loading