Skip to content

Commit e4b7c04

Browse files
committed
feat(preprod): Add snapshot PR comment task and wiring (EME-999)
Add Celery task that posts/updates GitHub PR comments for snapshot comparisons. Follows the same pattern as build distribution PR comments: locks CommitComparison rows, finds existing comment across PR commits, creates or updates via GitHub API, stores comment_id in extras["pr_comments"]["snapshots"]. Wire the task into all snapshot trigger points: - Comparison completion/failure in snapshots/tasks.py - Upload completion in snapshot endpoint - Approval via API and GitHub check run webhook - Rerun status checks endpoint
1 parent 15b3213 commit e4b7c04

7 files changed

Lines changed: 784 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from sentry.models.organization import Organization
1717
from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval
18+
from sentry.preprod.vcs.pr_comments.snapshot_tasks import create_preprod_snapshot_pr_comment_task
1819
from sentry.preprod.vcs.status_checks.size.tasks import create_preprod_status_check_task
1920
from sentry.preprod.vcs.status_checks.snapshots.tasks import (
2021
create_preprod_snapshot_status_check_task,
@@ -91,4 +92,12 @@ def post(self, request: Request, organization: Organization, artifact_id: str) -
9192
caller="approval_endpoint",
9293
)
9394

95+
if feature_type == PreprodComparisonApproval.FeatureType.SNAPSHOTS:
96+
create_preprod_snapshot_pr_comment_task.apply_async(
97+
kwargs={
98+
"preprod_artifact_id": artifact.id,
99+
"caller": "approval_endpoint",
100+
},
101+
)
102+
94103
return Response({"detail": "Approved"}, status=201)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sentry.preprod.analytics import PreprodArtifactApiRerunStatusChecksEvent
1414
from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint
1515
from sentry.preprod.models import PreprodArtifact
16+
from sentry.preprod.vcs.pr_comments.snapshot_tasks import create_preprod_snapshot_pr_comment_task
1617
from sentry.preprod.vcs.status_checks.size.tasks import create_preprod_status_check_task
1718
from sentry.preprod.vcs.status_checks.snapshots.tasks import (
1819
create_preprod_snapshot_status_check_task,
@@ -100,6 +101,9 @@ def post(
100101
create_preprod_snapshot_status_check_task.delay(
101102
preprod_artifact_id=head_artifact.id, caller="rerun_endpoint"
102103
)
104+
create_preprod_snapshot_pr_comment_task.delay(
105+
preprod_artifact_id=head_artifact.id, caller="rerun_endpoint"
106+
)
103107
case _:
104108
continue
105109
except Exception:

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from sentry.preprod.snapshots.tasks import compare_snapshots
5757
from sentry.preprod.snapshots.utils import find_base_snapshot_artifact
5858
from sentry.preprod.url_utils import get_preprod_artifact_url
59+
from sentry.preprod.vcs.pr_comments.snapshot_tasks import create_preprod_snapshot_pr_comment_task
5960
from sentry.preprod.vcs.status_checks.snapshots.tasks import (
6061
create_preprod_snapshot_status_check_task,
6162
)
@@ -574,6 +575,12 @@ def post(self, request: Request, project: Project) -> Response:
574575
"caller": "upload_completion",
575576
},
576577
)
578+
create_preprod_snapshot_pr_comment_task.apply_async(
579+
kwargs={
580+
"preprod_artifact_id": artifact.id,
581+
"caller": "upload_completion",
582+
},
583+
)
577584

578585
if base_sha and base_repo_name:
579586
try:

src/sentry/preprod/snapshots/tasks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
SnapshotManifest,
2222
)
2323
from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics
24+
from sentry.preprod.vcs.pr_comments.snapshot_tasks import create_preprod_snapshot_pr_comment_task
2425
from sentry.preprod.vcs.status_checks.snapshots.tasks import (
2526
create_preprod_snapshot_status_check_task,
2627
)
@@ -587,6 +588,12 @@ def _fetch_hash(h: str) -> None:
587588
"caller": "compare_completion",
588589
},
589590
)
591+
create_preprod_snapshot_pr_comment_task.apply_async(
592+
kwargs={
593+
"preprod_artifact_id": head_artifact_id,
594+
"caller": "compare_completion",
595+
},
596+
)
590597

591598
logger.info(
592599
"Snapshot comparison complete",
@@ -627,4 +634,10 @@ def _fetch_hash(h: str) -> None:
627634
"caller": "compare_failure",
628635
},
629636
)
637+
create_preprod_snapshot_pr_comment_task.apply_async(
638+
kwargs={
639+
"preprod_artifact_id": head_artifact_id,
640+
"caller": "compare_failure",
641+
},
642+
)
630643
raise
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import Sequence
5+
from typing import Any
6+
7+
from django.db import router, transaction
8+
from taskbroker_client.retry import Retry
9+
10+
from sentry import features
11+
from sentry.models.commitcomparison import CommitComparison
12+
from sentry.preprod.integration_utils import get_commit_context_client
13+
from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval
14+
from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics
15+
from sentry.preprod.vcs.pr_comments.snapshot_templates import format_snapshot_pr_comment
16+
from sentry.preprod.vcs.pr_comments.tasks import _get_error_type
17+
from sentry.preprod.vcs.status_checks.snapshots.tasks import (
18+
FAIL_ON_ADDED_OPTION_KEY,
19+
FAIL_ON_REMOVED_OPTION_KEY,
20+
_build_changes_map,
21+
)
22+
from sentry.shared_integrations.exceptions import ApiError
23+
from sentry.silo.base import SiloMode
24+
from sentry.tasks.base import instrumented_task
25+
from sentry.taskworker.namespaces import preprod_tasks
26+
27+
logger = logging.getLogger(__name__)
28+
29+
ENABLED_OPTION_KEY = "sentry:preprod_snapshot_pr_comments_enabled"
30+
FEATURE_FLAG = "organizations:preprod-snapshot-pr-comments"
31+
32+
33+
@instrumented_task(
34+
name="sentry.preprod.tasks.create_preprod_snapshot_pr_comment",
35+
namespace=preprod_tasks,
36+
processing_deadline_duration=30,
37+
silo_mode=SiloMode.CELL,
38+
retry=Retry(times=5, delay=60 * 5),
39+
)
40+
def create_preprod_snapshot_pr_comment_task(
41+
preprod_artifact_id: int, caller: str | None = None, **kwargs: Any
42+
) -> None:
43+
try:
44+
artifact = PreprodArtifact.objects.select_related(
45+
"mobile_app_info",
46+
"commit_comparison",
47+
"project",
48+
"project__organization",
49+
).get(id=preprod_artifact_id)
50+
except PreprodArtifact.DoesNotExist:
51+
logger.exception(
52+
"preprod.snapshot_pr_comments.create.artifact_not_found",
53+
extra={"artifact_id": preprod_artifact_id, "caller": caller},
54+
)
55+
return
56+
57+
if not artifact.commit_comparison:
58+
logger.info(
59+
"preprod.snapshot_pr_comments.create.no_commit_comparison",
60+
extra={"artifact_id": artifact.id},
61+
)
62+
return
63+
64+
commit_comparison = artifact.commit_comparison
65+
if (
66+
not commit_comparison.pr_number
67+
or not commit_comparison.head_repo_name
68+
or not commit_comparison.provider
69+
):
70+
logger.info(
71+
"preprod.snapshot_pr_comments.create.no_pr_info",
72+
extra={
73+
"artifact_id": artifact.id,
74+
"pr_number": commit_comparison.pr_number,
75+
"head_repo_name": commit_comparison.head_repo_name,
76+
},
77+
)
78+
return
79+
80+
if not artifact.project.get_option(ENABLED_OPTION_KEY, default=True):
81+
logger.info(
82+
"preprod.snapshot_pr_comments.create.project_disabled",
83+
extra={"artifact_id": artifact.id, "project_id": artifact.project.id},
84+
)
85+
return
86+
87+
organization = artifact.project.organization
88+
if not features.has(FEATURE_FLAG, organization):
89+
logger.info(
90+
"preprod.snapshot_pr_comments.create.feature_disabled",
91+
extra={"artifact_id": artifact.id, "organization_id": organization.id},
92+
)
93+
return
94+
95+
client = get_commit_context_client(
96+
organization, commit_comparison.head_repo_name, commit_comparison.provider
97+
)
98+
if not client:
99+
logger.info(
100+
"preprod.snapshot_pr_comments.create.no_client",
101+
extra={"artifact_id": artifact.id},
102+
)
103+
return
104+
105+
api_error: Exception | None = None
106+
107+
with transaction.atomic(router.db_for_write(CommitComparison)):
108+
all_for_pr = list(
109+
CommitComparison.objects.select_for_update()
110+
.filter(
111+
organization_id=commit_comparison.organization_id,
112+
head_repo_name=commit_comparison.head_repo_name,
113+
pr_number=commit_comparison.pr_number,
114+
)
115+
.order_by("id")
116+
)
117+
118+
try:
119+
cc = next(c for c in all_for_pr if c.id == commit_comparison.id)
120+
except StopIteration:
121+
raise CommitComparison.DoesNotExist(
122+
f"CommitComparison {commit_comparison.id} was deleted before lock acquisition"
123+
)
124+
125+
# Gather snapshot data
126+
all_artifacts = list(artifact.get_sibling_artifacts_for_commit())
127+
128+
artifact_ids = [a.id for a in all_artifacts]
129+
snapshot_metrics_qs = PreprodSnapshotMetrics.objects.filter(
130+
preprod_artifact_id__in=artifact_ids,
131+
)
132+
snapshot_metrics_map: dict[int, PreprodSnapshotMetrics] = {
133+
m.preprod_artifact_id: m for m in snapshot_metrics_qs
134+
}
135+
136+
all_artifacts = [a for a in all_artifacts if a.id in snapshot_metrics_map]
137+
if not all_artifacts:
138+
return
139+
140+
metrics_ids = [m.id for m in snapshot_metrics_map.values()]
141+
comparisons_qs = PreprodSnapshotComparison.objects.filter(
142+
head_snapshot_metrics_id__in=metrics_ids,
143+
)
144+
comparisons_map: dict[int, PreprodSnapshotComparison] = {
145+
c.head_snapshot_metrics_id: c for c in comparisons_qs
146+
}
147+
148+
approvals_map: dict[int, PreprodComparisonApproval] = {}
149+
approval_qs = PreprodComparisonApproval.objects.filter(
150+
preprod_artifact_id__in=artifact_ids,
151+
preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS,
152+
approval_status=PreprodComparisonApproval.ApprovalStatus.APPROVED,
153+
)
154+
for approval in approval_qs:
155+
approvals_map[approval.preprod_artifact_id] = approval
156+
157+
base_artifact_map = PreprodArtifact.get_base_artifacts_for_commit(all_artifacts)
158+
159+
fail_on_added = artifact.project.get_option(FAIL_ON_ADDED_OPTION_KEY, default=False)
160+
fail_on_removed = artifact.project.get_option(FAIL_ON_REMOVED_OPTION_KEY, default=True)
161+
changes_map = _build_changes_map(
162+
all_artifacts,
163+
snapshot_metrics_map,
164+
comparisons_map,
165+
fail_on_added=fail_on_added,
166+
fail_on_removed=fail_on_removed,
167+
)
168+
169+
comment_body = format_snapshot_pr_comment(
170+
all_artifacts,
171+
snapshot_metrics_map,
172+
comparisons_map,
173+
base_artifact_map,
174+
changes_map,
175+
approvals_map=approvals_map,
176+
)
177+
178+
existing_comment_id = _find_existing_comment_id(all_for_pr)
179+
180+
try:
181+
if existing_comment_id:
182+
client.update_comment(
183+
repo=cc.head_repo_name,
184+
issue_id=str(cc.pr_number),
185+
comment_id=str(existing_comment_id),
186+
data={"body": comment_body},
187+
)
188+
comment_id = existing_comment_id
189+
logger.info(
190+
"preprod.snapshot_pr_comments.create.updated",
191+
extra={"artifact_id": artifact.id, "comment_id": comment_id},
192+
)
193+
else:
194+
resp = client.create_comment(
195+
repo=cc.head_repo_name,
196+
issue_id=str(cc.pr_number),
197+
data={"body": comment_body},
198+
)
199+
comment_id = str(resp["id"])
200+
logger.info(
201+
"preprod.snapshot_pr_comments.create.created",
202+
extra={"artifact_id": artifact.id, "comment_id": comment_id},
203+
)
204+
except Exception as e:
205+
extra: dict[str, Any] = {
206+
"artifact_id": artifact.id,
207+
"organization_id": organization.id,
208+
"error_type": type(e).__name__,
209+
}
210+
if isinstance(e, ApiError):
211+
extra["status_code"] = e.code
212+
logger.exception("preprod.snapshot_pr_comments.create.failed", extra=extra)
213+
_save_pr_comment_result(cc, success=False, error=e)
214+
api_error = e
215+
else:
216+
_save_pr_comment_result(cc, success=True, comment_id=comment_id)
217+
218+
if api_error is not None:
219+
raise api_error
220+
221+
222+
def _find_existing_comment_id(
223+
comparisons: Sequence[CommitComparison],
224+
) -> str | None:
225+
for cc in comparisons:
226+
extras = cc.extras or {}
227+
comment_id = extras.get("pr_comments", {}).get("snapshots", {}).get("comment_id")
228+
if comment_id:
229+
return str(comment_id)
230+
return None
231+
232+
233+
def _save_pr_comment_result(
234+
commit_comparison: CommitComparison,
235+
success: bool,
236+
comment_id: str | None = None,
237+
error: Exception | None = None,
238+
) -> None:
239+
extras = commit_comparison.extras or {}
240+
241+
# Preserve the existing comment_id on failure so retries use
242+
# update_comment instead of creating a duplicate.
243+
if not comment_id:
244+
existing = extras.get("pr_comments", {}).get("snapshots", {})
245+
comment_id = existing.get("comment_id")
246+
247+
result: dict[str, Any] = {"success": success}
248+
if comment_id:
249+
result["comment_id"] = comment_id
250+
if not success:
251+
result["error_type"] = _get_error_type(error)
252+
253+
pr_comments = extras.setdefault("pr_comments", {})
254+
pr_comments["snapshots"] = result
255+
commit_comparison.extras = extras
256+
commit_comparison.save(update_fields=["extras"])

src/sentry/preprod/vcs/webhooks/github_check_run.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from sentry.models.repository import Repository
1717
from sentry.preprod.analytics import PreprodStatusCheckApprovalCreatedEvent
1818
from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval
19+
from sentry.preprod.vcs.pr_comments.snapshot_tasks import create_preprod_snapshot_pr_comment_task
1920
from sentry.preprod.vcs.status_checks.size.tasks import (
2021
APPROVE_SIZE_ACTION_IDENTIFIER,
2122
create_preprod_status_check_task,
@@ -224,5 +225,11 @@ def handle_preprod_check_run_event(
224225
preprod_artifact_id=artifact.id,
225226
caller="github_approve_webhook",
226227
)
228+
create_preprod_snapshot_pr_comment_task.apply_async(
229+
kwargs={
230+
"preprod_artifact_id": artifact.id,
231+
"caller": "github_approve_webhook",
232+
},
233+
)
227234
else:
228235
raise ValueError(f"Unknown identifier: {identifier}")

0 commit comments

Comments
 (0)