Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/sentry/seer/endpoints/seer_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}


Expand Down
86 changes: 86 additions & 0 deletions tests/sentry/seer/endpoints/test_seer_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Loading