|
| 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"]) |
0 commit comments