Skip to content

Commit 16dc990

Browse files
feat(preprod): add API to re-run status checks (#105484)
Sometimes status checks fail outside of our control, say if the Github API is down for an extended time or the user is being rate-limited. This allows the user to manually trigger the status check. --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent ae7d27f commit 16dc990

File tree

5 files changed

+325
-0
lines changed

5 files changed

+325
-0
lines changed

src/sentry/preprod/analytics.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ class PreprodArtifactApiRerunAnalysisEvent(analytics.Event):
5252
artifact_id: str
5353

5454

55+
@analytics.eventclass("preprod_artifact.api.rerun_status_checks")
56+
class PreprodArtifactApiRerunStatusChecksEvent(analytics.Event):
57+
organization_id: int
58+
project_id: int
59+
user_id: int | None = None
60+
artifact_id: str
61+
check_types: list[str]
62+
63+
5564
@analytics.eventclass("preprod_artifact.api.admin_get_info")
5665
class PreprodArtifactApiAdminGetInfoEvent(analytics.Event):
5766
organization_id: int
@@ -143,6 +152,7 @@ class PreprodApiPrPageCommentsEvent(analytics.Event):
143152
analytics.register(PreprodArtifactApiListBuildsEvent)
144153
analytics.register(PreprodArtifactApiInstallDetailsEvent)
145154
analytics.register(PreprodArtifactApiRerunAnalysisEvent)
155+
analytics.register(PreprodArtifactApiRerunStatusChecksEvent)
146156
analytics.register(PreprodArtifactApiAdminGetInfoEvent)
147157
analytics.register(PreprodArtifactApiAdminBatchDeleteEvent)
148158
analytics.register(PreprodArtifactApiDeleteEvent)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from rest_framework.request import Request
6+
from rest_framework.response import Response
7+
8+
from sentry import analytics
9+
from sentry.api.api_owners import ApiOwner
10+
from sentry.api.api_publish_status import ApiPublishStatus
11+
from sentry.api.base import region_silo_endpoint
12+
from sentry.models.project import Project
13+
from sentry.preprod.analytics import PreprodArtifactApiRerunStatusChecksEvent
14+
from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint
15+
from sentry.preprod.models import PreprodArtifact
16+
from sentry.preprod.vcs.status_checks.size.tasks import create_preprod_status_check_task
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
@region_silo_endpoint
22+
class PreprodArtifactRerunStatusChecksEndpoint(PreprodArtifactEndpoint):
23+
owner = ApiOwner.EMERGE_TOOLS
24+
publish_status = {
25+
"POST": ApiPublishStatus.PRIVATE,
26+
}
27+
28+
def post(
29+
self,
30+
request: Request,
31+
project: Project,
32+
head_artifact_id: int,
33+
head_artifact: PreprodArtifact,
34+
) -> Response:
35+
"""
36+
Re-run status checks for a specific preprod artifact.
37+
38+
This endpoint re-triggers the status check posting to GitHub without
39+
re-running the full analysis pipeline. Useful for recovering from
40+
transient GitHub API errors.
41+
"""
42+
check_types = request.data.get("check_types", ["size"])
43+
44+
if not isinstance(check_types, list):
45+
return Response(
46+
{"error": "check_types must be an array"},
47+
status=400,
48+
)
49+
50+
if not check_types:
51+
return Response(
52+
{"error": "check_types must contain at least one check type"},
53+
status=400,
54+
)
55+
56+
if not all(isinstance(ct, str) for ct in check_types):
57+
return Response(
58+
{"error": "All check_types must be strings"},
59+
status=400,
60+
)
61+
62+
SUPPORTED_CHECK_TYPES = {"size"}
63+
supported_types = list({ct for ct in check_types if ct in SUPPORTED_CHECK_TYPES})
64+
if not supported_types:
65+
return Response(
66+
{"error": "No supported check types provided. Currently only 'size' is supported."},
67+
status=400,
68+
)
69+
70+
if not head_artifact.commit_comparison:
71+
return Response(
72+
{"error": "Cannot create status check: artifact has no commit comparison."},
73+
status=400,
74+
)
75+
76+
analytics.record(
77+
PreprodArtifactApiRerunStatusChecksEvent(
78+
organization_id=project.organization_id,
79+
project_id=project.id,
80+
user_id=request.user.id,
81+
artifact_id=str(head_artifact.id),
82+
check_types=supported_types,
83+
)
84+
)
85+
86+
failed_types = []
87+
for check_type in supported_types:
88+
try:
89+
match check_type:
90+
case "size":
91+
create_preprod_status_check_task.delay(preprod_artifact_id=head_artifact.id)
92+
case _:
93+
continue
94+
except Exception:
95+
logger.exception(
96+
"preprod_artifact.rerun_status_checks.task_error",
97+
extra={
98+
"artifact_id": head_artifact.id,
99+
"user_id": request.user.id,
100+
"organization_id": head_artifact.project.organization_id,
101+
"project_id": head_artifact.project.id,
102+
"check_type": check_type,
103+
},
104+
)
105+
failed_types.append(check_type)
106+
107+
if failed_types:
108+
return Response(
109+
{
110+
"error": f"Failed to queue status checks for artifact {head_artifact.id}",
111+
"failed_check_types": failed_types,
112+
},
113+
status=500,
114+
)
115+
116+
logger.info(
117+
"preprod_artifact.rerun_status_checks",
118+
extra={
119+
"artifact_id": head_artifact.id,
120+
"user_id": request.user.id,
121+
"organization_id": head_artifact.project.organization_id,
122+
"project_id": head_artifact.project.id,
123+
"check_types": supported_types,
124+
},
125+
)
126+
127+
return Response(
128+
{
129+
"success": True,
130+
"artifact_id": str(head_artifact.id),
131+
"message": f"Status check rerun initiated for artifact {head_artifact.id}",
132+
"check_types": supported_types,
133+
},
134+
status=202,
135+
)

src/sentry/preprod/api/endpoints/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
PreprodArtifactAdminRerunAnalysisEndpoint,
2525
PreprodArtifactRerunAnalysisEndpoint,
2626
)
27+
from .preprod_artifact_rerun_status_checks import PreprodArtifactRerunStatusChecksEndpoint
2728
from .project_installable_preprod_artifact_download import (
2829
ProjectInstallablePreprodArtifactDownloadEndpoint,
2930
)
@@ -113,6 +114,11 @@
113114
PreprodArtifactRerunAnalysisEndpoint.as_view(),
114115
name="sentry-api-0-preprod-artifact-rerun-analysis",
115116
),
117+
re_path(
118+
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/preprod-artifact/rerun-status-checks/(?P<head_artifact_id>[^/]+)/$",
119+
PreprodArtifactRerunStatusChecksEndpoint.as_view(),
120+
name="sentry-api-0-preprod-artifact-rerun-status-checks",
121+
),
116122
]
117123

118124
preprod_organization_urlpatterns = [

static/app/utils/api/knownSentryApiUrls.generated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ export type KnownSentryApiUrls =
656656
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/plugins/'
657657
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/plugins/$pluginId/'
658658
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/preprod-artifact/rerun-analysis/$headArtifactId/'
659+
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/preprod-artifact/rerun-status-checks/$headArtifactId/'
659660
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/preprodartifacts/$headArtifactId/build-details/'
660661
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/preprodartifacts/$headArtifactId/delete/'
661662
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/preprodartifacts/$headArtifactId/install-details/'
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
from unittest.mock import patch
2+
3+
from sentry.testutils.cases import APITestCase
4+
5+
6+
class PreprodArtifactRerunStatusChecksTest(APITestCase):
7+
endpoint = "sentry-api-0-preprod-artifact-rerun-status-checks"
8+
method = "post"
9+
10+
def setUp(self):
11+
super().setUp()
12+
self.organization = self.create_organization(owner=self.user)
13+
self.project = self.create_project(organization=self.organization)
14+
self.login_as(user=self.user)
15+
16+
def test_success(self):
17+
commit_comparison = self.create_commit_comparison(
18+
organization=self.organization,
19+
provider="github",
20+
head_repo_name="sentry/sentry",
21+
head_sha="abc123",
22+
)
23+
artifact = self.create_preprod_artifact(
24+
project=self.project,
25+
app_name="test_artifact",
26+
app_id="com.test.app",
27+
build_version="1.0.0",
28+
build_number=1,
29+
commit_comparison=commit_comparison,
30+
)
31+
32+
with patch(
33+
"sentry.preprod.api.endpoints.preprod_artifact_rerun_status_checks.create_preprod_status_check_task"
34+
) as mock_task:
35+
response = self.get_success_response(
36+
self.organization.slug,
37+
self.project.slug,
38+
artifact.id,
39+
status_code=202,
40+
)
41+
42+
assert response.data["success"] is True
43+
assert response.data["check_types"] == ["size"]
44+
mock_task.delay.assert_called_once_with(preprod_artifact_id=artifact.id)
45+
46+
def test_invalid_check_types(self):
47+
commit_comparison = self.create_commit_comparison(
48+
organization=self.organization,
49+
provider="github",
50+
head_repo_name="sentry/sentry",
51+
head_sha="abc123",
52+
)
53+
artifact = self.create_preprod_artifact(
54+
project=self.project,
55+
app_name="test_artifact",
56+
app_id="com.test.app",
57+
build_version="1.0.0",
58+
build_number=1,
59+
commit_comparison=commit_comparison,
60+
)
61+
62+
response = self.get_error_response(
63+
self.organization.slug,
64+
self.project.slug,
65+
artifact.id,
66+
check_types=["invalid"],
67+
status_code=400,
68+
)
69+
70+
assert "No supported check types" in response.data["error"]
71+
72+
def test_non_string_check_types(self):
73+
commit_comparison = self.create_commit_comparison(
74+
organization=self.organization,
75+
provider="github",
76+
head_repo_name="sentry/sentry",
77+
head_sha="abc123",
78+
)
79+
artifact = self.create_preprod_artifact(
80+
project=self.project,
81+
app_name="test_artifact",
82+
app_id="com.test.app",
83+
build_version="1.0.0",
84+
build_number=1,
85+
commit_comparison=commit_comparison,
86+
)
87+
88+
response = self.get_error_response(
89+
self.organization.slug,
90+
self.project.slug,
91+
artifact.id,
92+
check_types=[{"type": "size"}],
93+
status_code=400,
94+
)
95+
96+
assert "All check_types must be strings" in response.data["error"]
97+
98+
def test_no_commit_comparison(self):
99+
artifact = self.create_preprod_artifact(
100+
project=self.project,
101+
app_name="test_artifact",
102+
app_id="com.test.app",
103+
build_version="1.0.0",
104+
build_number=1,
105+
)
106+
107+
response = self.get_error_response(
108+
self.organization.slug,
109+
self.project.slug,
110+
artifact.id,
111+
status_code=400,
112+
)
113+
114+
assert "no commit comparison" in response.data["error"]
115+
116+
def test_task_failure(self):
117+
commit_comparison = self.create_commit_comparison(
118+
organization=self.organization,
119+
provider="github",
120+
head_repo_name="sentry/sentry",
121+
head_sha="abc123",
122+
)
123+
artifact = self.create_preprod_artifact(
124+
project=self.project,
125+
app_name="test_artifact",
126+
app_id="com.test.app",
127+
build_version="1.0.0",
128+
build_number=1,
129+
commit_comparison=commit_comparison,
130+
)
131+
132+
with patch(
133+
"sentry.preprod.api.endpoints.preprod_artifact_rerun_status_checks.create_preprod_status_check_task"
134+
) as mock_task:
135+
mock_task.delay.side_effect = Exception("Task queue error")
136+
137+
response = self.get_error_response(
138+
self.organization.slug,
139+
self.project.slug,
140+
artifact.id,
141+
status_code=500,
142+
)
143+
144+
assert "Failed to queue status checks" in response.data["error"]
145+
assert response.data["failed_check_types"] == ["size"]
146+
147+
def test_permission_denied(self):
148+
other_user = self.create_user()
149+
self.login_as(user=other_user)
150+
151+
commit_comparison = self.create_commit_comparison(
152+
organization=self.organization,
153+
provider="github",
154+
head_repo_name="sentry/sentry",
155+
head_sha="abc123",
156+
)
157+
artifact = self.create_preprod_artifact(
158+
project=self.project,
159+
app_name="test_artifact",
160+
app_id="com.test.app",
161+
build_version="1.0.0",
162+
build_number=1,
163+
commit_comparison=commit_comparison,
164+
)
165+
166+
response = self.get_error_response(
167+
self.organization.slug,
168+
self.project.slug,
169+
artifact.id,
170+
status_code=403,
171+
)
172+
173+
assert response.status_code == 403

0 commit comments

Comments
 (0)