Skip to content

Commit

Permalink
pull request file issues API
Browse files Browse the repository at this point in the history
  • Loading branch information
cathteng committed Jul 16, 2024
1 parent 028e402 commit 129178f
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 9 deletions.
95 changes: 95 additions & 0 deletions src/sentry/api/endpoints/organization_pull_request_file_issues.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

from rest_framework import serializers, status
from rest_framework.request import Request
from rest_framework.response import Response

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.models.group import Group
from sentry.models.organization import Organization
from sentry.snuba.referrer import Referrer
from sentry.tasks.integrations.github.language_parsers import PATCH_PARSERS
from sentry.tasks.integrations.github.open_pr_comment import (
get_projects_and_filenames_from_source_file,
get_top_5_issues_by_count_for_file,
)


class PullRequestFileSerializer(serializers.Serializer):
filename = serializers.CharField(required=True)
repo = serializers.CharField(required=True)
patch = serializers.CharField(required=True)

def validate_filename(self, value):
if not value:
raise serializers.ValidationError("Filename is required")

file_extension = value.split(".")[-1]
language_parser = PATCH_PARSERS.get(file_extension, None)
if not language_parser:
raise serializers.ValidationError("Unsupported file type")

return value


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

def post(self, request: Request, organization: Organization) -> Response:
serializer = PullRequestFileSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

filename = serializer.validated_data["filename"]
repo_name = serializer.validated_data["repo"]
patch = serializer.validated_data["patch"]

projects, sentry_filenames = get_projects_and_filenames_from_source_file(
org_id=organization.id, repo_name=repo_name, pr_filename=filename
)

if not len(projects) or not len(sentry_filenames):
return Response([])

file_extension = filename.split(".")[-1]
language_parser = PATCH_PARSERS[file_extension]

function_names = language_parser.extract_functions_from_patch(patch)

if not len(function_names):
return Response([])

top_issues = get_top_5_issues_by_count_for_file(
list(projects), list(sentry_filenames), list(function_names)
)

group_id_to_info = {}
for issue in top_issues:
group_id = issue["group_id"]
group_id_to_info[group_id] = dict(filter(lambda k: k[0] != "group_id", issue.items()))

issues = Group.objects.filter(id__in=list(group_id_to_info.keys())).all()

pr_file_issues = [
{
"title": issue.title,
"culprit": issue.culprit,
"url": issue.get_absolute_url(),
"affected_users": issue.count_users_seen(
referrer=Referrer.TAGSTORE_GET_GROUPS_USER_COUNTS_OPEN_PR_COMMENT.value
),
"event_count": group_id_to_info[issue.id]["event_count"],
"function_name": group_id_to_info[issue.id]["function_name"],
}
for issue in issues
]
pr_file_issues.sort(key=lambda k: k.get("event_count", 0), reverse=True)

return Response(pr_file_issues)
8 changes: 8 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
from sentry.api.endpoints.organization_projects_experiment import (
OrganizationProjectsExperimentEndpoint,
)
from sentry.api.endpoints.organization_pull_request_file_issues import (
OrganizationPullRequestFilesIssuesEndpoint,
)
from sentry.api.endpoints.organization_spans_aggregation import OrganizationSpansAggregationEndpoint
from sentry.api.endpoints.organization_stats_summary import OrganizationStatsSummaryEndpoint
from sentry.api.endpoints.organization_unsubscribe import (
Expand Down Expand Up @@ -1827,6 +1830,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationPluginsConfigsEndpoint.as_view(),
name="sentry-api-0-organization-plugins-configs",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/pr-file-issues/$",
OrganizationPullRequestFilesIssuesEndpoint.as_view(),
name="sentry-api-0-organization-pr-file-issues",
),
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/releases/$",
OrganizationReleasesEndpoint.as_view(),
Expand Down
32 changes: 23 additions & 9 deletions src/sentry/tasks/integrations/github/open_pr_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,18 +241,32 @@ def get_pr_files(pr_files: list[dict[str, str]]) -> list[PullRequestFile]:

def get_projects_and_filenames_from_source_file(
org_id: int,
repo_id: int,
pr_filename: str,
repo_id: int | None = None,
repo_name: str | None = None,
) -> tuple[set[Project], set[str]]:
# fetch the code mappings in which the source_root is a substring at the start of pr_filename
code_mappings = (
RepositoryProjectPathConfig.objects.filter(
organization_id=org_id,
repository_id=repo_id,
if not repo_id and not repo_name:
raise ValueError("repository ID or repository name are required")

if repo_id:
code_mappings = (
RepositoryProjectPathConfig.objects.filter(
organization_id=org_id,
repository_id=repo_id,
)
.annotate(substring_match=StrIndex(Value(pr_filename), "source_root"))
.filter(substring_match=1)
)
else:
code_mappings = (
RepositoryProjectPathConfig.objects.filter(
organization_id=org_id,
repository__name=repo_name,
)
.annotate(substring_match=StrIndex(Value(pr_filename), "source_root"))
.filter(substring_match=1)
)
.annotate(substring_match=StrIndex(Value(pr_filename), "source_root"))
.filter(substring_match=1)
)

project_list: set[Project] = set()
sentry_filenames = set()
Expand Down Expand Up @@ -476,7 +490,7 @@ def open_pr_comment_workflow(pr_id: int) -> None:
# fetch issues related to the files
for file in pullrequest_files:
projects, sentry_filenames = get_projects_and_filenames_from_source_file(
org_id, repo.id, file.filename
org_id=org_id, repo_id=repo.id, pr_filename=file.filename
)
if not len(projects) or not len(sentry_filenames):
continue
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from unittest.mock import patch

from sentry.models.group import Group
from sentry.testutils.cases import APITestCase
from tests.sentry.tasks.integrations.github.test_open_pr_comment import CreateEventTestCase


@patch(
"sentry.api.endpoints.organization_pull_request_file_issues.get_projects_and_filenames_from_source_file"
)
class OrganizationPullRequestFileIssuesTest(APITestCase, CreateEventTestCase):
endpoint = "sentry-api-0-organization-pr-file-issues"
method = "post"

def setUp(self):
super().setUp()
self.user_id = "user_1"
self.app_id = "app_1"

self.group_id_1 = [
self._create_event(
culprit="issue1",
user_id=str(i),
filenames=["bar.py", "foo.py"],
function_names=["planet", "blue"],
)
for i in range(5)
][0].group.id
self.group_id_2 = [
self._create_event(
culprit="issue2",
filenames=["foo.py", "bar.py"],
function_names=["blue", "planet"],
user_id=str(i),
)
for i in range(6)
][0].group.id

self.gh_repo = self.create_repo(
name="getsentry/sentry",
provider="integrations:github",
integration_id=self.integration.id,
project=self.project,
url="https://github.com/getsentry/sentry",
)
self.groups = [
{
"group_id": g.id,
"event_count": 1000 * (i + 1),
"function_name": "function_" + str(i),
}
for i, g in enumerate(Group.objects.all())
]
self.groups.reverse()

self.login_as(self.user)

def test_simple(self, mock_reverse_codemappings):
mock_reverse_codemappings.return_value = ([self.project], ["foo.py"])

patch = """@@ -36,6 +36,7 @@\n def blue(self):"""
response = self.get_success_response(
self.organization.slug,
**{"filename": "foo.py", "repo": self.gh_repo.name, "patch": patch},
)
group_urls = [g["url"] for g in response.data]
assert group_urls == [
f"http://testserver/organizations/{self.organization.slug}/issues/{self.group_id_2}/",
f"http://testserver/organizations/{self.organization.slug}/issues/{self.group_id_1}/",
]

def test_no_codemappings(self, mock_reverse_codemappings):
mock_reverse_codemappings.return_value = ([], [])

patch = """@@ -36,6 +36,7 @@\n def blue(self):"""
response = self.get_success_response(
self.organization.slug,
**{"filename": "foo.py", "repo": self.gh_repo.name, "patch": patch},
)
assert response.data == []

def test_no_functions(self, mock_reverse_codemappings):
mock_reverse_codemappings.return_value = ([self.project], ["foo.py"])

patch = """@@ -36,6 +36,7 @@\n import pytest"""
response = self.get_success_response(
self.organization.slug,
**{"filename": "foo.py", "repo": self.gh_repo.name, "patch": patch},
)
assert response.data == []

def test_no_issues(self, mock_reverse_codemappings):
mock_reverse_codemappings.return_value = ([self.project], ["foo.py"])

patch = """@@ -36,6 +36,7 @@\n def purple(self):"""
response = self.get_success_response(
self.organization.slug,
**{"filename": "bar.py", "repo": self.gh_repo.name, "patch": patch},
)
assert response.data == []

0 comments on commit 129178f

Please sign in to comment.