Skip to content

Commit 5cd53c6

Browse files
authored
feat(preprod): Add snapshot PR comment task and wiring (#112360)
Add Celery task that posts/updates GitHub PR comments for snapshot comparisons, and wire it into all existing trigger points. **Task** (`snapshot_tasks.py`): Follows the build distribution PR comment pattern — locks CommitComparison rows to prevent duplicates, finds existing comments across PR commits, creates or updates via GitHub API, stores comment_id in `extras["pr_comments"]["snapshots"]`. Gated by feature flag `organizations:preprod-snapshot-pr-comments` and project option `sentry:preprod_snapshot_pr_comments_enabled` (both already registered). **Wiring**: Triggered alongside the snapshot status check task at five entry points: - Comparison completion and failure (`snapshots/tasks.py`) - Upload completion (`preprod_artifact_snapshot.py`) - Approval via API (`preprod_artifact_approve.py`) - Approval via GitHub check run button (`github_check_run.py`) - Rerun status checks (`preprod_artifact_rerun_status_checks.py`) I might have missed something so let me know if this should be added somewhere else! Here's what this looks like using the test script: [link](runningcode/hackernews#1) <img width="910" height="373" alt="image" src="https://github.com/user-attachments/assets/04569cbf-268d-437b-b0c4-4f2b86541226" /> Depends on #112353. Refs EME-999
1 parent 894f929 commit 5cd53c6

File tree

12 files changed

+1199
-51
lines changed

12 files changed

+1199
-51
lines changed
Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
#!/usr/bin/env python
2+
3+
from sentry.runner import configure
4+
5+
configure()
6+
7+
import logging
8+
import sys
9+
from concurrent.futures import as_completed
10+
from datetime import timedelta
11+
12+
logger = logging.getLogger(__name__)
13+
14+
from sentry.models.commitcomparison import CommitComparison
15+
from sentry.models.project import Project
16+
from sentry.preprod.models import (
17+
PreprodArtifact,
18+
PreprodArtifactMobileAppInfo,
19+
PreprodComparisonApproval,
20+
)
21+
from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics
22+
from sentry.preprod.vcs.pr_comments.snapshot_tasks import create_preprod_snapshot_pr_comment_task
23+
from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor as ThreadPoolExecutor
24+
25+
#################
26+
# Configuration #
27+
#################
28+
ORG_SLUG = "sentry"
29+
PROJECT_SLUG = "internal"
30+
REPO_NAME = "EmergeTools/hackernews"
31+
PR_NUMBER = 784
32+
COMMIT_SHA = "46fb48581ba0e8cdcaa01765a1123d996f5272d3"
33+
BASE_COMMIT_SHA = "a79c91dacdbc32c0cc64e5251062a5d210089e64"
34+
35+
# Snapshot build configurations
36+
BUILDS = [
37+
{
38+
"app_id": "com.emerge.hackernews.android",
39+
"artifact_type": PreprodArtifact.ArtifactType.AAB,
40+
"app_name": "HackerNews Android",
41+
"build_version": "1.0.3",
42+
"build_number": 42,
43+
"state": "compared_with_changes",
44+
"create_base": True,
45+
"images_changed": 3,
46+
"images_added": 1,
47+
"images_removed": 0,
48+
"images_unchanged": 20,
49+
"image_count": 24,
50+
},
51+
{
52+
"app_id": "com.emerge.hackernews.ios",
53+
"artifact_type": PreprodArtifact.ArtifactType.XCARCHIVE,
54+
"app_name": "HackerNews iOS",
55+
"build_version": "1.0.3",
56+
"build_number": 15,
57+
"state": "compared_no_changes",
58+
"create_base": True,
59+
"images_changed": 0,
60+
"images_added": 0,
61+
"images_removed": 0,
62+
"images_unchanged": 15,
63+
"image_count": 15,
64+
},
65+
]
66+
67+
# Available states:
68+
# - "no_metrics": Artifact has no snapshot metrics yet (processing)
69+
# - "metrics_no_comparison": Artifact has metrics but no comparison yet
70+
# - "comparison_pending": Comparison created but still pending
71+
# - "comparison_processing": Comparison in progress
72+
# - "comparison_failed": Comparison failed
73+
# - "compared_no_changes": Comparison complete, all images match
74+
# - "compared_with_changes": Comparison complete, some images differ
75+
76+
# Pass --concurrent to trigger all builds in parallel (tests lock serialization)
77+
CONCURRENT = "--concurrent" in sys.argv
78+
79+
80+
def cleanup_existing_data(project, commit_comparison):
81+
"""Remove existing test artifacts for idempotent reruns."""
82+
logger.info("\nCleaning up existing artifacts for commit %s...", COMMIT_SHA[:8])
83+
84+
base_commit_comparisons = CommitComparison.objects.filter(
85+
head_sha=BASE_COMMIT_SHA,
86+
organization_id=project.organization_id,
87+
)
88+
89+
all_commit_comparisons = [commit_comparison] + list(base_commit_comparisons)
90+
existing_artifacts = PreprodArtifact.objects.filter(
91+
commit_comparison__in=all_commit_comparisons,
92+
project__organization_id=project.organization_id,
93+
)
94+
95+
if existing_artifacts.exists():
96+
artifact_ids = list(existing_artifacts.values_list("id", flat=True))
97+
metrics = PreprodSnapshotMetrics.objects.filter(preprod_artifact_id__in=artifact_ids)
98+
metrics_ids = list(metrics.values_list("id", flat=True))
99+
100+
comparison_count = PreprodSnapshotComparison.objects.filter(
101+
head_snapshot_metrics_id__in=metrics_ids
102+
).delete()[0]
103+
metrics_count = metrics.delete()[0]
104+
approval_count = PreprodComparisonApproval.objects.filter(
105+
preprod_artifact_id__in=artifact_ids
106+
).delete()[0]
107+
artifact_count = existing_artifacts.delete()[0]
108+
109+
if base_commit_comparisons.exists():
110+
base_count = base_commit_comparisons.delete()[0]
111+
logger.info(
112+
" Deleted %s artifacts, %s metrics, %s comparisons, %s approvals, %s base commits",
113+
artifact_count,
114+
metrics_count,
115+
comparison_count,
116+
approval_count,
117+
base_count,
118+
)
119+
else:
120+
logger.info(
121+
" Deleted %s artifacts, %s metrics, %s comparisons, %s approvals",
122+
artifact_count,
123+
metrics_count,
124+
comparison_count,
125+
approval_count,
126+
)
127+
else:
128+
logger.info(" No existing artifacts found (clean slate)")
129+
130+
# Clear any previous snapshot comment_id so we get a fresh create
131+
extras = commit_comparison.extras or {}
132+
pr_comments = extras.get("pr_comments", {})
133+
if "snapshots" in pr_comments:
134+
del pr_comments["snapshots"]
135+
commit_comparison.extras = extras
136+
commit_comparison.save(update_fields=["extras"])
137+
logger.info(" Cleared previous snapshot comment_id from extras")
138+
139+
140+
def create_artifact_and_metrics(project, build_config, commit_comparison):
141+
"""Create a preprod artifact with snapshot metrics."""
142+
state = build_config["state"]
143+
144+
artifact = PreprodArtifact.objects.create(
145+
project=project,
146+
artifact_type=build_config["artifact_type"],
147+
app_id=build_config["app_id"],
148+
commit_comparison=commit_comparison,
149+
state=PreprodArtifact.ArtifactState.PROCESSED,
150+
)
151+
PreprodArtifactMobileAppInfo.objects.create(
152+
preprod_artifact=artifact,
153+
app_name=build_config["app_name"],
154+
build_version=build_config["build_version"],
155+
build_number=build_config["build_number"],
156+
)
157+
158+
artifact.date_added = artifact.date_added - timedelta(minutes=5)
159+
artifact.save(update_fields=["date_added"])
160+
161+
logger.info(" Created artifact: %s (ID: %s)", build_config["app_id"], artifact.id)
162+
163+
if state == "no_metrics":
164+
logger.info(" No metrics created (simulating upload in progress)")
165+
return artifact, None
166+
167+
metrics = PreprodSnapshotMetrics.objects.create(
168+
preprod_artifact=artifact,
169+
image_count=build_config.get("image_count", 10),
170+
)
171+
logger.info(" Created snapshot metrics: %s (%s images)", metrics.id, metrics.image_count)
172+
173+
return artifact, metrics
174+
175+
176+
def create_base_data(project, build_config, head_commit_comparison):
177+
"""Create base artifact and metrics for comparison."""
178+
base_commit_comparison, _ = CommitComparison.objects.get_or_create(
179+
head_repo_name=head_commit_comparison.head_repo_name,
180+
head_sha=BASE_COMMIT_SHA,
181+
provider=head_commit_comparison.provider,
182+
organization_id=head_commit_comparison.organization_id,
183+
)
184+
185+
if not head_commit_comparison.base_sha:
186+
head_commit_comparison.base_sha = BASE_COMMIT_SHA
187+
head_commit_comparison.save(update_fields=["base_sha"])
188+
189+
base_artifact = PreprodArtifact.objects.create(
190+
project=project,
191+
artifact_type=build_config["artifact_type"],
192+
app_id=build_config["app_id"],
193+
commit_comparison=base_commit_comparison,
194+
state=PreprodArtifact.ArtifactState.PROCESSED,
195+
)
196+
PreprodArtifactMobileAppInfo.objects.create(
197+
preprod_artifact=base_artifact,
198+
app_name=build_config["app_name"],
199+
build_version=build_config["build_version"],
200+
build_number=build_config["build_number"] - 1,
201+
)
202+
203+
base_metrics = PreprodSnapshotMetrics.objects.create(
204+
preprod_artifact=base_artifact,
205+
image_count=build_config.get("image_count", 10),
206+
)
207+
208+
logger.info(
209+
" Created base artifact: %s (ID: %s) with metrics",
210+
build_config["app_id"],
211+
base_artifact.id,
212+
)
213+
214+
return base_metrics
215+
216+
217+
def create_comparison(build_config, head_metrics, base_metrics):
218+
"""Create a snapshot comparison based on the configured state."""
219+
state = build_config["state"]
220+
221+
state_map = {
222+
"comparison_pending": PreprodSnapshotComparison.State.PENDING,
223+
"comparison_processing": PreprodSnapshotComparison.State.PROCESSING,
224+
"comparison_failed": PreprodSnapshotComparison.State.FAILED,
225+
"compared_no_changes": PreprodSnapshotComparison.State.SUCCESS,
226+
"compared_with_changes": PreprodSnapshotComparison.State.SUCCESS,
227+
}
228+
229+
comparison_state = state_map.get(state)
230+
if comparison_state is None:
231+
return None
232+
233+
comparison = PreprodSnapshotComparison.objects.create(
234+
head_snapshot_metrics=head_metrics,
235+
base_snapshot_metrics=base_metrics,
236+
state=comparison_state,
237+
images_changed=build_config.get("images_changed", 0),
238+
images_added=build_config.get("images_added", 0),
239+
images_removed=build_config.get("images_removed", 0),
240+
images_unchanged=build_config.get("images_unchanged", 0),
241+
)
242+
243+
status_label = {
244+
PreprodSnapshotComparison.State.PENDING: "Pending",
245+
PreprodSnapshotComparison.State.PROCESSING: "Processing",
246+
PreprodSnapshotComparison.State.FAILED: "Failed",
247+
PreprodSnapshotComparison.State.SUCCESS: "Success",
248+
}[comparison_state]
249+
250+
logger.info(" Created comparison: %s", status_label)
251+
if comparison_state == PreprodSnapshotComparison.State.SUCCESS:
252+
logger.info(
253+
" Changed: %s, Added: %s, Removed: %s, Unchanged: %s",
254+
comparison.images_changed,
255+
comparison.images_added,
256+
comparison.images_removed,
257+
comparison.images_unchanged,
258+
)
259+
260+
return comparison
261+
262+
263+
def setup_builds(project, build_configs, commit_comparison):
264+
"""Create all artifacts, metrics, base data, and comparisons."""
265+
created_artifacts = []
266+
267+
for build_config in build_configs:
268+
logger.info("\nSetting up %s...", build_config["app_id"])
269+
270+
artifact, head_metrics = create_artifact_and_metrics(
271+
project, build_config, commit_comparison
272+
)
273+
created_artifacts.append(artifact)
274+
275+
if (
276+
head_metrics
277+
and build_config.get("create_base")
278+
and build_config["state"]
279+
not in (
280+
"no_metrics",
281+
"metrics_no_comparison",
282+
)
283+
):
284+
base_metrics = create_base_data(project, build_config, commit_comparison)
285+
create_comparison(build_config, head_metrics, base_metrics)
286+
287+
return created_artifacts
288+
289+
290+
def run_task(artifact_id):
291+
"""Run the snapshot PR comment task for an artifact."""
292+
create_preprod_snapshot_pr_comment_task(preprod_artifact_id=artifact_id)
293+
294+
295+
def main():
296+
try:
297+
project = Project.objects.get(slug=PROJECT_SLUG, organization__slug=ORG_SLUG)
298+
logger.info("Found project: %s (ID: %s)", project.name, project.id)
299+
300+
commit_comparison, created = CommitComparison.objects.get_or_create(
301+
head_repo_name=REPO_NAME,
302+
head_sha=COMMIT_SHA,
303+
provider="github",
304+
organization_id=project.organization.id,
305+
defaults={
306+
"head_ref": "main",
307+
"base_ref": "main",
308+
"pr_number": PR_NUMBER,
309+
},
310+
)
311+
if not created and not commit_comparison.pr_number:
312+
commit_comparison.pr_number = PR_NUMBER
313+
commit_comparison.save(update_fields=["pr_number"])
314+
315+
logger.info(
316+
"%s commit comparison: %s (pr_number=%s)",
317+
"Created" if created else "Found",
318+
commit_comparison.id,
319+
commit_comparison.pr_number,
320+
)
321+
322+
cleanup_existing_data(project, commit_comparison)
323+
324+
logger.info("\nRunning snapshot PR comment test with %s build(s):", len(BUILDS))
325+
for i, config in enumerate(BUILDS, 1):
326+
logger.info(
327+
" BUILD_%s: %s (state: %s, base: %s)",
328+
i,
329+
config["app_id"],
330+
config["state"],
331+
config.get("create_base", False),
332+
)
333+
334+
created_artifacts = setup_builds(project, BUILDS, commit_comparison)
335+
336+
if CONCURRENT:
337+
logger.info(
338+
"\nTriggering %s tasks concurrently (testing lock serialization)...",
339+
len(created_artifacts),
340+
)
341+
342+
def run_build_task(artifact):
343+
from django import db
344+
345+
db.connections.close_all()
346+
run_task(artifact.id)
347+
return artifact.id
348+
349+
with ThreadPoolExecutor(max_workers=len(created_artifacts)) as pool:
350+
futures = {pool.submit(run_build_task, a): a.id for a in created_artifacts}
351+
for future in as_completed(futures):
352+
aid = futures[future]
353+
try:
354+
future.result()
355+
logger.info(" Task for artifact %s completed", aid)
356+
except Exception:
357+
logger.exception(" Task for artifact %s failed", aid)
358+
else:
359+
logger.info(
360+
"\nCalling create_preprod_snapshot_pr_comment_task with artifact %s...",
361+
created_artifacts[0].id,
362+
)
363+
run_task(created_artifacts[0].id)
364+
365+
logger.info("Done!")
366+
367+
# Show results
368+
commit_comparison.refresh_from_db()
369+
cc_extras = commit_comparison.extras or {}
370+
snapshots = cc_extras.get("pr_comments", {}).get("snapshots", {})
371+
comment_id = snapshots.get("comment_id")
372+
373+
if snapshots.get("success") and comment_id:
374+
logger.info(
375+
"\nComment posted! Check: https://github.com/%s/pull/%s",
376+
REPO_NAME,
377+
PR_NUMBER,
378+
)
379+
logger.info(" Comment ID: %s", comment_id)
380+
elif not snapshots.get("success") and snapshots:
381+
logger.info("\nComment FAILED. Error: %s", snapshots.get("error_type"))
382+
else:
383+
logger.info("\nComment was NOT posted. Check logs above for early-return reasons.")
384+
385+
except Project.DoesNotExist:
386+
logger.exception("Project not found: %s/%s", ORG_SLUG, PROJECT_SLUG)
387+
for p in Project.objects.select_related("organization").all()[:10]:
388+
logger.info(" - %s/%s", p.organization.slug, p.slug)
389+
sys.exit(1)
390+
except Exception:
391+
logger.exception("Error running trigger_snapshot_pr_comment")
392+
sys.exit(1)
393+
394+
395+
if __name__ == "__main__":
396+
main()

0 commit comments

Comments
 (0)