From fe69fe88f12758c5a0e96582fd70fe6c41ca692e Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Fri, 17 Jul 2026 17:23:58 -0300 Subject: [PATCH] fix: distinguish tree-report by tree_name for shared git url/branch Trees that share git_url and branch could return the wrong checkout when they also share a commit hash, because tree_name was not applied in head selection or the join back to checkouts. Accept tree_name on the tree-report API and thread it through get_checkout_summary_data. Closes #1460 Signed-off-by: Alan Peixinho --- backend/kernelCI_app/queries/notifications.py | 25 +++-- .../tests/factories/checkout_factory.py | 4 +- .../tests/factories/mocks/checkout.py | 8 ++ .../factories/mocks/fixtures/tree_data.py | 24 +++- .../tests/integrationTests/treeReport_test.py | 29 +++++ .../tests/utils/client/treeClient.py | 5 + backend/kernelCI_app/typeModels/treeReport.py | 6 +- backend/kernelCI_app/views/treeReportView.py | 2 + backend/requests/tree-report-get.sh | 104 +++++------------- backend/schema.yml | 9 ++ 10 files changed, 125 insertions(+), 91 deletions(-) create mode 100644 backend/kernelCI_app/tests/integrationTests/treeReport_test.py mode change 100644 => 100755 backend/requests/tree-report-get.sh diff --git a/backend/kernelCI_app/queries/notifications.py b/backend/kernelCI_app/queries/notifications.py index bde33f450..088eab0aa 100644 --- a/backend/kernelCI_app/queries/notifications.py +++ b/backend/kernelCI_app/queries/notifications.py @@ -1,7 +1,7 @@ import sys from concurrent.futures import ThreadPoolExecutor from datetime import date, datetime, time, timedelta, timezone -from typing import Any +from typing import Any, Optional from django.db import connection, connections from pydantic import ValidationError @@ -425,6 +425,7 @@ def get_checkout_summary_data( tuple_params: list[tuple[str, str, str]], interval_min="5 hours", interval_max="29 hours", + tree_name: Optional[str] = None, ) -> list[dict]: """Queries for the checkout and status count data similarly to tree_listing but using a list of parameters for the filtering @@ -432,6 +433,7 @@ def get_checkout_summary_data( Parameters: tuple_params: a list of tuples (str, str, str) representing (git_repository_branch, git_repository_url, origin) + tree_name: optional filter so trees sharing branch/git_url stay distinct Returns: out: a list of dicts with the records found. @@ -440,6 +442,8 @@ def get_checkout_summary_data( if not tuple_params: return [] + tree_name_filter = "AND C.TREE_NAME = %s" if tree_name is not None else "" + with_clause = f""" WITH ORDERED_CHECKOUTS_BY_TREE AS ( @@ -448,6 +452,7 @@ def get_checkout_summary_data( C.GIT_REPOSITORY_URL, C.GIT_COMMIT_HASH, C.ORIGIN, + C.TREE_NAME, ROW_NUMBER() OVER ( PARTITION BY C.GIT_REPOSITORY_BRANCH, @@ -469,13 +474,15 @@ def get_checkout_summary_data( WHERE C.START_TIME >= NOW() - INTERVAL %s AND C.START_TIME <= NOW() - INTERVAL %s + {tree_name_filter} ), FIRST_TREE_CHECKOUT AS ( SELECT GIT_REPOSITORY_BRANCH, GIT_REPOSITORY_URL, GIT_COMMIT_HASH, - ORIGIN + ORIGIN, + TREE_NAME FROM ORDERED_CHECKOUTS_BY_TREE WHERE @@ -489,6 +496,7 @@ def get_checkout_summary_data( AND checkouts.git_repository_url IS NOT DISTINCT FROM FTC.GIT_REPOSITORY_URL AND checkouts.git_commit_hash = FTC.GIT_COMMIT_HASH AND checkouts.origin = FTC.ORIGIN + AND checkouts.tree_name IS NOT DISTINCT FROM FTC.TREE_NAME ) """ @@ -502,15 +510,12 @@ def get_checkout_summary_data( for tuple in tuple_params: flattened_list += list(tuple) + params = flattened_list + [interval_max, interval_min] + if tree_name is not None: + params.append(tree_name) + with connection.cursor() as cursor: - cursor.execute( - query, - flattened_list - + [ - interval_max, - interval_min, - ], - ) + cursor.execute(query, params) return dict_fetchall(cursor=cursor) diff --git a/backend/kernelCI_app/tests/factories/checkout_factory.py b/backend/kernelCI_app/tests/factories/checkout_factory.py index c237fd1c3..1de697506 100644 --- a/backend/kernelCI_app/tests/factories/checkout_factory.py +++ b/backend/kernelCI_app/tests/factories/checkout_factory.py @@ -65,7 +65,9 @@ class Meta: git_commit_hash = factory.LazyAttribute( lambda obj: ( - obj.id if Checkout.is_known_checkout(obj.id) else f"commit_{obj.id[:8]}" + Checkout.get_git_commit_hash(obj.id) + if Checkout.is_known_checkout(obj.id) + else f"commit_{obj.id[:8]}" ) ) diff --git a/backend/kernelCI_app/tests/factories/mocks/checkout.py b/backend/kernelCI_app/tests/factories/mocks/checkout.py index 03167887c..2c063b142 100644 --- a/backend/kernelCI_app/tests/factories/mocks/checkout.py +++ b/backend/kernelCI_app/tests/factories/mocks/checkout.py @@ -43,6 +43,14 @@ def get_git_branch(cls, checkout_id: str): checkout_data = TREE_DATA.get(checkout_id) return checkout_data.get("git_branch") if checkout_data else None + @classmethod + def get_git_commit_hash(cls, checkout_id: str): + """Get git commit hash for a checkout (defaults to checkout_id).""" + checkout_data = TREE_DATA.get(checkout_id) + if not checkout_data: + return None + return checkout_data.get("git_commit_hash", checkout_id) + @classmethod def get_tree_name(cls, checkout_id: str): """Get tree name for a checkout.""" diff --git a/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py b/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py index 83f1d4234..015276e44 100644 --- a/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py +++ b/backend/kernelCI_app/tests/factories/mocks/fixtures/tree_data.py @@ -2,7 +2,10 @@ Tree data fixtures for test factories. """ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone + +# Evaluated at seed import so tree-report age windows (hours from NOW) still match. +_SEED_NOW = datetime.now(timezone.utc) TREE_DATA = { "a1c24ab822793eb513351686f631bd18952b7870": { # ARM64_TREE @@ -167,4 +170,23 @@ ), # failed_tests_build "hardware_platform": None, }, + # https://github.com/kernelci/dashboard/issues/1460 — same url/branch/hash, different tree_name + "issue1460_older_checkout": { + "origin": "maestro", + "git_url": "https://example.com/kernelci-dashboard-issue-1460.git", + "git_branch": "issue-1460-branch", + "tree_name": "issue1460_older", + "git_commit_hash": "issue1460sharedhash00000000000000000000", + "start_time": _SEED_NOW - timedelta(hours=5), + "hardware_platform": None, + }, + "issue1460_newer_checkout": { + "origin": "maestro", + "git_url": "https://example.com/kernelci-dashboard-issue-1460.git", + "git_branch": "issue-1460-branch", + "tree_name": "issue1460_newer", + "git_commit_hash": "issue1460sharedhash00000000000000000000", + "start_time": _SEED_NOW - timedelta(hours=1), + "hardware_platform": None, + }, } diff --git a/backend/kernelCI_app/tests/integrationTests/treeReport_test.py b/backend/kernelCI_app/tests/integrationTests/treeReport_test.py new file mode 100644 index 000000000..2aadb18c1 --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/treeReport_test.py @@ -0,0 +1,29 @@ +from http import HTTPStatus + +from kernelCI_app.tests.utils.client.treeClient import TreeClient +from kernelCI_app.utils import string_to_json + +client = TreeClient() + +# Seeded in tree_data.py (issue1460_*): same git_url/branch/commit hash, different tree_name. +ORIGIN = "maestro" +GIT_URL = "https://example.com/kernelci-dashboard-issue-1460.git" +GIT_BRANCH = "issue-1460-branch" +OLDER_TREE_NAME = "issue1460_older" +NEWER_TREE_NAME = "issue1460_newer" + + +def test_tree_report_returns_requested_tree_name(): + response = client.get_tree_report( + query={ + "origin": ORIGIN, + "git_url": GIT_URL, + "git_branch": GIT_BRANCH, + "tree_name": OLDER_TREE_NAME, + } + ) + assert response.status_code == HTTPStatus.OK + content = string_to_json(response.content.decode()) + + assert f"/tree/{OLDER_TREE_NAME}/" in content["dashboard_url"] + assert NEWER_TREE_NAME not in content["dashboard_url"] diff --git a/backend/kernelCI_app/tests/utils/client/treeClient.py b/backend/kernelCI_app/tests/utils/client/treeClient.py index c872f90c5..64815c9dc 100644 --- a/backend/kernelCI_app/tests/utils/client/treeClient.py +++ b/backend/kernelCI_app/tests/utils/client/treeClient.py @@ -18,6 +18,11 @@ def get_tree_listing(self, *, query: dict) -> requests.Response: url = self.get_endpoint(path=path, query=query) return requests.get(url) + def get_tree_report(self, *, query: dict) -> requests.Response: + path = reverse("treeReportView") + url = self.get_endpoint(path=path, query=query) + return requests.get(url) + def get_tree_latest( self, *, tree_name: str, git_branch: str, query: dict ) -> requests.Response: diff --git a/backend/kernelCI_app/typeModels/treeReport.py b/backend/kernelCI_app/typeModels/treeReport.py index 2b1a49324..7dceb68f5 100644 --- a/backend/kernelCI_app/typeModels/treeReport.py +++ b/backend/kernelCI_app/typeModels/treeReport.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import TypedDict +from typing import Optional, TypedDict from pydantic import BaseModel, Field from typing_extensions import Annotated @@ -35,6 +35,10 @@ class TreeReportQueryParameters(BaseModel): str, Field(description=DocStrings.TREE_QUERY_GIT_URL_DESCRIPTION), ] + tree_name: Annotated[ + Optional[str], + Field(default=None, description=DocStrings.TREE_NAME_PATH_DESCRIPTION), + ] = None path: Annotated[ list[str], Field( diff --git a/backend/kernelCI_app/views/treeReportView.py b/backend/kernelCI_app/views/treeReportView.py index 913b71737..6c4423dd7 100644 --- a/backend/kernelCI_app/views/treeReportView.py +++ b/backend/kernelCI_app/views/treeReportView.py @@ -35,6 +35,7 @@ def get(self, request: HttpRequest): origin=request.GET.get("origin"), git_branch=request.GET.get("git_branch"), git_url=request.GET.get("git_url"), + tree_name=request.GET.get("tree_name"), path=request.GET.getlist("path"), group_size=request.GET.get("group_size"), min_age_in_hours=request.GET.get("min_age_in_hours"), @@ -61,6 +62,7 @@ def get(self, request: HttpRequest): tuple_params=[tree_key], interval_min=min_query_interval, interval_max=max_query_interval, + tree_name=params.tree_name or None, ) if not records: return create_api_error_response( diff --git a/backend/requests/tree-report-get.sh b/backend/requests/tree-report-get.sh old mode 100644 new mode 100755 index 9353e373f..a05d98c3f --- a/backend/requests/tree-report-get.sh +++ b/backend/requests/tree-report-get.sh @@ -1,80 +1,28 @@ -http "http://localhost:8000/api/tree-report/" git_branch==for-kernelci git_url==https://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git origin==maestro +#!/usr/bin/env bash +# Usage: +# ./tree-report-get.sh +# GIT_BRANCH=for-next TREE_NAME=mediatek GIT_URL=https://git.kernel.org/pub/scm/linux/kernel/git/mediatek/linux.git ./tree-report-get.sh +# MIN_AGE_IN_HOURS=24 MAX_AGE_IN_HOURS=48 ./tree-report-get.sh +# Env: GIT_BRANCH, GIT_URL, ORIGIN, TREE_NAME (set TREE_NAME= empty to omit), +# MIN_AGE_IN_HOURS (default 0), MAX_AGE_IN_HOURS (default 24) -# HTTP/1.1 200 OK -# Allow: GET, HEAD, OPTIONS -# Content-Length: 3677 -# Content-Type: application/json -# Cross-Origin-Opener-Policy: same-origin -# Date: Wed, 02 Jul 2025 17:57:35 GMT -# Referrer-Policy: same-origin -# Server: WSGIServer/0.2 CPython/3.12.7 -# Vary: Accept, Cookie, origin -# X-Content-Type-Options: nosniff -# X-Frame-Options: DENY +GIT_BRANCH="${GIT_BRANCH:-master}" +GIT_URL="${GIT_URL:-https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git}" +ORIGIN="${ORIGIN:-maestro}" +TREE_NAME="${TREE_NAME-mainline}" +MIN_AGE_IN_HOURS="${MIN_AGE_IN_HOURS:-0}" +MAX_AGE_IN_HOURS="${MAX_AGE_IN_HOURS:-24}" -# { -# "boot_status_summary": { -# "done_count": 0, -# "error_count": 0, -# "fail_count": 0, -# "miss_count": 13, -# "null_count": 0, -# "pass_count": 9, -# "skip_count": 0 -# }, -# "build_status_summary": { -# "DONE": 0, -# "ERROR": 0, -# "FAIL": 0, -# "MISS": 0, -# "NULL": 0, -# "PASS": 9, -# "SKIP": 0 -# }, -# "checkout_start_time": "2025-07-01T15:08:26.610000Z", -# "commit_hash": "3c795c3404e82c4db5c69317847dc5bbafbb368b", -# "dashboard_url": "https://d.kernelci.org/tree/arm64/for-kernelci/3c795c3404e82c4db5c69317847dc5bbafbb368b?o=maestro", -# "fixed_regressions": {}, -# "git_branch": "for-kernelci", -# "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git", -# "origin": "maestro", -# "possible_regressions": {}, -# "test_status_summary": { -# "done_count": 0, -# "error_count": 0, -# "fail_count": 0, -# "miss_count": 0, -# "null_count": 62, -# "pass_count": 592, -# "skip_count": 0 -# }, -# "unstable_tests": { -# "bcm2711-rpi-4-b": { -# "defconfig+lab-setup+kselftest": { -# "boot": [ -# { -# "id": "maestro:686413d35c2cf25042f65ec8", -# "start_time": "2025-07-01T16:58:59.086000Z", -# "status": "MISS" -# }, -# { -# "id": "maestro:686413cf5c2cf25042f65ead", -# "start_time": "2025-07-01T16:58:55.436000Z", -# "status": "PASS" -# }, -# { -# "id": "maestro:6862e6f15c2cf25042f38ba1", -# "start_time": "2025-06-30T19:35:13.117000Z", -# "status": "PASS" -# }, -# { -# "id": "maestro:6862e6ed5c2cf25042f38b85", -# "start_time": "2025-06-30T19:35:09.018000Z", -# "status": "PASS" -# } -# ] -# } -# }, -# ... -# } -# } \ No newline at end of file +args=( + "http://localhost:8000/api/tree-report/" + "git_branch==${GIT_BRANCH}" + "git_url==${GIT_URL}" + "origin==${ORIGIN}" + "min_age_in_hours==${MIN_AGE_IN_HOURS}" + "max_age_in_hours==${MAX_AGE_IN_HOURS}" +) +if [[ -n "${TREE_NAME}" ]]; then + args+=("tree_name==${TREE_NAME}") +fi + +http "${args[@]}" diff --git a/backend/schema.yml b/backend/schema.yml index fe12505b9..ecb315150 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -1106,6 +1106,15 @@ paths: title: Path type: array description: A list of test paths to query for. SQL Wildcard can be used. + - in: query + name: tree_name + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Tree Name + description: Name of the tree tags: - tree-report security: