Skip to content

Commit 973ee02

Browse files
authored
feat: add script to export TA data (#548)
* feat: add script to export TA data * get rid of unnecessary comments * address cursor comments * get rid of enumerate since it's redundant * add grouping logic
1 parent 97c262c commit 973ee02

File tree

3 files changed

+306
-2
lines changed

3 files changed

+306
-2
lines changed

apps/codecov-api/api/sentry/tests/test_views.py

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,19 @@
88
from rest_framework.test import APIClient
99

1010
from codecov_auth.models import Account
11-
from shared.django_apps.codecov_auth.models import GithubAppInstallation, Owner
11+
from shared.django_apps.codecov_auth.models import (
12+
GithubAppInstallation,
13+
Owner,
14+
Service,
15+
)
1216
from shared.django_apps.codecov_auth.tests.factories import (
1317
AccountFactory,
1418
OwnerFactory,
1519
PlanFactory,
1620
TierFactory,
1721
)
22+
from shared.django_apps.core.tests.factories import RepositoryFactory
23+
from shared.django_apps.ta_timeseries.tests.factories import TestrunFactory
1824
from shared.plan.constants import PlanName, TierName
1925

2026

@@ -823,3 +829,229 @@ def test_account_unlink_authentication_failure(self):
823829
)
824830

825831
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
832+
833+
834+
class TestAnalyticsEuViewTests(TestCase):
835+
databases = ["default", "ta_timeseries"]
836+
837+
def setUp(self):
838+
self.client = APIClient()
839+
self.url = reverse("test-analytics-eu")
840+
841+
def _make_authenticated_request(self, data, jwt_payload=None):
842+
"""Helper method to make an authenticated request with JWT payload"""
843+
with patch(
844+
"codecov_auth.permissions.get_sentry_jwt_payload"
845+
) as mock_get_payload:
846+
mock_get_payload.return_value = jwt_payload or {
847+
"g_p": "github",
848+
"g_o": "test-org",
849+
}
850+
return self.client.post(
851+
self.url, data=json.dumps(data), content_type="application/json"
852+
)
853+
854+
def test_test_analytics_eu_empty_integration_names(self):
855+
"""Test that empty integration_names list fails validation"""
856+
data = {"integration_names": []}
857+
858+
response = self._make_authenticated_request(data=data)
859+
860+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
861+
self.assertIn("integration_names", response.data)
862+
863+
def test_test_analytics_eu_missing_integration_names(self):
864+
"""Test that missing integration_names fails validation"""
865+
data = {}
866+
867+
response = self._make_authenticated_request(data=data)
868+
869+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
870+
self.assertIn("integration_names", response.data)
871+
872+
@patch("api.sentry.views.log")
873+
def test_test_analytics_eu_owner_not_found(self, mock_log):
874+
"""Test that non-existent owner is skipped with warning log"""
875+
data = {"integration_names": ["non-existent-org"]}
876+
877+
response = self._make_authenticated_request(data=data)
878+
879+
self.assertEqual(response.status_code, status.HTTP_200_OK)
880+
self.assertEqual(response.data["test_runs_per_integration"], {})
881+
882+
mock_log.warning.assert_called_once()
883+
warning_call = mock_log.warning.call_args[0][0]
884+
self.assertIn("non-existent-org", warning_call)
885+
self.assertIn("not found", warning_call)
886+
887+
def test_test_analytics_eu_owner_without_repositories(self):
888+
"""Test that owner without repositories returns empty dict"""
889+
OwnerFactory(name="org-no-repos", service=Service.GITHUB)
890+
891+
data = {"integration_names": ["org-no-repos"]}
892+
893+
response = self._make_authenticated_request(data=data)
894+
895+
self.assertEqual(response.status_code, status.HTTP_200_OK)
896+
self.assertEqual(
897+
response.data["test_runs_per_integration"], {"org-no-repos": {}}
898+
)
899+
900+
@patch("api.sentry.views.log")
901+
def test_test_analytics_eu_mixed_owners_found_and_not_found(self, mock_log):
902+
"""Test mix of existing and non-existing owners"""
903+
owner = OwnerFactory(name="org-exists", service=Service.GITHUB)
904+
repo = RepositoryFactory(
905+
author=owner, name="test-repo", test_analytics_enabled=True
906+
)
907+
TestrunFactory(
908+
repo_id=repo.repoid,
909+
commit_sha="abc123",
910+
outcome="pass",
911+
name="test_example",
912+
)
913+
914+
data = {"integration_names": ["org-exists", "org-not-exists"]}
915+
916+
response = self._make_authenticated_request(data=data)
917+
918+
self.assertEqual(response.status_code, status.HTTP_200_OK)
919+
self.assertIn("org-exists", response.data["test_runs_per_integration"])
920+
self.assertNotIn("org-not-exists", response.data["test_runs_per_integration"])
921+
922+
# Verify warning log was called for non-existent owner
923+
mock_log.warning.assert_called_once()
924+
warning_call = mock_log.warning.call_args[0][0]
925+
self.assertIn("org-not-exists", warning_call)
926+
927+
def test_test_analytics_eu_filters_by_test_analytics_enabled(self):
928+
"""Test that only repositories with test_analytics_enabled=True are included"""
929+
owner = OwnerFactory(name="org-with-repos", service=Service.GITHUB)
930+
931+
repo_enabled = RepositoryFactory(
932+
author=owner,
933+
name="repo-enabled",
934+
test_analytics_enabled=True,
935+
)
936+
TestrunFactory(
937+
repo_id=repo_enabled.repoid,
938+
commit_sha="abc123",
939+
outcome="pass",
940+
name="test_enabled",
941+
)
942+
943+
repo_disabled = RepositoryFactory(
944+
author=owner,
945+
name="repo-disabled",
946+
test_analytics_enabled=False,
947+
)
948+
949+
data = {"integration_names": ["org-with-repos"]}
950+
951+
response = self._make_authenticated_request(data=data)
952+
953+
self.assertEqual(response.status_code, status.HTTP_200_OK)
954+
test_runs_data = response.data["test_runs_per_integration"]["org-with-repos"]
955+
956+
# Only repo-enabled should be in the response
957+
self.assertIn("repo-enabled", test_runs_data)
958+
self.assertNotIn("repo-disabled", test_runs_data)
959+
960+
test_runs_list = test_runs_data["repo-enabled"]
961+
self.assertEqual(len(test_runs_list), 1)
962+
self.assertEqual(test_runs_list[0]["name"], "test_enabled")
963+
964+
def test_test_analytics_eu_multiple_owners_with_multiple_repos_and_testruns(self):
965+
"""Test complex scenario with 2 owners, different repositories and test runs"""
966+
owner1 = OwnerFactory(name="org-one", service=Service.GITHUB)
967+
repo1 = RepositoryFactory(
968+
author=owner1,
969+
name="repo-one",
970+
test_analytics_enabled=True,
971+
)
972+
TestrunFactory(
973+
repo_id=repo1.repoid,
974+
commit_sha="commit1",
975+
outcome="pass",
976+
name="test_one_first",
977+
classname="TestClass1",
978+
)
979+
TestrunFactory(
980+
repo_id=repo1.repoid,
981+
commit_sha="commit1",
982+
outcome="failure",
983+
name="test_one_second",
984+
classname="TestClass2",
985+
)
986+
987+
owner2 = OwnerFactory(name="org-two", service=Service.GITHUB)
988+
repo2_1 = RepositoryFactory(
989+
author=owner2,
990+
name="repo-two-first",
991+
test_analytics_enabled=True,
992+
)
993+
TestrunFactory(
994+
repo_id=repo2_1.repoid,
995+
commit_sha="commit2",
996+
outcome="pass",
997+
name="test_two_first",
998+
classname="TestClassA",
999+
)
1000+
1001+
repo2_2 = RepositoryFactory(
1002+
author=owner2,
1003+
name="repo-two-second",
1004+
test_analytics_enabled=True,
1005+
)
1006+
TestrunFactory(
1007+
repo_id=repo2_2.repoid,
1008+
commit_sha="commit3",
1009+
outcome="skip",
1010+
name="test_two_second",
1011+
classname="TestClassB",
1012+
)
1013+
1014+
data = {"integration_names": ["org-one", "org-two"]}
1015+
1016+
response = self._make_authenticated_request(data=data)
1017+
1018+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1019+
test_runs_per_integration = response.data["test_runs_per_integration"]
1020+
1021+
# Verify org-one data
1022+
self.assertIn("org-one", test_runs_per_integration)
1023+
org_one_data = test_runs_per_integration["org-one"]
1024+
self.assertIn("repo-one", org_one_data)
1025+
self.assertEqual(len(org_one_data), 1)
1026+
1027+
repo_one_testruns = org_one_data["repo-one"]
1028+
self.assertEqual(len(repo_one_testruns), 2)
1029+
testrun_names = [tr["name"] for tr in repo_one_testruns]
1030+
self.assertIn("test_one_first", testrun_names)
1031+
self.assertIn("test_one_second", testrun_names)
1032+
1033+
self.assertIn("org-two", test_runs_per_integration)
1034+
org_two_data = test_runs_per_integration["org-two"]
1035+
self.assertIn("repo-two-first", org_two_data)
1036+
self.assertIn("repo-two-second", org_two_data)
1037+
self.assertEqual(len(org_two_data), 2)
1038+
1039+
repo_two_first_testruns = org_two_data["repo-two-first"]
1040+
self.assertEqual(len(repo_two_first_testruns), 1)
1041+
self.assertEqual(repo_two_first_testruns[0]["name"], "test_two_first")
1042+
self.assertEqual(repo_two_first_testruns[0]["outcome"], "pass")
1043+
1044+
repo_two_second_testruns = org_two_data["repo-two-second"]
1045+
self.assertEqual(len(repo_two_second_testruns), 1)
1046+
self.assertEqual(repo_two_second_testruns[0]["name"], "test_two_second")
1047+
self.assertEqual(repo_two_second_testruns[0]["outcome"], "skip")
1048+
1049+
def test_test_analytics_eu_authentication_failure(self):
1050+
"""Test that the endpoint requires authentication"""
1051+
data = {"integration_names": ["test-org"]}
1052+
1053+
response = self.client.post(
1054+
self.url, data=json.dumps(data), content_type="application/json"
1055+
)
1056+
1057+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from django.urls import path
22

3-
from .views import account_link, account_unlink
3+
from .views import account_link, account_unlink, test_analytics_eu
44

55
urlpatterns = [
66
path("internal/account/link/", account_link, name="account-link"),
77
path("internal/account/unlink/", account_unlink, name="account-unlink"),
8+
path("internal/test-analytics/eu/", test_analytics_eu, name="test-analytics-eu"),
89
]

apps/codecov-api/api/sentry/views.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import logging
2+
from itertools import groupby
23

34
import sentry_sdk
45
from django.conf import settings
6+
from django.views.decorators.gzip import gzip_page
57
from rest_framework import serializers, status
68
from rest_framework.decorators import (
79
api_view,
@@ -10,6 +12,7 @@
1012
)
1113
from rest_framework.response import Response
1214

15+
from api.public.v2.test_results.serializers import TestrunSerializer
1316
from codecov_auth.models import Account
1417
from codecov_auth.permissions import JWTAuthenticationPermission
1518
from shared.django_apps.codecov_auth.models import (
@@ -18,6 +21,8 @@
1821
Plan,
1922
Service,
2023
)
24+
from shared.django_apps.core.models import Repository
25+
from shared.django_apps.ta_timeseries.models import Testrun
2126
from shared.plan.constants import PlanName
2227

2328
log = logging.getLogger(__name__)
@@ -243,3 +248,69 @@ def account_unlink(request, *args, **kwargs):
243248
"total_requested": total_requested,
244249
}
245250
)
251+
252+
253+
class SentryTestAnalyticsEuSerializer(serializers.Serializer):
254+
"""Serializer for test analytics EU endpoint"""
255+
256+
integration_names = serializers.ListField(
257+
child=serializers.CharField(),
258+
help_text="The Sentry integration names",
259+
min_length=1,
260+
)
261+
262+
263+
@gzip_page
264+
@api_view(["POST"])
265+
@authentication_classes([])
266+
@permission_classes([JWTAuthenticationPermission])
267+
def test_analytics_eu(request, *args, **kwargs):
268+
serializer = SentryTestAnalyticsEuSerializer(data=request.data)
269+
serializer.is_valid(raise_exception=True)
270+
271+
integration_names = serializer.validated_data["integration_names"]
272+
273+
# For every integration name, determine if an Owner record exist by filtering by name and service=github
274+
test_runs_per_integration = {}
275+
for name in integration_names:
276+
try:
277+
owner = Owner.objects.get(name=name, service=Service.GITHUB)
278+
except Owner.DoesNotExist:
279+
log.warning(
280+
f"Owner with name {name} and service {Service.GITHUB} not found"
281+
)
282+
continue
283+
284+
# Only fetch name and repoid fields
285+
repo_id_to_name = dict(
286+
Repository.objects.filter(
287+
author=owner, test_analytics_enabled=True
288+
).values_list("repoid", "name")
289+
)
290+
291+
if not repo_id_to_name:
292+
test_runs_per_integration[name] = {}
293+
continue
294+
295+
# Fetch all test runs for all repositories in a single query
296+
test_runs = Testrun.objects.filter(repo_id__in=repo_id_to_name.keys()).order_by(
297+
"repo_id", "-timestamp"
298+
)
299+
300+
# Group by repo_id (data is already ordered by repo_id) and serialize each group
301+
test_runs_per_repository = {}
302+
for repo_id, group in groupby(test_runs, key=lambda tr: tr.repo_id):
303+
repo_name = repo_id_to_name[repo_id] # Safe: we only fetch these repo_ids
304+
test_runs_list = list(group)
305+
test_runs_per_repository[repo_name] = TestrunSerializer(
306+
test_runs_list, many=True
307+
).data
308+
309+
# Store each test_runs_per_repository in a dictionary
310+
test_runs_per_integration[name] = test_runs_per_repository
311+
312+
return Response(
313+
{
314+
"test_runs_per_integration": test_runs_per_integration,
315+
}
316+
)

0 commit comments

Comments
 (0)