|
| 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