From 47fca4894ce991390f8eed41947c2c56fe8884e9 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 2 Jun 2026 12:06:00 +0200 Subject: [PATCH 1/2] feat(seer): Stub inbound register_pr_attribution & upsert_pr_metrics_summary RPCs Add two accept-and-acknowledge handlers to the seer-rpc surface so the Seer side has a real, signed endpoint to integrate against before the PR-metrics storage and full logic land. Both validate the org-scoped repository (via repository_service.get_repository) and the required payload fields, log structured context, and return {"success": True}; the actual persistence is left as in-code TODOs because the PullRequest extensions and the new PullRequestAttribution / PullRequestMetrics tables land separately in CORE-200, with the full handler logic following in M1/M2/M3. PRs are identified externally by organization_id + repository_id + pr_number; repository_id already pins the provider, so no separate provider field. Refs CORE-201 Co-Authored-By: Claude Opus 4.8 --- src/sentry/seer/endpoints/seer_rpc.py | 114 ++++++++++++++ tests/sentry/seer/endpoints/test_seer_rpc.py | 152 +++++++++++++++++++ 2 files changed, 266 insertions(+) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 5fea1f8c74abe2..c2ef2dc140e1ab 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,115 @@ def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) - return {"success": True} +def register_pr_attribution( + *, + organization_id: int, + repository_id: int, + pr_number: str, + signal_type: str, + run_id: int | None = None, + signal_details: dict | None = None, +) -> dict: + """Accept (in Sentry, from Seer/Sentry-MCP) an attribution signal pushed for a PR. + + Seer calls this when it learns a PR is attributable to a Sentry feature + (e.g. a delegated coding-agent run) — possibly before Sentry's SCM webhook + has fired. This is the cheap push path of the PR-metrics attribution flow. + + 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. + signal_type: The attribution source (e.g. "seer_delegated:cursor"). + run_id: The Seer run id that produced the PR, if known. + signal_details: Optional extra context to persist on the signal. + + Returns: + dict: ``{"success": True}`` once accepted, else ``{"success": False, "error": ...}``. + """ + if not signal_type: + return {"success": False, "error": "signal_type 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 a shell + # PullRequest row for the race where Seer learns the PR id before the SCM + # `opened` webhook, insert a PullRequestAttribution row, and recompute + # PullRequest.attribution. Full handling lands in M2 (CORE-204). + logger.info( + "seer.pr_metrics.register_pr_attribution", + extra={ + "organization_id": organization_id, + "repository_id": repository_id, + "pr_number": pr_number, + "signal_type": signal_type, + "run_id": run_id, + }, + ) + 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 +1135,10 @@ def bulk_get_project_preferences( # # Issue Detection "create_issue_occurrence": create_issue_occurrence, + # + # PR Merge Live Metrics + "register_pr_attribution": register_pr_attribution, + "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..8109e9c7bdad6f 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -29,7 +29,9 @@ get_project_preferences, get_repo_installation_id, has_repo_code_mappings, + register_pr_attribution, trigger_coding_agent_launch, + upsert_pr_metrics_summary, validate_repo, ) from sentry.sentry_apps.metrics import SentryAppEventType @@ -1771,3 +1773,153 @@ 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 register_pr_attribution / upsert_pr_metrics_summary handlers (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_register_pr_attribution_success(self) -> None: + result = register_pr_attribution( + organization_id=self.organization.id, + repository_id=self.repository.id, + pr_number="123", + signal_type="seer_delegated:cursor", + run_id=42, + ) + assert result == {"success": True} + + def test_register_pr_attribution_empty_signal_type(self) -> None: + result = register_pr_attribution( + organization_id=self.organization.id, + repository_id=self.repository.id, + pr_number="123", + signal_type="", + ) + assert result["success"] is False + + def test_register_pr_attribution_repository_not_found(self) -> None: + result = register_pr_attribution( + organization_id=self.organization.id, + repository_id=self.repository.id + 1000, + pr_number="123", + signal_type="seer_app", + ) + assert result == {"success": False, "error": "Repository not found"} + + def test_register_pr_attribution_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/other") + result = register_pr_attribution( + organization_id=self.organization.id, + repository_id=other_repo.id, + pr_number="123", + signal_type="seer_app", + ) + assert result == {"success": False, "error": "Repository not found"} + + def test_register_pr_attribution_missing_required_arg_raises(self) -> None: + with pytest.raises(TypeError): + register_pr_attribution( + organization_id=self.organization.id, + repository_id=self.repository.id, + pr_number="123", + ) + + def test_register_pr_attribution_registered_on_internal_rpc(self) -> None: + path = self._get_path("register_pr_attribution") + data: dict[str, Any] = { + "args": { + "organization_id": self.organization.id, + "repository_id": self.repository.id, + "pr_number": "123", + "signal_type": "seer_app", + }, + "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} + + 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} From e5669e43f660d915e7b5eb3f41f40bad83a3646d Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 2 Jun 2026 15:36:17 +0200 Subject: [PATCH 2/2] ref(seer): Drop register_pr_attribution stub; SEER_PR_CREATED already covers PR creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register_pr_attribution turned out to be unnecessary. Seer already emits the existing seer.pr_created event (SentryAppEventType.SEER_PR_CREATED) when a run creates a PR, and its payload carries the PR id/number/url, repo, and run_id — everything the push RPC was meant to deliver. Sentry therefore already learns about Seer-created PRs over the existing webhook channel, so a dedicated push handler is redundant. The inbound seer-rpc surface for PR-metrics reduces to upsert_pr_metrics_summary, the judge-verdict callback, which has no alternative source. Refs CORE-201 Co-Authored-By: Claude Opus 4.8 --- src/sentry/seer/endpoints/seer_rpc.py | 54 ---------------- tests/sentry/seer/endpoints/test_seer_rpc.py | 68 +------------------- 2 files changed, 1 insertion(+), 121 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index c2ef2dc140e1ab..a1fcfe3c24b981 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -636,59 +636,6 @@ def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) - return {"success": True} -def register_pr_attribution( - *, - organization_id: int, - repository_id: int, - pr_number: str, - signal_type: str, - run_id: int | None = None, - signal_details: dict | None = None, -) -> dict: - """Accept (in Sentry, from Seer/Sentry-MCP) an attribution signal pushed for a PR. - - Seer calls this when it learns a PR is attributable to a Sentry feature - (e.g. a delegated coding-agent run) — possibly before Sentry's SCM webhook - has fired. This is the cheap push path of the PR-metrics attribution flow. - - 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. - signal_type: The attribution source (e.g. "seer_delegated:cursor"). - run_id: The Seer run id that produced the PR, if known. - signal_details: Optional extra context to persist on the signal. - - Returns: - dict: ``{"success": True}`` once accepted, else ``{"success": False, "error": ...}``. - """ - if not signal_type: - return {"success": False, "error": "signal_type 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 a shell - # PullRequest row for the race where Seer learns the PR id before the SCM - # `opened` webhook, insert a PullRequestAttribution row, and recompute - # PullRequest.attribution. Full handling lands in M2 (CORE-204). - logger.info( - "seer.pr_metrics.register_pr_attribution", - extra={ - "organization_id": organization_id, - "repository_id": repository_id, - "pr_number": pr_number, - "signal_type": signal_type, - "run_id": run_id, - }, - ) - return {"success": True} - - def upsert_pr_metrics_summary( *, organization_id: int, @@ -1137,7 +1084,6 @@ def bulk_get_project_preferences( "create_issue_occurrence": create_issue_occurrence, # # PR Merge Live Metrics - "register_pr_attribution": register_pr_attribution, "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 8109e9c7bdad6f..5cf2f7a1b5fcbc 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -29,7 +29,6 @@ get_project_preferences, get_repo_installation_id, has_repo_code_mappings, - register_pr_attribution, trigger_coding_agent_launch, upsert_pr_metrics_summary, validate_repo, @@ -1777,7 +1776,7 @@ def test_integration_not_found_skips_clear_when_project_outside_org(self, mock_l @override_settings(SEER_RPC_SHARED_SECRET=["a-long-value-that-is-hard-to-guess"]) class TestPrMetricsRpc(APITestCase): - """Stub register_pr_attribution / upsert_pr_metrics_summary handlers (CORE-201).""" + """Stub upsert_pr_metrics_summary handler (CORE-201).""" def setUp(self) -> None: super().setUp() @@ -1793,71 +1792,6 @@ 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_register_pr_attribution_success(self) -> None: - result = register_pr_attribution( - organization_id=self.organization.id, - repository_id=self.repository.id, - pr_number="123", - signal_type="seer_delegated:cursor", - run_id=42, - ) - assert result == {"success": True} - - def test_register_pr_attribution_empty_signal_type(self) -> None: - result = register_pr_attribution( - organization_id=self.organization.id, - repository_id=self.repository.id, - pr_number="123", - signal_type="", - ) - assert result["success"] is False - - def test_register_pr_attribution_repository_not_found(self) -> None: - result = register_pr_attribution( - organization_id=self.organization.id, - repository_id=self.repository.id + 1000, - pr_number="123", - signal_type="seer_app", - ) - assert result == {"success": False, "error": "Repository not found"} - - def test_register_pr_attribution_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/other") - result = register_pr_attribution( - organization_id=self.organization.id, - repository_id=other_repo.id, - pr_number="123", - signal_type="seer_app", - ) - assert result == {"success": False, "error": "Repository not found"} - - def test_register_pr_attribution_missing_required_arg_raises(self) -> None: - with pytest.raises(TypeError): - register_pr_attribution( - organization_id=self.organization.id, - repository_id=self.repository.id, - pr_number="123", - ) - - def test_register_pr_attribution_registered_on_internal_rpc(self) -> None: - path = self._get_path("register_pr_attribution") - data: dict[str, Any] = { - "args": { - "organization_id": self.organization.id, - "repository_id": self.repository.id, - "pr_number": "123", - "signal_type": "seer_app", - }, - "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} - def test_upsert_pr_metrics_summary_success(self) -> None: result = upsert_pr_metrics_summary( organization_id=self.organization.id,