From 174f3ce2926d01fc818d5aa47884a1c841f11cb8 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Thu, 23 Jul 2026 17:25:38 -0300 Subject: [PATCH] feat: add boot and test commit comparison endpoints * Add filtered comparison queries for two commits * Group results by path, configuration, and platform * Add response models, routes, unit tests, and E2E configuration updates Closes #2021 Signed-off-by: Alan Peixinho --- backend/kernelCI_app/helpers/treeCompare.py | 154 ++++++++++++++++ backend/kernelCI_app/queries/tree.py | 104 +++++++++++ .../unitTests/helpers/treeCompare_test.py | 165 ++++++++++++++++++ .../views/treeDetailsCompareView_test.py | 91 ++++++++++ .../kernelCI_app/typeModels/treeDetails.py | 23 ++- backend/kernelCI_app/urls.py | 10 ++ .../views/treeDetailsCompareView.py | 115 ++++++++++++ 7 files changed, 660 insertions(+), 2 deletions(-) create mode 100644 backend/kernelCI_app/helpers/treeCompare.py create mode 100644 backend/kernelCI_app/tests/unitTests/helpers/treeCompare_test.py create mode 100644 backend/kernelCI_app/tests/unitTests/views/treeDetailsCompareView_test.py create mode 100644 backend/kernelCI_app/views/treeDetailsCompareView.py diff --git a/backend/kernelCI_app/helpers/treeCompare.py b/backend/kernelCI_app/helpers/treeCompare.py new file mode 100644 index 000000000..0da3444f1 --- /dev/null +++ b/backend/kernelCI_app/helpers/treeCompare.py @@ -0,0 +1,154 @@ +from typing import Literal, Optional + +from kernelCI_app.constants.general import UNKNOWN_STRING +from kernelCI_app.helpers.filters import FilterParams, is_filtered_out +from kernelCI_app.helpers.issueExtras import parse_issue +from kernelCI_app.typeModels.treeDetails import ( + GroupedCompareStatus, + TreeCompareTest, +) + +CompareKey = tuple[str, str, str] +STATUS_RANK: dict[GroupedCompareStatus, int] = { + "PASS": 0, + "INCONCLUSIVE": 1, + "FAIL": 2, +} + + +def group_raw_status(status: Optional[str]) -> GroupedCompareStatus: + if status == "PASS": + return "PASS" + if status == "FAIL": + return "FAIL" + return "INCONCLUSIVE" + + +def worst_status( + current: Optional[GroupedCompareStatus], + candidate: GroupedCompareStatus, +) -> GroupedCompareStatus: + if current is None: + return candidate + if STATUS_RANK[candidate] > STATUS_RANK[current]: + return candidate + return current + + +def is_compare_row_filtered_out( + *, + filters: FilterParams, + data_type: Literal["boots", "tests"], + path: str, + status: Optional[str], + config: str, + lab: str, + compiler: str, + architecture: str, + platform: str, + origin: Optional[str], + compatibles: set[str], + known_issues: set[tuple[str, Optional[int]]], +) -> bool: + tab: Literal["boot", "test"] = "boot" if data_type == "boots" else "test" + status_filter = ( + filters.filterBootStatus if tab == "boot" else filters.filterTestStatus + ) + path_filter = filters.filterBootPath if tab == "boot" else filters.filterTestPath + origin_filter = ( + filters.filter_boot_origin if tab == "boot" else filters.filter_test_origin + ) + issue_filter = filters.filterIssues[tab] + + return bool( + is_filtered_out(status or "NULL", status_filter) + or (path_filter and path_filter not in path) + or is_filtered_out(platform, filters.filterPlatforms[tab]) + or is_filtered_out(origin or UNKNOWN_STRING, origin_filter) + or is_filtered_out(compiler, filters.filterCompiler) + or is_filtered_out(config, filters.filterConfigs) + or is_filtered_out(lab, filters.filter_labs) + or is_filtered_out(architecture, filters.filterArchitecture) + or (filters.filterHardware and filters.filterHardware.isdisjoint(compatibles)) + or (issue_filter and not known_issues.issubset(issue_filter)) + ) + + +def collapse_side_statuses( + *, + rows: list[dict], + commit_hash: str, + filters: FilterParams, + data_type: Literal["boots", "tests"], +) -> dict[CompareKey, GroupedCompareStatus]: + """Filter one commit's rows and collapse to worst status per identity key. + + Duration filters are applied in SQL. Issue matching follows the + hardwareDetailsSummary subset pattern on aggregated known_issues. + """ + result: dict[CompareKey, GroupedCompareStatus] = {} + + for instance in rows: + if instance["git_commit_hash"] != commit_hash: + continue + + path = instance["path"] or UNKNOWN_STRING + config = instance["config_name"] or UNKNOWN_STRING + platform = instance["platform"] or UNKNOWN_STRING + lab = instance["lab"] or UNKNOWN_STRING + status = instance["status"] + compatibles = set(instance["environment_compatible"] or []) + (compiler, architecture) = [ + (val or UNKNOWN_STRING).strip(" []'") + for val in (instance["compiler_arch"] or [None, None]) + ] + known_issues = { + parse_issue(issue) for issue in (instance["known_issues"] or []) + } + + if is_compare_row_filtered_out( + filters=filters, + data_type=data_type, + path=path, + status=status, + config=config, + lab=lab, + compiler=compiler, + architecture=architecture, + platform=platform, + origin=instance["origin"], + compatibles=compatibles, + known_issues=known_issues, + ): + continue + + key: CompareKey = (path, config, platform) + result[key] = worst_status(result.get(key), group_raw_status(status)) + + return result + + +def build_compare_rows( + status_a: dict[CompareKey, GroupedCompareStatus], + status_b: dict[CompareKey, GroupedCompareStatus], +) -> list[TreeCompareTest]: + """Return rows where grouped status differs, including one-sided nulls.""" + keys = set(status_a) | set(status_b) + rows: list[TreeCompareTest] = [] + + for path, config_name, platform in sorted(keys): + a = status_a.get((path, config_name, platform)) + b = status_b.get((path, config_name, platform)) + if a == b: + continue + rows.append( + TreeCompareTest( + path=path, + config_name=config_name, + platform=platform, + status_a=a, + status_b=b, + ) + ) + + return rows diff --git a/backend/kernelCI_app/queries/tree.py b/backend/kernelCI_app/queries/tree.py index 52f755a1f..1bb8f98e0 100644 --- a/backend/kernelCI_app/queries/tree.py +++ b/backend/kernelCI_app/queries/tree.py @@ -977,6 +977,110 @@ def get_tree_commit_history_hashes_aggregated( return rows +def get_tree_compare_data( + *, + data_type: Literal["boots", "tests"], + origin: str, + git_branch: str, + tree_name: str, + commit_hashes: list[str], + boots_duration: tuple[Optional[int], Optional[int]] = (None, None), + tests_duration: tuple[Optional[int], Optional[int]] = (None, None), +) -> list[dict]: + """Fetch aggregated boot/test rows for commit comparison. + + Groups by identity dims (path, config_name, platform) plus filter dims + (status, compiler, arch, lab, origin, issues). Duration filters apply in SQL; + remaining filters apply in Python. + """ + if not commit_hashes: + return [] + + boot_duration_min, boot_duration_max = boots_duration + test_duration_min, test_duration_max = tests_duration + + params = { + "commit_hashes": commit_hashes, + "origin_param": origin, + "git_branch_param": git_branch, + "tree_name": tree_name, + "git_url_param": None, + "boot_duration_min": boot_duration_min, + "boot_duration_max": boot_duration_max, + "test_duration_min": test_duration_min, + "test_duration_max": test_duration_max, + } + + cache_key = "treeCompareData" + cache_params = { + **params, + "data_type": data_type, + "commit_hashes": tuple(sorted(commit_hashes)), + } + rows = get_query_cache(cache_key, cache_params) + if rows is not None: + return rows + + checkout_clauses = create_checkouts_where_clauses( + git_url=None, git_branch=git_branch, tree_name=tree_name + ) + git_branch_clause = checkout_clauses.get("git_branch_clause") + tree_name_clause = checkout_clauses.get("tree_name_clause") + tree_name_full_clause = "\nAND " + tree_name_clause if tree_name_clause else "" + git_branch_full_clause = "\nAND " + git_branch_clause if git_branch_clause else "" + + if data_type == "boots": + path_filter = "AND (tests.path = 'boot' OR tests.path LIKE 'boot.%%')" + duration_clause = get_boot_test_duration_clause(boots_duration, (None, None)) + else: + path_filter = "AND tests.path <> 'boot' AND tests.path NOT LIKE 'boot.%%'" + duration_clause = get_boot_test_duration_clause((None, None), tests_duration) + + query = f""" + SELECT + COUNT(DISTINCT tests.id) AS count, + c.git_commit_hash, + tests.path, + tests.status AS status, + builds.config_name, + tests.environment_misc->>'platform' AS platform, + tests.environment_compatible, + array[builds.compiler, builds.architecture] AS compiler_arch, + tests.misc->>'runtime' AS lab, + tests.origin, + ARRAY_AGG(DISTINCT ic.issue_id || ',' || ic.issue_version::text) + AS known_issues + FROM checkouts c + INNER JOIN builds ON c.id = builds.checkout_id + INNER JOIN tests ON tests.build_id = builds.id + {path_filter} + LEFT JOIN incidents ic ON tests.id = ic.test_id + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + AND c.origin = %(origin_param)s + {git_branch_full_clause} + {tree_name_full_clause} + {duration_clause} + GROUP BY + c.git_commit_hash, + tests.path, + tests.status, + builds.config_name, + platform, + tests.environment_compatible, + builds.compiler, + builds.architecture, + lab, + tests.origin + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor) + set_query_cache(key=cache_key, params=cache_params, rows=rows) + return rows + + def get_tree_commit_history( *, commit_hash: str, diff --git a/backend/kernelCI_app/tests/unitTests/helpers/treeCompare_test.py b/backend/kernelCI_app/tests/unitTests/helpers/treeCompare_test.py new file mode 100644 index 000000000..fa483f738 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/helpers/treeCompare_test.py @@ -0,0 +1,165 @@ +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.helpers.filters import FilterParams +from kernelCI_app.helpers.treeCompare import ( + build_compare_rows, + collapse_side_statuses, + group_raw_status, + worst_status, +) + + +def _row( + *, + commit_hash: str, + path: str = "boot", + status: str = "PASS", + config_name: str = "defconfig", + platform: str = "qemu", + compiler: str = "gcc", + architecture: str = "x86_64", + lab: str = "lab-a", + origin: str = "maestro", + environment_compatible: list[str] | None = None, + known_issues: list[str] | None = None, +) -> dict: + return { + "count": 1, + "git_commit_hash": commit_hash, + "path": path, + "status": status, + "config_name": config_name, + "platform": platform, + "environment_compatible": environment_compatible or [platform], + "compiler_arch": [compiler, architecture], + "lab": lab, + "origin": origin, + "known_issues": known_issues or [], + } + + +class TestGroupRawStatus(SimpleTestCase): + def test_pass_fail_and_inconclusive(self): + self.assertEqual(group_raw_status("PASS"), "PASS") + self.assertEqual(group_raw_status("FAIL"), "FAIL") + self.assertEqual(group_raw_status("SKIP"), "INCONCLUSIVE") + self.assertEqual(group_raw_status("MISS"), "INCONCLUSIVE") + self.assertEqual(group_raw_status(None), "INCONCLUSIVE") + + +class TestWorstStatus(SimpleTestCase): + def test_fail_wins(self): + self.assertEqual(worst_status("PASS", "FAIL"), "FAIL") + self.assertEqual(worst_status("INCONCLUSIVE", "FAIL"), "FAIL") + self.assertEqual(worst_status("FAIL", "PASS"), "FAIL") + + def test_inconclusive_over_pass(self): + self.assertEqual(worst_status("PASS", "INCONCLUSIVE"), "INCONCLUSIVE") + self.assertEqual(worst_status(None, "PASS"), "PASS") + + +class TestBuildCompareRows(SimpleTestCase): + def test_status_flip(self): + rows = build_compare_rows( + {("boot", "defconfig", "qemu"): "PASS"}, + {("boot", "defconfig", "qemu"): "FAIL"}, + ) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].status_a, "PASS") + self.assertEqual(rows[0].status_b, "FAIL") + + def test_one_sided_null(self): + rows = build_compare_rows( + {("boot", "defconfig", "qemu"): "PASS"}, + {}, + ) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].status_a, "PASS") + self.assertIsNone(rows[0].status_b) + + def test_same_grouped_bucket_no_diff(self): + rows = build_compare_rows( + {("boot", "defconfig", "qemu"): "INCONCLUSIVE"}, + {("boot", "defconfig", "qemu"): "INCONCLUSIVE"}, + ) + self.assertEqual(rows, []) + + +class TestCollapseSideStatuses(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.commit_a = "aaa" + self.commit_b = "bbb" + + def _filters(self, params: dict | None = None) -> FilterParams: + request = self.factory.get( + "/api/tree/mainline/master/boots/compare", params or {} + ) + return FilterParams(request) + + def test_worst_wins_on_collision(self): + rows = [ + _row(commit_hash=self.commit_a, status="PASS"), + _row(commit_hash=self.commit_a, status="FAIL"), + ] + collapsed = collapse_side_statuses( + rows=rows, + commit_hash=self.commit_a, + filters=self._filters(), + data_type="boots", + ) + self.assertEqual(collapsed[("boot", "defconfig", "qemu")], "FAIL") + + def test_skip_and_miss_same_bucket(self): + rows = [ + _row(commit_hash=self.commit_a, status="SKIP"), + _row(commit_hash=self.commit_b, status="MISS"), + ] + status_a = collapse_side_statuses( + rows=rows, + commit_hash=self.commit_a, + filters=self._filters(), + data_type="boots", + ) + status_b = collapse_side_statuses( + rows=rows, + commit_hash=self.commit_b, + filters=self._filters(), + data_type="boots", + ) + self.assertEqual(build_compare_rows(status_a, status_b), []) + + def test_config_filter_applies_both_sides(self): + rows = [ + _row(commit_hash=self.commit_a, status="PASS", config_name="defconfig"), + _row(commit_hash=self.commit_b, status="FAIL", config_name="defconfig"), + _row( + commit_hash=self.commit_a, + status="PASS", + config_name="otherconfig", + path="boot.other", + ), + _row( + commit_hash=self.commit_b, + status="FAIL", + config_name="otherconfig", + path="boot.other", + ), + ] + filters = self._filters({"filter_config_name": "defconfig"}) + status_a = collapse_side_statuses( + rows=rows, + commit_hash=self.commit_a, + filters=filters, + data_type="boots", + ) + status_b = collapse_side_statuses( + rows=rows, + commit_hash=self.commit_b, + filters=filters, + data_type="boots", + ) + compare_rows = build_compare_rows(status_a, status_b) + self.assertEqual(len(compare_rows), 1) + self.assertEqual(compare_rows[0].config_name, "defconfig") diff --git a/backend/kernelCI_app/tests/unitTests/views/treeDetailsCompareView_test.py b/backend/kernelCI_app/tests/unitTests/views/treeDetailsCompareView_test.py new file mode 100644 index 000000000..0f49df56d --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/treeDetailsCompareView_test.py @@ -0,0 +1,91 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.views.treeDetailsCompareView import ( + TreeDetailsBootsCompare, + TreeDetailsTestsCompare, +) + + +class TestTreeDetailsCompareView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.boots_view = TreeDetailsBootsCompare() + self.tests_view = TreeDetailsTestsCompare() + self.commit_a = "aaa111" + self.commit_b = "bbb222" + + def _row(self, commit_hash: str, status: str, path: str = "boot") -> dict: + return { + "count": 1, + "git_commit_hash": commit_hash, + "path": path, + "status": status, + "config_name": "defconfig", + "platform": "qemu", + "environment_compatible": ["qemu"], + "compiler_arch": ["gcc", "x86_64"], + "lab": "lab-a", + "origin": "maestro", + "known_issues": [], + } + + @patch("kernelCI_app.views.treeDetailsCompareView.get_tree_compare_data") + def test_boots_compare_returns_diff(self, mock_query): + mock_query.return_value = [ + self._row(self.commit_a, "PASS"), + self._row(self.commit_b, "FAIL"), + ] + request = self.factory.get( + "/api/tree/mainline/master/boots/compare", + { + "origin": "maestro", + "commit_a": self.commit_a, + "commit_b": self.commit_b, + }, + ) + response = self.boots_view.get( + request, tree_name="mainline", git_branch="master" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["status_a"], "PASS") + self.assertEqual(response.data[0]["status_b"], "FAIL") + mock_query.assert_called_once() + self.assertEqual(mock_query.call_args.kwargs["data_type"], "boots") + + @patch("kernelCI_app.views.treeDetailsCompareView.get_tree_compare_data") + def test_tests_compare_uses_tests_data_type(self, mock_query): + mock_query.return_value = [ + self._row(self.commit_a, "PASS", path="ltp.smoke"), + self._row(self.commit_b, "SKIP", path="ltp.smoke"), + ] + request = self.factory.get( + "/api/tree/mainline/master/tests/compare", + { + "origin": "maestro", + "commit_a": self.commit_a, + "commit_b": self.commit_b, + }, + ) + response = self.tests_view.get( + request, tree_name="mainline", git_branch="master" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["status_a"], "PASS") + self.assertEqual(response.data[0]["status_b"], "INCONCLUSIVE") + self.assertEqual(mock_query.call_args.kwargs["data_type"], "tests") + + @patch("kernelCI_app.views.treeDetailsCompareView.get_tree_compare_data") + def test_missing_commit_params_returns_error(self, mock_query): + request = self.factory.get( + "/api/tree/mainline/master/boots/compare", + {"origin": "maestro"}, + ) + response = self.boots_view.get( + request, tree_name="mainline", git_branch="master" + ) + self.assertEqual(response.status_code, 400) + mock_query.assert_not_called() diff --git a/backend/kernelCI_app/typeModels/treeDetails.py b/backend/kernelCI_app/typeModels/treeDetails.py index 5657f62a8..7b681bce3 100644 --- a/backend/kernelCI_app/typeModels/treeDetails.py +++ b/backend/kernelCI_app/typeModels/treeDetails.py @@ -1,6 +1,6 @@ -from typing import List, Optional +from typing import List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, RootModel from kernelCI_app.constants.general import DEFAULT_ORIGIN from kernelCI_app.constants.localization import DocStrings @@ -13,6 +13,8 @@ ) from kernelCI_app.typeModels.treeListing import BaseCheckouts +GroupedCompareStatus = Literal["PASS", "FAIL", "INCONCLUSIVE"] + class TreeLatestPathParameters(BaseModel): tree_name: str = Field(description=DocStrings.TREE_LATEST_TREE_NAME_DESCRIPTION) @@ -87,3 +89,20 @@ class TreeDetailsFullResponse( SummaryResponse, ): pass + + +class TreeCompareQueryParameters(DirectTreeQueryParameters): + commit_a: str = Field(description="First commit hash to compare") + commit_b: str = Field(description="Second commit hash to compare") + + +class TreeCompareTest(BaseModel): + path: str + config_name: str + platform: str + status_a: Optional[GroupedCompareStatus] + status_b: Optional[GroupedCompareStatus] + + +class TreeCompareResponse(RootModel[List[TreeCompareTest]]): + root: List[TreeCompareTest] diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index a89890862..e8fa47305 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -92,6 +92,16 @@ def view_cache(view, timeout: int = settings.CACHE_TIMEOUT): views.TreeDetailsTestsDirect.as_view(), name="treeDetailsTestsDirectView", ), + path( + "tree///boots/compare", + views.TreeDetailsBootsCompare.as_view(), + name="treeDetailsBootsCompareView", + ), + path( + "tree///tests/compare", + views.TreeDetailsTestsCompare.as_view(), + name="treeDetailsTestsCompareView", + ), path( "tree//", view_cache(views.TreeLatest), diff --git a/backend/kernelCI_app/views/treeDetailsCompareView.py b/backend/kernelCI_app/views/treeDetailsCompareView.py new file mode 100644 index 000000000..79cd6640b --- /dev/null +++ b/backend/kernelCI_app/views/treeDetailsCompareView.py @@ -0,0 +1,115 @@ +from http import HTTPStatus +from typing import Literal + +from django.http import HttpRequest +from drf_spectacular.utils import extend_schema +from pydantic import ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.helpers.filters import FilterParams +from kernelCI_app.helpers.treeCompare import build_compare_rows, collapse_side_statuses +from kernelCI_app.queries.tree import get_tree_compare_data +from kernelCI_app.typeModels.commonOpenApiParameters import ( + GIT_BRANCH_PATH_PARAM, + TREE_NAME_PATH_PARAM, +) +from kernelCI_app.typeModels.treeDetails import ( + TreeCompareQueryParameters, + TreeCompareResponse, +) + + +class BaseTreeDetailsCompare(APIView): + data_type: Literal["boots", "tests"] + + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + try: + params = TreeCompareQueryParameters.model_validate(request.GET.dict()) + except ValidationError as e: + return create_api_error_response(error_message=e.json()) + + filters = FilterParams(request) + rows = get_tree_compare_data( + data_type=self.data_type, + origin=params.origin, + git_branch=git_branch, + tree_name=tree_name, + commit_hashes=[params.commit_a, params.commit_b], + boots_duration=( + filters.filterBootDurationMin, + filters.filterBootDurationMax, + ), + tests_duration=( + filters.filterTestDurationMin, + filters.filterTestDurationMax, + ), + ) + + status_a = collapse_side_statuses( + rows=rows, + commit_hash=params.commit_a, + filters=filters, + data_type=self.data_type, + ) + status_b = collapse_side_statuses( + rows=rows, + commit_hash=params.commit_b, + filters=filters, + data_type=self.data_type, + ) + + try: + response = TreeCompareResponse(root=build_compare_rows(status_a, status_b)) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return Response(response.model_dump()) + + +class TreeDetailsBootsCompare(BaseTreeDetailsCompare): + data_type = "boots" + + @extend_schema( + parameters=[ + TREE_NAME_PATH_PARAM, + GIT_BRANCH_PATH_PARAM, + TreeCompareQueryParameters, + ], + methods=["GET"], + responses=TreeCompareResponse, + ) + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + return super().get(request, tree_name, git_branch) + + +class TreeDetailsTestsCompare(BaseTreeDetailsCompare): + data_type = "tests" + + @extend_schema( + parameters=[ + TREE_NAME_PATH_PARAM, + GIT_BRANCH_PATH_PARAM, + TreeCompareQueryParameters, + ], + methods=["GET"], + responses=TreeCompareResponse, + ) + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + return super().get(request, tree_name, git_branch)