Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit b7b6cbc

Browse files
[feat] Add patch totals to the /pulls endpoint (#1108)
1 parent e4924ba commit b7b6cbc

File tree

4 files changed

+183
-19
lines changed

4 files changed

+183
-19
lines changed

api/public/v2/pull/serializers.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
from typing import Dict, Optional
2+
13
from rest_framework import serializers
24

35
from api.public.v2.owner.serializers import OwnerSerializer
4-
from api.shared.commit.serializers import CommitTotalsSerializer
6+
from api.shared.commit.serializers import (
7+
CommitTotalsSerializer,
8+
PatchCoverageSerializer,
9+
)
510
from core.models import Pull, PullStates
11+
from services.comparison import CommitComparisonService, ComparisonReport
612

713

814
class PullSerializer(serializers.ModelSerializer):
@@ -18,6 +24,7 @@ class PullSerializer(serializers.ModelSerializer):
1824
label="indicates whether the CI process passed for the head commit of this pull"
1925
)
2026
author = OwnerSerializer(label="pull author")
27+
patch = serializers.SerializerMethodField()
2128

2229
class Meta:
2330
model = Pull
@@ -30,5 +37,30 @@ class Meta:
3037
"state",
3138
"ci_passed",
3239
"author",
40+
"patch",
3341
)
3442
fields = read_only_fields
43+
44+
def get_patch(self, obj: Pull) -> Optional[Dict[str, float]]:
45+
commit_comparison = CommitComparisonService.get_commit_comparison_for_pull(obj)
46+
if not commit_comparison or not commit_comparison.is_processed:
47+
return None
48+
cr = ComparisonReport(commit_comparison)
49+
hits = misses = partials = 0
50+
for f in cr.impacted_files:
51+
pc = f.patch_coverage
52+
if pc:
53+
hits += pc.hits
54+
misses += pc.misses
55+
partials += pc.partials
56+
total_branches = hits + misses + partials
57+
coverage = 0.0
58+
if total_branches != 0:
59+
coverage = round(100 * hits / total_branches, 2)
60+
data = dict(
61+
hits=hits,
62+
misses=misses,
63+
partials=partials,
64+
coverage=coverage,
65+
)
66+
return PatchCoverageSerializer(data).data

api/public/v2/tests/test_api_pull_viewset.py

Lines changed: 130 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import patch
1+
from unittest.mock import MagicMock, patch
22

33
from django.test import override_settings
44
from django.urls import reverse
@@ -20,7 +20,8 @@ def setUp(self):
2020
self.org = OwnerFactory()
2121
self.repo = RepositoryFactory(author=self.org)
2222
self.current_owner = OwnerFactory(
23-
permission=[self.repo.repoid], organizations=[self.org.ownerid]
23+
permission=[self.repo.repoid],
24+
organizations=[self.org.ownerid],
2425
)
2526
self.pulls = [
2627
PullFactory(repository=self.repo),
@@ -29,11 +30,13 @@ def setUp(self):
2930
Pull.objects.filter(pk=self.pulls[1].pk).update(
3031
updatestamp="2023-01-01T00:00:00"
3132
)
32-
3333
self.client = APIClient()
3434
self.client.force_login_owner(self.current_owner)
35+
self.no_patch_response = dict(hits=0, misses=0, partials=0, coverage=0.0)
3536

36-
def test_list(self):
37+
@patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
38+
def test_list(self, mock_patch):
39+
mock_patch.return_value = self.no_patch_response
3740
res = self.client.get(
3841
reverse(
3942
"api-v2-pulls-list",
@@ -59,6 +62,7 @@ def test_list(self):
5962
"state": "open",
6063
"ci_passed": None,
6164
"author": None,
65+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
6266
},
6367
{
6468
"pullid": self.pulls[0].pullid,
@@ -69,12 +73,15 @@ def test_list(self):
6973
"state": "open",
7074
"ci_passed": None,
7175
"author": None,
76+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
7277
},
7378
],
7479
"total_pages": 1,
7580
}
7681

77-
def test_list_state(self):
82+
@patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
83+
def test_list_state(self, mock_patch):
84+
mock_patch.return_value = self.no_patch_response
7885
pull = PullFactory(repository=self.repo, state="closed")
7986
url = reverse(
8087
"api-v2-pulls-list",
@@ -100,12 +107,15 @@ def test_list_state(self):
100107
"state": "closed",
101108
"ci_passed": None,
102109
"author": None,
103-
},
110+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
111+
}
104112
],
105113
"total_pages": 1,
106114
}
107115

108-
def test_list_start_date(self):
116+
@patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
117+
def test_list_start_date(self, mock_patch):
118+
mock_patch.return_value = self.no_patch_response
109119
url = reverse(
110120
"api-v2-pulls-list",
111121
kwargs={
@@ -130,12 +140,15 @@ def test_list_start_date(self):
130140
"state": "open",
131141
"ci_passed": None,
132142
"author": None,
133-
},
143+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
144+
}
134145
],
135146
"total_pages": 1,
136147
}
137148

138-
def test_list_cursor_pagination(self):
149+
@patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
150+
def test_list_cursor_pagination(self, mock_patch):
151+
mock_patch.return_value = self.no_patch_response
139152
url = reverse(
140153
"api-v2-pulls-list",
141154
kwargs={
@@ -157,7 +170,8 @@ def test_list_cursor_pagination(self):
157170
"state": "open",
158171
"ci_passed": None,
159172
"author": None,
160-
},
173+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
174+
}
161175
]
162176
assert data["previous"] is None
163177
assert data["next"] is not None
@@ -174,15 +188,17 @@ def test_list_cursor_pagination(self):
174188
"state": "open",
175189
"ci_passed": None,
176190
"author": None,
177-
},
191+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
192+
}
178193
]
179194
assert data["previous"] is not None
180195
assert data["next"] is None
181196

197+
@patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
182198
@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
183-
def test_retrieve(self, get_repo_permissions):
199+
def test_retrieve(self, get_repo_permissions, mock_patch):
200+
mock_patch.return_value = self.no_patch_response
184201
get_repo_permissions.return_value = (True, True)
185-
186202
res = self.client.get(
187203
reverse(
188204
"api-v2-pulls-detail",
@@ -204,6 +220,7 @@ def test_retrieve(self, get_repo_permissions):
204220
"state": "open",
205221
"ci_passed": None,
206222
"author": None,
223+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
207224
}
208225

209226
@patch("api.shared.permissions.RepositoryArtifactPermissions.has_permission")
@@ -215,7 +232,6 @@ def test_no_pull_if_unauthenticated_token_request(
215232
):
216233
repository_artifact_permissions_has_permission.return_value = False
217234
super_token_permissions_has_permission.return_value = False
218-
219235
res = self.client.get(
220236
reverse(
221237
"api-v2-pulls-detail",
@@ -238,7 +254,6 @@ def test_no_pull_if_not_super_token_nor_user_token(
238254
self, repository_artifact_permissions_has_permission
239255
):
240256
repository_artifact_permissions_has_permission.return_value = False
241-
242257
res = self.client.get(
243258
reverse(
244259
"api-v2-pulls-detail",
@@ -278,7 +293,9 @@ def test_no_pull_if_super_token_but_no_GET_request(
278293
)
279294

280295
@override_settings(SUPER_API_TOKEN="testaxs3o76rdcdpfzexuccx3uatui2nw73r")
281-
def test_pull_with_valid_super_token(self):
296+
@patch("api.public.v2.pull.serializers.PullSerializer.get_patch")
297+
def test_pull_with_valid_super_token(self, mock_patch):
298+
mock_patch.return_value = self.no_patch_response
282299
res = self.client.get(
283300
reverse(
284301
"api-v2-pulls-detail",
@@ -301,4 +318,101 @@ def test_pull_with_valid_super_token(self):
301318
"state": "open",
302319
"ci_passed": None,
303320
"author": None,
321+
"patch": {"hits": 0, "misses": 0, "partials": 0, "coverage": 0.0},
304322
}
323+
324+
@patch("api.public.v2.pull.serializers.ComparisonReport")
325+
@patch("services.comparison.CommitComparison.objects.filter")
326+
def test_retrieve_with_patch_coverage(self, mock_cc_filter, mock_comparison_report):
327+
mock_cc_instance = MagicMock(is_processed=True)
328+
mock_cc_filter.return_value.select_related.return_value.first.return_value = (
329+
mock_cc_instance
330+
)
331+
332+
mock_file = MagicMock()
333+
mock_file.patch_coverage.hits = 10
334+
mock_file.patch_coverage.misses = 5
335+
mock_file.patch_coverage.partials = 2
336+
mock_comparison_report.return_value.impacted_files = [mock_file]
337+
338+
res = self.client.get(
339+
reverse(
340+
"api-v2-pulls-detail",
341+
kwargs={
342+
"service": self.org.service,
343+
"owner_username": self.org.username,
344+
"repo_name": self.repo.name,
345+
"pullid": self.pulls[0].pullid,
346+
},
347+
)
348+
)
349+
assert res.status_code == 200
350+
data = res.json()
351+
assert data["patch"] == {
352+
"hits": 10,
353+
"misses": 5,
354+
"partials": 2,
355+
"coverage": 58.82,
356+
}
357+
358+
@patch("api.public.v2.pull.serializers.ComparisonReport")
359+
@patch("services.comparison.CommitComparison.objects.filter")
360+
def test_retrieve_with_patch_coverage_no_branches(
361+
self, mock_cc_filter, mock_comparison_report
362+
):
363+
mock_cc_instance = MagicMock(is_processed=True)
364+
mock_cc_filter.return_value.select_related.return_value.first.return_value = (
365+
mock_cc_instance
366+
)
367+
368+
mock_file = MagicMock()
369+
mock_file.patch_coverage.hits = 0
370+
mock_file.patch_coverage.misses = 0
371+
mock_file.patch_coverage.partials = 0
372+
mock_comparison_report.return_value.impacted_files = [mock_file]
373+
374+
res = self.client.get(
375+
reverse(
376+
"api-v2-pulls-detail",
377+
kwargs={
378+
"service": self.org.service,
379+
"owner_username": self.org.username,
380+
"repo_name": self.repo.name,
381+
"pullid": self.pulls[0].pullid,
382+
},
383+
)
384+
)
385+
assert res.status_code == 200
386+
data = res.json()
387+
assert data["patch"] == self.no_patch_response
388+
389+
@patch("api.public.v2.pull.serializers.ComparisonReport")
390+
@patch("services.comparison.CommitComparison.objects.filter")
391+
def test_retrieve_with_patch_coverage_no_commit_comparison(
392+
self, mock_cc_filter, mock_comparison_report
393+
):
394+
mock_cc_instance = MagicMock(is_processed=False)
395+
mock_cc_filter.return_value.select_related.return_value.first.return_value = (
396+
mock_cc_instance
397+
)
398+
399+
mock_file = MagicMock()
400+
mock_file.patch_coverage.hits = 0
401+
mock_file.patch_coverage.misses = 0
402+
mock_file.patch_coverage.partials = 0
403+
mock_comparison_report.return_value.impacted_files = [mock_file]
404+
405+
res = self.client.get(
406+
reverse(
407+
"api-v2-pulls-detail",
408+
kwargs={
409+
"service": self.org.service,
410+
"owner_username": self.org.username,
411+
"repo_name": self.repo.name,
412+
"pullid": self.pulls[0].pullid,
413+
},
414+
)
415+
)
416+
assert res.status_code == 200
417+
data = res.json()
418+
assert data["patch"] is None

api/shared/commit/serializers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ def get_coverage(self, totals) -> float:
2121
return 0
2222

2323

24+
class PatchCoverageSerializer(serializers.Serializer):
25+
hits = serializers.IntegerField()
26+
misses = serializers.IntegerField()
27+
partials = serializers.IntegerField()
28+
coverage = serializers.FloatField()
29+
30+
2431
class CommitTotalsSerializer(BaseTotalsSerializer):
2532
files = serializers.IntegerField(source="f")
2633
lines = serializers.IntegerField(source="n")

services/comparison.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from shared.utils.merge import LineType, line_type
2121

2222
from compare.models import CommitComparison
23-
from core.models import Commit
23+
from core.models import Commit, Pull
2424
from reports.models import CommitReport
2525
from services import ServiceException
2626
from services.redis_configuration import get_redis_connection
@@ -1186,7 +1186,8 @@ def update_base_report_with_pseudo_diff(self):
11861186
class CommitComparisonService:
11871187
"""
11881188
Utilities for determining whether a commit comparison needs to be recomputed
1189-
(and enqueueing that recompute when necessary)
1189+
(and enqueueing that recompute when necessary), and fetching associated comparisons
1190+
for pulls
11901191
"""
11911192

11921193
def __init__(self, commit_comparison: CommitComparison):
@@ -1246,6 +1247,16 @@ def _load_commit(self, commit_id: int) -> Optional[Commit]:
12461247
.first()
12471248
)
12481249

1250+
@staticmethod
1251+
def get_commit_comparison_for_pull(obj: Pull) -> Optional[CommitComparison]:
1252+
comparison_qs = CommitComparison.objects.filter(
1253+
base_commit__commitid=obj.compared_to,
1254+
compare_commit__commitid=obj.head,
1255+
base_commit__repository_id=obj.repository_id,
1256+
compare_commit__repository_id=obj.repository_id,
1257+
).select_related("compare_commit", "base_commit")
1258+
return comparison_qs.first()
1259+
12491260
@classmethod
12501261
def fetch_precomputed(self, repo_id: int, keys: List[Tuple]) -> QuerySet:
12511262
comparison_table = CommitComparison._meta.db_table

0 commit comments

Comments
 (0)