Skip to content
Closed
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
25 changes: 25 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,15 @@
ProjectRuleGroupHistoryIndexEndpoint,
)
from sentry.rules.history.endpoints.project_rule_stats import ProjectRuleStatsIndexEndpoint
from sentry.seer.code_review.api.endpoints.organization_code_review_event_details import (
OrganizationCodeReviewPRDetailsEndpoint,
)
from sentry.seer.code_review.api.endpoints.organization_code_review_events import (
OrganizationCodeReviewPRsEndpoint,
)
from sentry.seer.code_review.api.endpoints.organization_code_review_stats import (
OrganizationCodeReviewStatsEndpoint,
)
from sentry.seer.endpoints.group_ai_autofix import GroupAutofixEndpoint
from sentry.seer.endpoints.group_ai_summary import GroupAiSummaryEndpoint
from sentry.seer.endpoints.group_autofix_setup_check import GroupAutofixSetupCheck
Expand Down Expand Up @@ -1448,6 +1457,22 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationCodeMappingCodeOwnersEndpoint.as_view(),
name="sentry-api-0-organization-code-mapping-codeowners",
),
# Code Review PRs
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/code-review-prs/$",
OrganizationCodeReviewPRsEndpoint.as_view(),
name="sentry-api-0-organization-code-review-prs",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/code-review-prs/(?P<repo_id>\d+)/(?P<pr_number>\d+)/$",
OrganizationCodeReviewPRDetailsEndpoint.as_view(),
name="sentry-api-0-organization-code-review-pr-details",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/code-review-stats/$",
OrganizationCodeReviewStatsEndpoint.as_view(),
name="sentry-api-0-organization-code-review-stats",
),
# Data Forwarding
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/forwarding/$",
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,8 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
"sentry.sentry_apps.tasks.sentry_apps",
"sentry.sentry_apps.tasks.service_hooks",
"sentry.seer.autofix.issue_summary",
"sentry.seer.code_review.tasks",
"sentry.seer.code_review.webhooks.on_completion",
"sentry.seer.code_review.webhooks.task",
"sentry.seer.entrypoints.operator",
"sentry.seer.entrypoints.slack.messaging",
Expand Down Expand Up @@ -1132,6 +1134,10 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
"task": "seer:sentry.tasks.seer_explorer_index.schedule_explorer_index",
"schedule": task_crontab("0", "*/1", "*", "*", "*"),
},
"seer-code-review-cleanup": {
"task": "seer.code_review:sentry.seer.code_review.tasks.cleanup_old_code_review_events",
"schedule": task_crontab("0", "3", "*", "*", "*"),
},
"refresh-artifact-bundles-in-use": {
"task": "attachments:sentry.debug_files.tasks.refresh_artifact_bundles_in_use",
"schedule": task_crontab("*/1", "*", "*", "*", "*"),
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:preprod-snapshots", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enables PR page
manager.add("organizations:pr-page", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enables PR review dashboard at /explore/pr-review/
manager.add("organizations:pr-review-dashboard", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enables the playstation ingestion in relay
manager.add("organizations:relay-playstation-ingestion", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
# Enables OTLP Trace ingestion in Relay for an entire org.
Expand Down
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from __future__ import annotations

from django.db.models import Avg, Count, F, Q, Sum
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint
from sentry.api.serializers import serialize
from sentry.models.code_review_event import CodeReviewEvent, CodeReviewEventStatus
from sentry.models.organization import Organization
from sentry.models.repository import Repository
from sentry.seer.code_review.api.serializers.code_review_event import CodeReviewEventSerializer


@region_silo_endpoint
class OrganizationCodeReviewPRDetailsEndpoint(OrganizationEndpoint):
owner = ApiOwner.CODING_WORKFLOWS
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}

def get(
self, request: Request, organization: Organization, repo_id: str, pr_number: str
) -> Response:
if not features.has("organizations:pr-review-dashboard", organization, actor=request.user):
return Response(status=404)

repo_id_int = int(repo_id)
pr_number_int = int(pr_number)

events = CodeReviewEvent.objects.filter(
organization_id=organization.id,
repository_id=repo_id_int,
pr_number=pr_number_int,
).order_by("-trigger_at")

if not events.exists():
return Response(status=404)

latest_event = events[0]

try:
repo = Repository.objects.get(id=repo_id_int, organization_id=organization.id)
repo_name = repo.name
except Repository.DoesNotExist:
repo_name = None

summary = events.aggregate(
total_reviews=Count("id", filter=Q(status=CodeReviewEventStatus.REVIEW_COMPLETED)),
total_failed=Count("id", filter=Q(status=CodeReviewEventStatus.REVIEW_FAILED)),
total_skipped=Count(
"id",
filter=Q(
status__in=[
CodeReviewEventStatus.PREFLIGHT_DENIED,
CodeReviewEventStatus.WEBHOOK_FILTERED,
]
),
),
total_comments=Sum("comments_posted"),
avg_review_duration=Avg(
F("review_completed_at") - F("sent_to_seer_at"),
filter=Q(
review_completed_at__isnull=False,
sent_to_seer_at__isnull=False,
),
),
)

avg_duration_ms = None
if summary["avg_review_duration"] is not None:
avg_duration_ms = int(summary["avg_review_duration"].total_seconds() * 1000)

return Response(
{
"repositoryId": str(repo_id_int),
"repositoryName": repo_name,
"prNumber": pr_number_int,
"prTitle": latest_event.pr_title,
"prAuthor": latest_event.pr_author,
"prUrl": latest_event.pr_url,
"prState": latest_event.pr_state,
"events": serialize(list(events), request.user, CodeReviewEventSerializer()),
"summary": {
"totalReviews": summary["total_reviews"],
"totalFailed": summary["total_failed"],
"totalSkipped": summary["total_skipped"],
"totalComments": summary["total_comments"] or 0,
"avgReviewDurationMs": avg_duration_ms,
},
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from __future__ import annotations

from typing import Any

from django.db.models import Count, Max, Q, Sum
from django.db.models.functions import Coalesce
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint
from sentry.api.paginator import OffsetPaginator
from sentry.models.code_review_event import CodeReviewEvent
from sentry.models.organization import Organization
from sentry.models.repository import Repository
from sentry.search.utils import parse_datetime_string


@region_silo_endpoint
class OrganizationCodeReviewPRsEndpoint(OrganizationEndpoint):
owner = ApiOwner.CODING_WORKFLOWS
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}

def get(self, request: Request, organization: Organization) -> Response:
if not features.has("organizations:pr-review-dashboard", organization, actor=request.user):
return Response(status=404)

queryset = CodeReviewEvent.objects.filter(organization_id=organization.id)

repository_ids = request.GET.getlist("repositoryId")
if repository_ids:
queryset = queryset.filter(repository_id__in=repository_ids)

trigger_type = request.GET.get("triggerType")
if trigger_type:
queryset = queryset.filter(trigger=trigger_type)

start_str = request.GET.get("start")
if start_str:
queryset = queryset.filter(trigger_at__gte=parse_datetime_string(start_str))

end_str = request.GET.get("end")
if end_str:
queryset = queryset.filter(trigger_at__lte=parse_datetime_string(end_str))

pr_groups = (
queryset.filter(pr_number__isnull=False)
.values("repository_id", "pr_number")
.annotate(
event_count=Count("id"),
total_comments=Coalesce(Sum("comments_posted"), 0),
last_activity=Max("trigger_at"),
)
.order_by("-last_activity")
)

pr_state = request.GET.get("prState")

return self.paginate(
request=request,
queryset=pr_groups,
order_by="-last_activity",
paginator_cls=OffsetPaginator,
default_per_page=25,
count_hits=True,
on_results=lambda groups: self._enrich_groups(
groups, queryset, organization.id, pr_state
),
)

def _enrich_groups(
self,
groups: list[dict[str, Any]],
base_queryset: Any,
organization_id: int,
pr_state_filter: str | None = None,
) -> list[dict[str, Any]]:
"""Attach latest event metadata (title, author, status) to each PR group."""
if not groups:
return []

repo_ids = {g["repository_id"] for g in groups}
repos = {
r.id: r
for r in Repository.objects.filter(id__in=repo_ids, organization_id=organization_id)
}

# Build a single OR filter for all (repo_id, pr_number) pairs
pr_filter = Q()
for g in groups:
pr_filter |= Q(repository_id=g["repository_id"], pr_number=g["pr_number"])

# Fetch all candidate events in one query, ordered so the latest per PR comes first
candidate_events = base_queryset.filter(pr_filter).order_by(
"repository_id", "pr_number", "-trigger_at"
)

# Pick the latest event per (repo_id, pr_number)
latest_events_by_key: dict[tuple[int, int], CodeReviewEvent] = {}
for event in candidate_events:
key = (event.repository_id, event.pr_number)
if key not in latest_events_by_key:
latest_events_by_key[key] = event

results = []
for group in groups:
repo_id = group["repository_id"]
pr_number = group["pr_number"]
repo = repos.get(repo_id)
latest_event = latest_events_by_key.get((repo_id, pr_number))

# Filter by pr_state from the latest event rather than from all events,
# since pr_state is denormalized and only the latest event reflects current state
if pr_state_filter and (not latest_event or latest_event.pr_state != pr_state_filter):
continue

results.append(
{
"repositoryId": str(repo_id),
"repositoryName": repo.name if repo else None,
"prNumber": pr_number,
"prTitle": latest_event.pr_title if latest_event else None,
"prAuthor": latest_event.pr_author if latest_event else None,
"prUrl": latest_event.pr_url if latest_event else None,
"prState": latest_event.pr_state if latest_event else None,
"latestStatus": latest_event.status if latest_event else None,
"latestTrigger": latest_event.trigger if latest_event else None,
"eventCount": group["event_count"],
"totalComments": group["total_comments"],
"lastActivity": group["last_activity"].isoformat()
if group["last_activity"]
else None,
}
)

return results
Loading
Loading