diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 5fea1f8c74abe2..a1fcfe3c24b981 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -50,6 +50,7 @@ from sentry.hybridcloud.rpc.sig import SerializableFunctionValueException from sentry.integrations.github_enterprise.integration import GitHubEnterpriseIntegration from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.repository import repository_service from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organization import Organization, OrganizationStatus from sentry.models.project import Project @@ -635,6 +636,62 @@ def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) - return {"success": True} +def upsert_pr_metrics_summary( + *, + organization_id: int, + repository_id: int, + pr_number: str, + event_id: str, + verdict: str, + counters: dict, + refined_attribution_signals: list[dict] | None = None, +) -> dict: + """Accept (in Sentry, from Seer) a PR judge summary at close/merge time. + + Seer computes the verdict + counters during its single close/merge SCM + fetch and posts them back here so Sentry's canonical PR row stays the source + of truth for product queries. + + This is currently a validate-and-acknowledge stub (CORE-201): it pins the + signed-RPC contract so Seer can integrate before the storage exists. + + Args: + organization_id: The owning organization. + repository_id: Sentry's internal Repository id (also pins the provider). + pr_number: The external SCM pull-request number. + event_id: Idempotency key, stable across webhook redeliveries. + verdict: The judge verdict (e.g. "merged_unchanged"). + counters: Comment/commit/timing counters computed at judge time. + refined_attribution_signals: Any newly-derived attribution signals. + + Returns: + dict: ``{"success": True}`` once accepted, else ``{"success": False, "error": ...}``. + """ + if not verdict: + return {"success": False, "error": "verdict must be a non-empty string"} + + # Scope the repository to the organization to prevent cross-org reference. + if repository_service.get_repository(organization_id=organization_id, id=repository_id) is None: + return {"success": False, "error": "Repository not found"} + + # TODO(CORE-201): persist once CORE-200's models land. Upsert + # PullRequestMetrics keyed on event_id (idempotent across webhook + # redeliveries), insert any newly-derived PullRequestAttribution rows from + # refined_attribution_signals, and recompute PullRequest.attribution. + # Full write lands in M1/M3 (CW-1436). + logger.info( + "seer.pr_metrics.upsert_pr_metrics_summary", + extra={ + "organization_id": organization_id, + "repository_id": repository_id, + "pr_number": pr_number, + "event_id": event_id, + "verdict": verdict, + }, + ) + return {"success": True} + + def trigger_coding_agent_launch( *, organization_id: int, @@ -1025,6 +1082,9 @@ def bulk_get_project_preferences( # # Issue Detection "create_issue_occurrence": create_issue_occurrence, + # + # PR Merge Live Metrics + "upsert_pr_metrics_summary": upsert_pr_metrics_summary, } diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index dbf59ebe247607..5cf2f7a1b5fcbc 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -30,6 +30,7 @@ get_repo_installation_id, has_repo_code_mappings, trigger_coding_agent_launch, + upsert_pr_metrics_summary, validate_repo, ) from sentry.sentry_apps.metrics import SentryAppEventType @@ -1771,3 +1772,88 @@ def test_integration_not_found_skips_clear_when_project_outside_org(self, mock_l assert result == {"success": False, "error_code": "integration_not_found"} assert other_project.get_option("sentry:seer_automation_handoff_point") == "root_cause" + + +@override_settings(SEER_RPC_SHARED_SECRET=["a-long-value-that-is-hard-to-guess"]) +class TestPrMetricsRpc(APITestCase): + """Stub upsert_pr_metrics_summary handler (CORE-201).""" + + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization(owner=self.user) + project = self.create_project(organization=self.organization) + self.repository = self.create_repo(project=project, name="getsentry/sentry") + + @staticmethod + def _get_path(method_name: str) -> str: + return reverse("sentry-api-0-seer-rpc-service", kwargs={"method_name": method_name}) + + def _auth_header(self, path: str, data: dict) -> str: + body = orjson.dumps(data).decode() + return f"rpcsignature {generate_request_signature(path, body.encode())}" + + def test_upsert_pr_metrics_summary_success(self) -> None: + result = upsert_pr_metrics_summary( + organization_id=self.organization.id, + repository_id=self.repository.id, + pr_number="123", + event_id="123:merged:abc123", + verdict="merged_unchanged", + counters={"comments": 0}, + ) + assert result == {"success": True} + + def test_upsert_pr_metrics_summary_empty_verdict(self) -> None: + result = upsert_pr_metrics_summary( + organization_id=self.organization.id, + repository_id=self.repository.id, + pr_number="123", + event_id="evt", + verdict="", + counters={}, + ) + assert result["success"] is False + + def test_upsert_pr_metrics_summary_repository_not_found(self) -> None: + result = upsert_pr_metrics_summary( + organization_id=self.organization.id, + repository_id=self.repository.id + 1000, + pr_number="123", + event_id="evt", + verdict="closed_unmerged", + counters={}, + ) + assert result == {"success": False, "error": "Repository not found"} + + def test_upsert_pr_metrics_summary_repository_outside_org(self) -> None: + other_org = self.create_organization() + other_project = self.create_project(organization=other_org) + other_repo = self.create_repo(project=other_project, name="getsentry/other2") + result = upsert_pr_metrics_summary( + organization_id=self.organization.id, + repository_id=other_repo.id, + pr_number="123", + event_id="evt", + verdict="closed_unmerged", + counters={}, + ) + assert result == {"success": False, "error": "Repository not found"} + + def test_upsert_pr_metrics_summary_registered_on_internal_rpc(self) -> None: + path = self._get_path("upsert_pr_metrics_summary") + data: dict[str, Any] = { + "args": { + "organization_id": self.organization.id, + "repository_id": self.repository.id, + "pr_number": "123", + "event_id": "123:merged:abc123", + "verdict": "merged_unchanged", + "counters": {"comments": 2}, + }, + "meta": {}, + } + response = self.client.post( + path, data=data, HTTP_AUTHORIZATION=self._auth_header(path, data) + ) + assert response.status_code == 200 + assert response.data == {"success": True}