diff --git a/backend/kernelCI_app/constants/localization.py b/backend/kernelCI_app/constants/localization.py index d63dd24fc..d0ebbc828 100644 --- a/backend/kernelCI_app/constants/localization.py +++ b/backend/kernelCI_app/constants/localization.py @@ -111,6 +111,10 @@ class DocStrings: TREE_LATEST_COMMIT_HASH_DESCRIPTION = "Commit hash to retrieve tree information" TREE_LATEST_ORIGIN_DESCRIPTION = "Origin filter to retrieve tree information" + TREE_LIST_COMMIT_HASH_DESCRIPTION = ( + "Comma-separated list of commit hashes to get history for" + ) + TREE_QUERY_ORIGIN_DESCRIPTION = "Origin of the tree" TREE_QUERY_GIT_URL_DESCRIPTION = "Git repository URL of the tree" diff --git a/backend/kernelCI_app/helpers/database.py b/backend/kernelCI_app/helpers/database.py index 8e3feafd6..4c1516dff 100644 --- a/backend/kernelCI_app/helpers/database.py +++ b/backend/kernelCI_app/helpers/database.py @@ -6,3 +6,15 @@ def dict_fetchall(cursor) -> list[dict]: """ columns = [col[0] for col in cursor.description] return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()] + + +def debug_query(cursor, query, params) -> tuple[str, str]: + sql = cursor.mogrify(query, params) + profile = "\n".join( + row for row, *_ in cursor.execute(f"EXPLAIN (ANALYZE, BUFFERS) {query}", params) + ) + return (sql, profile) + + +def print_debug_query(cursor, query, params): + print("{}\n{}\n".format(*debug_query(cursor, query, params))) diff --git a/backend/kernelCI_app/queries/duration.py b/backend/kernelCI_app/queries/duration.py new file mode 100644 index 000000000..edec59dd0 --- /dev/null +++ b/backend/kernelCI_app/queries/duration.py @@ -0,0 +1,43 @@ +from typing import Optional + + +def get_build_duration_clause( + builds_duration: tuple[Optional[int], Optional[int]], +) -> str: + clause = "" + duration_min, duration_max = builds_duration + if duration_min: + clause += "AND builds.duration >= %(build_duration_min)s\n" + if duration_max: + clause += "AND builds.duration <= %(build_duration_max)s\n" + return clause + + +def get_boot_test_duration_clause( + boots_duration: tuple[Optional[int], Optional[int]], + tests_duration: tuple[Optional[int], Optional[int]], +) -> str: + clause = "" + duration_min, duration_max = tests_duration + if duration_min: + clause += ( + "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration >= %(test_duration_min)s)\n" + ) + if duration_max: + clause += ( + "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration <= %(test_duration_max)s)\n" + ) + duration_min, duration_max = boots_duration + if duration_min: + clause += ( + "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration >= %(boot_duration_min)s)\n" + ) + if duration_max: + clause += ( + "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration <= %(boot_duration_max)s)\n" + ) + return clause diff --git a/backend/kernelCI_app/queries/hardware.py b/backend/kernelCI_app/queries/hardware.py index 9fb34b4b7..1bb410747 100644 --- a/backend/kernelCI_app/queries/hardware.py +++ b/backend/kernelCI_app/queries/hardware.py @@ -5,6 +5,10 @@ from kernelCI_app.cache import get_query_cache, set_query_cache from kernelCI_app.helpers.database import dict_fetchall +from kernelCI_app.queries.duration import ( + get_boot_test_duration_clause, + get_build_duration_clause, +) from kernelCI_app.typeModels.hardwareDetails import CommitHead, Tree @@ -340,56 +344,6 @@ def get_hardware_details_data( return records -def _get_build_duration_clause( - builds_duration: tuple[Optional[int], Optional[int]], -) -> str: - clause = "" - - # builds - duration_min, duration_max = builds_duration - if duration_min: - clause += "AND builds.duration >= %(build_duration_min)s\n" - if duration_max: - clause += "AND builds.duration <= %(build_duration_max)s\n" - - return clause - - -def _get_boot_test_duration_clause( - boots_duration: tuple[Optional[int], Optional[int]], - tests_duration: tuple[Optional[int], Optional[int]], -) -> str: - clause = "" - - # tests - duration_min, duration_max = tests_duration - if duration_min: - clause += ( - "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration >= %(test_duration_min)s)\n" - ) - if duration_max: - clause += ( - "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration <= %(test_duration_max)s)\n" - ) - - # boots - duration_min, duration_max = boots_duration - if duration_min: - clause += ( - "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration >= %(boot_duration_min)s)\n" - ) - if duration_max: - clause += ( - "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration <= %(boot_duration_max)s)\n" - ) - - return clause - - def get_hardware_details_summary( *, hardware_id: str, @@ -427,8 +381,8 @@ def get_hardware_details_summary( if query_rows is not None: return query_rows - builds_duration_clause = _get_build_duration_clause(builds_duration) - boots_tests_duration_clause = _get_boot_test_duration_clause( + builds_duration_clause = get_build_duration_clause(builds_duration) + boots_tests_duration_clause = get_boot_test_duration_clause( boots_duration, tests_duration ) diff --git a/backend/kernelCI_app/queries/tree.py b/backend/kernelCI_app/queries/tree.py index 11abc876b..253346033 100644 --- a/backend/kernelCI_app/queries/tree.py +++ b/backend/kernelCI_app/queries/tree.py @@ -7,6 +7,10 @@ from kernelCI_app.helpers.database import dict_fetchall from kernelCI_app.helpers.treeDetails import create_checkouts_where_clauses from kernelCI_app.models import Checkouts +from kernelCI_app.queries.duration import ( + get_boot_test_duration_clause, + get_build_duration_clause, +) from kernelCI_app.utils import get_query_time_interval @@ -896,6 +900,256 @@ def _create_selected_checkouts_clause( return selected_checkouts_clause +def get_tree_commits( + *, + origin: Optional[str], + git_url: Optional[str], + git_branch: Optional[str], + tree_name: Optional[str], +): + cache_key = "treeCommits" + + params = { + "git_repository_url": git_url, + "git_branch": git_branch, + "tree_name": tree_name, + "origin": origin, + } + + rows = get_query_cache(cache_key, params) + if rows is not None: + return rows + + if origin: + origin_clause = "\nAND origin = %(origin)s" + else: + origin_clause = "\nAND origin IS NULL" + + url_clause = "" + if git_url: + url_clause = "\nAND git_repository_url = %(git_repository_url)s" + + query = f""" + select + git_commit_hash, + max(start_time) as start_time_end + from + checkouts + where + tree_name = %(tree_name)s + and git_repository_branch = %(git_branch)s + {url_clause} + {origin_clause} + group by + git_commit_hash + order by + start_time_end desc; + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + return rows + + +def union_all(queries: list[str]) -> str: + return " UNION ALL ".join(f"({query})" for query in queries) + + +def _get_platform_filter_clause(platform_filter: Optional[list[str]]) -> str: + if platform_filter: + return """ + AND (tests.environment_compatible && %(platform)s::text[] + OR tests.environment_misc->>'platform' = ANY(%(platform)s::text[])) + """ + return "" + + +def get_tree_commit_history_hashes_aggregated( + *, + commit_hashes: list[str], + origin: str, + git_url: Optional[str], + git_branch: Optional[str], + tree_name: Optional[str], + platform_filter: list[str] = None, + include_types: Optional[list[str]] = None, + builds_duration: tuple[Optional[int], Optional[int]] = (None, None), + boots_duration: tuple[Optional[int], Optional[int]] = (None, None), + tests_duration: tuple[Optional[int], Optional[int]] = (None, None), +) -> list[dict]: + + if not commit_hashes: + return [] + + if not include_types: + include_types = ["builds", "boots", "tests"] + + include_types = [t.lower() for t in include_types] + + build_duration_min, build_duration_max = builds_duration + boot_duration_min, boot_duration_max = boots_duration + test_duration_min, test_duration_max = tests_duration + + build_duration_clause = get_build_duration_clause(builds_duration) + boots_tests_duration_clause = get_boot_test_duration_clause( + boots_duration, tests_duration + ) + + params = { + "commit_hashes": commit_hashes, + "origin_param": origin, + "git_url_param": git_url, + "git_branch_param": git_branch, + "tree_name": tree_name, + "platform": platform_filter, + "build_duration_min": build_duration_min, + "build_duration_max": build_duration_max, + "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 = "treeCommitHistoryHashesAggregated" + cache_params = { + **params, + "include_types": tuple(sorted(include_types)), + } + rows = get_query_cache(cache_key, cache_params) + if rows is not None: + return rows + + checkout_clauses = create_checkouts_where_clauses( + git_url=git_url, 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") + git_url_clause = checkout_clauses.get("git_url_clause") + tree_name_full_clause = "\nAND " + tree_name_clause if tree_name_clause else "" + git_url_full_clause = "\nAND " + git_url_clause if git_url_clause else "" + git_branch_full_clause = "\nAND " + git_branch_clause if git_branch_clause else "" + + include_builds = "builds" in include_types + include_tests = "tests" in include_types + include_boots = "boots" in include_types + + platform_filter_clause = _get_platform_filter_clause(platform_filter) + + builds_query = f""" + SELECT + COUNT(DISTINCT builds.id) AS count, + c.git_commit_hash, + c.git_commit_name, + c.git_commit_tags, + c.start_time, + c.origin, + builds.status AS status, + array[builds.compiler, builds.architecture] AS compiler_arch, + builds.config_name AS config_name, + builds.misc->>'lab' AS lab, + to_jsonb(array_append(tests.environment_compatible, + tests.environment_misc->>'platform')) AS compatibles, + ARRAY_AGG(DISTINCT ic.issue_id || ',' || ic.issue_version::text) AS known_issues, + true AS is_build, + false AS is_boot, + false AS is_test + FROM checkouts c + INNER JOIN builds ON c.id = builds.checkout_id + LEFT JOIN tests on tests.build_id = builds.id + LEFT JOIN incidents ic ON builds.id = ic.build_id + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + AND c.origin = %(origin_param)s + AND builds.config_name IS NOT NULL + AND builds.id NOT LIKE 'maestro:dummy_%%' + {platform_filter_clause} + {git_branch_full_clause} + {git_url_full_clause} + {tree_name_full_clause} + {build_duration_clause} + GROUP BY + c.id, + builds.status, + builds.compiler, + builds.architecture, + builds.config_name, + lab, + compatibles + """ + + boot_filter = "" + if include_boots and not include_tests: + boot_filter = "\nAND (tests.path ='boot' OR tests.path LIKE 'boot.%%')" + elif include_tests and not include_boots: + boot_filter = "\nAND (tests.path != 'boot' AND tests.path NOT LIKE 'boot.%%')" + + tests_query = f""" + SELECT + COUNT(DISTINCT tests.id) AS count, + c.git_commit_hash, + c.git_commit_name, + c.git_commit_tags, + c.start_time, + c.origin, + tests.status AS status, + array[builds.compiler, builds.architecture] AS compiler_arch, + builds.config_name AS config_name, + tests.misc->>'runtime' AS lab, + to_jsonb(array_append( + tests.environment_compatible, tests.environment_misc->>'platform' + )) AS compatibles, + ARRAY_AGG(DISTINCT ic.issue_id || ',' || ic.issue_version::text) AS known_issues, + false AS is_build, + true AS is_test, + (tests.path like 'boot.%%' or tests.path = 'boot') AS is_boot + FROM checkouts c + INNER JOIN builds ON c.id = builds.checkout_id + INNER JOIN tests ON tests.build_id = builds.id {boot_filter} + LEFT JOIN incidents ic ON tests.id = ic.test_id + LEFT JOIN issues i ON ic.issue_id = i.id + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + AND c.origin = %(origin_param)s + {platform_filter_clause} + {git_branch_full_clause} + {git_url_full_clause} + {tree_name_full_clause} + {boots_tests_duration_clause} + GROUP BY + c.id, + c.start_time, + c.origin, + tests.status, + is_boot, + builds.compiler, + builds.architecture, + builds.config_name, + lab, + compatibles + """ + + queries = [] + if include_builds: + queries.append(builds_query) + if include_boots or include_tests: + queries.append(tests_query) + + if not queries: + set_query_cache(key=cache_key, params=cache_params, rows=[]) + return [] + + query = union_all(queries) + + 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, @@ -1084,10 +1338,7 @@ def get_tree_commit_history( """ with connection.cursor() as cursor: - cursor.execute( - query, - field_values, - ) + cursor.execute(query, field_values) return cursor.fetchall() diff --git a/backend/kernelCI_app/tests/integrationTests/treeCommitsHistoryList_test.py b/backend/kernelCI_app/tests/integrationTests/treeCommitsHistoryList_test.py new file mode 100644 index 000000000..797eb9a04 --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/treeCommitsHistoryList_test.py @@ -0,0 +1,110 @@ +from http import HTTPStatus + +import pytest + +from kernelCI_app.tests.utils.asserts import assert_status_code_and_error_response +from kernelCI_app.tests.utils.client.treeClient import TreeClient +from kernelCI_app.utils import string_to_json +from requests import Response + +client = TreeClient() + + +def request_data(query: dict) -> tuple[Response, dict]: + response = client.get_tree_commits_history_list(query=query) + content = string_to_json(response.content.decode()) + return response, content + + +@pytest.mark.parametrize( + "query, status_code, has_error_body", + [ + ( + {"origin": "maestro", "commit_hashes": "invalid_hash"}, + HTTPStatus.OK, + True, + ), + ( + {"origin": "maestro"}, + HTTPStatus.BAD_REQUEST, + True, + ), + ( + { + "origin": "maestro", + "git_url": "https://android.googlesource.com/kernel/common", + "git_branch": "android-mainline", + "commit_hashes": ",".join( + [ + "ef143cc9d68aecf16ec4942e399e7699266b288f", + "fdf4d20b86285d7b4d1c2d3349a1bd1bc41b24ba", + ] + ), + }, + HTTPStatus.OK, + False, + ), + ], +) +def test_tree_commits_history_list( + query: dict, status_code: HTTPStatus, has_error_body: bool +) -> None: + response, content = request_data(query) + actual_status = response.status_code + if isinstance(status_code, list): + assert actual_status in status_code, ( + f"Expected one of {status_code}, got {actual_status}" + ) + else: + assert_status_code_and_error_response( + response=response, + content=content, + status_code=status_code, + should_error=has_error_body, + ) + + if not has_error_body and actual_status == HTTPStatus.OK: + for commit_data in content: + assert "git_commit_hash" in commit_data + assert "builds" in commit_data + assert "boots" in commit_data + assert "tests" in commit_data + + +@pytest.mark.parametrize( + "query", + [ + ( + { + "origin": "maestro", + "commit_hashes": ",".join( + [ + "ef143cc9d68aecf16ec4942e399e7699266b288f", + "fdf4d20b86285d7b4d1c2d3349a1bd1bc41b24ba", + ] + ), + "types": "builds", + } + ), + ( + { + "origin": "maestro", + "commit_hashes": ",".join( + [ + "ef143cc9d68aecf16ec4942e399e7699266b288f", + "fdf4d20b86285d7b4d1c2d3349a1bd1bc41b24ba", + ] + ), + "types": "tests", + } + ), + ], +) +def test_tree_commits_history_list_with_types(query: dict) -> None: + response, content = request_data(query) + assert_status_code_and_error_response( + response=response, + content=content, + status_code=HTTPStatus.OK, + should_error=False, + ) diff --git a/backend/kernelCI_app/tests/integrationTests/treeCommitsList_test.py b/backend/kernelCI_app/tests/integrationTests/treeCommitsList_test.py new file mode 100644 index 000000000..2578b1e00 --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/treeCommitsList_test.py @@ -0,0 +1,66 @@ +from http import HTTPStatus + +import pytest + +from kernelCI_app.tests.utils.asserts import assert_status_code_and_error_response +from kernelCI_app.tests.utils.client.treeClient import TreeClient +from kernelCI_app.utils import string_to_json +from requests import Response + +client = TreeClient() + + +def request_data(tree_name: str, git_branch: str, query: dict) -> tuple[Response, dict]: + response = client.get_tree_commits_list( + tree_name=tree_name, git_branch=git_branch, query=query + ) + content = string_to_json(response.content.decode()) + return response, content + + +@pytest.mark.parametrize( + "tree_name, git_branch, query, status_code, has_error_body", + [ + ( + "fluster_mainline", + "master", + { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + }, + HTTPStatus.OK, + False, + ), + ( + "nonexistent", + "master", + { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + }, + HTTPStatus.OK, + True, + ), + ], +) +def test_tree_commits_list_view( + tree_name: str, + git_branch: str, + query: dict, + status_code: HTTPStatus, + has_error_body: bool, +) -> None: + response, content = request_data(tree_name, git_branch, query) + assert_status_code_and_error_response( + response=response, + content=content, + status_code=status_code, + should_error=has_error_body, + ) + + assert_status_code_and_error_response( + response=response, + content=content, + status_code=HTTPStatus.OK, + should_error=False, + ) diff --git a/backend/kernelCI_app/tests/unitTests/queries/tree_test.py b/backend/kernelCI_app/tests/unitTests/queries/tree_test.py index 380c5d015..edc3e05ab 100644 --- a/backend/kernelCI_app/tests/unitTests/queries/tree_test.py +++ b/backend/kernelCI_app/tests/unitTests/queries/tree_test.py @@ -2,7 +2,6 @@ from kernelCI_app.queries.tree import ( get_latest_tree, - get_tree_commit_history, get_tree_details_data, get_tree_listing_data, get_tree_listing_data_by_checkout_id, @@ -117,35 +116,6 @@ def test_get_tree_details_data_from_database( mock_set_cache.assert_called_once() -class TestGetTreeCommitHistory: - @patch("kernelCI_app.queries.tree.create_checkouts_where_clauses") - @patch("kernelCI_app.queries.tree.connection") - def test_get_tree_commit_history_success( - self, mock_connection, mock_create_clauses - ): - expected_result = [("abc123", "v6.1", None, "2025-11-10T10:00:00Z")] - mock_create_clauses.return_value = { - "git_branch_clause": "git_repository_branch = %(git_branch_param)s", - "tree_name_clause": "tree_name = %(tree_name)s", - "git_url_clause": "git_repository_url = %(git_url_param)s", - } - mock_cursor = setup_mock_cursor(mock_connection) - mock_cursor.fetchall.return_value = expected_result - - result = get_tree_commit_history( - commit_hash="abc123", - origin="maestro", - git_url="https://my_url.com", - git_branch="master", - tree_name="mainline", - ) - - assert result == expected_result - mock_create_clauses.assert_called_once_with( - git_url="https://my_url.com", git_branch="master", tree_name="mainline" - ) - - class TestGetLatestTree: @patch("kernelCI_app.queries.tree.Checkouts") def test_get_latest_tree_success(self, mock_checkouts_model): diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py index 25fd1f5d5..95123cc65 100644 --- a/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py +++ b/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py @@ -4,9 +4,11 @@ from django.test import SimpleTestCase from rest_framework.test import APIRequestFactory +from kernelCI_app.constants.localization import ClientStrings from kernelCI_app.views.treeCommitsHistory import ( TreeCommitsHistory, TreeCommitsHistoryDirect, + TreeCommitsHistoryList, ) @@ -278,3 +280,130 @@ def test_direct_tree_details_builds_with_hardware_filter_is_not_empty( self.assertEqual(response.status_code, 200) self.assertGreater(len(response.data), 0) self.assertEqual(response.data[0]["builds"]["PASS"], 1) + + +class TestTreeCommitsHistoryList(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = TreeCommitsHistoryList() + self.url = "/api/tree/commits" + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_success(self, mock_get_aggregated): + mock_get_aggregated.return_value = [ + { + "count": 5, + "git_commit_hash": "abc123", + "git_commit_name": "v6.1", + "git_commit_tags": ["v1"], + "start_time": datetime(2026, 2, 1, tzinfo=timezone.utc), + "origin": "maestro", + "status": "PASS", + "compiler_arch": ["gcc", "x86_64"], + "config_name": "defconfig", + "lab": "lab-a", + "environment_compatible": ["hw-a"], + "compatibles": "[]", + "known_issues": ["issue-1,1"], + "is_build": True, + "is_boot": False, + "is_test": False, + } + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "commit_hashes": "abc123,def456", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["git_commit_hash"], "abc123") + self.assertEqual(response.data[0]["builds"]["PASS"], 5) + mock_get_aggregated.assert_called_once() + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_with_types_filter(self, mock_aggregated): + mock_aggregated.return_value = [ + { + "count": 10, + "git_commit_hash": "abc123", + "git_commit_name": "v6.1", + "git_commit_tags": None, + "start_time": datetime(2026, 2, 1, tzinfo=timezone.utc), + "origin": "maestro", + "status": "FAIL", + "compiler_arch": ["gcc", "x86_64"], + "config_name": "defconfig", + "lab": "lab-a", + "environment_compatible": ["hw-a"], + "compatibles": "[]", + "known_issues": None, + "is_build": False, + "is_boot": False, + "is_test": True, + } + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "commit_hashes": "abc123", + "types": "tests", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + mock_aggregated.assert_called_once() + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_with_no_commit_hashes_returns_error( + self, mock_aggregated + ): + request = self.factory.get( + self.url, + { + "origin": "maestro", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 400) + mock_aggregated.assert_not_called() + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_empty_results(self, mock_aggregated): + mock_aggregated.return_value = [] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "commit_hashes": "nonexistent_hash", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"error": ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND}, + ) + mock_aggregated.assert_called_once() diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py new file mode 100644 index 000000000..20b345f1d --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py @@ -0,0 +1,83 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.views.treeCommitsListView import TreeCommitsListView + + +class TestTreeCommitsListView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = TreeCommitsListView() + self.url = "/api/tree/mainline/master/commits" + + @patch("kernelCI_app.views.treeCommitsListView.get_tree_commits") + def test_tree_commits_list_view_success(self, mock_get_commits): + mock_get_commits.return_value = [ + {"git_commit_hash": "abc123", "start_time_end": "2025-11-10T10:00:00Z"} + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + }, + ) + + response = self.view.get( + request, + tree_name="mainline", + git_branch="master", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + + @patch("kernelCI_app.views.treeCommitsListView.get_tree_commits") + def test_tree_commits_list_view_empty(self, mock_get_commits): + mock_get_commits.return_value = [] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + }, + ) + + response = self.view.get( + request, + tree_name="mainline", + git_branch="master", + ) + + self.assertEqual(response.status_code, 200) + assert "error" in response.data + + @patch("kernelCI_app.views.treeCommitsListView.get_tree_commits") + def test_tree_commits_list_view_with_git_url(self, mock_get_commits): + mock_get_commits.return_value = [ + {"git_commit_hash": "abc123", "start_time_end": "2025-11-10T10:00:00Z"} + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git", + }, + ) + + response = self.view.get( + request, + tree_name="mainline", + git_branch="master", + ) + + self.assertEqual(response.status_code, 200) + mock_get_commits.assert_called_once_with( + origin="maestro", + tree_name="mainline", + git_branch="master", + git_url="https://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git", + ) diff --git a/backend/kernelCI_app/tests/utils/client/treeClient.py b/backend/kernelCI_app/tests/utils/client/treeClient.py index db936fb37..9bdea5b57 100644 --- a/backend/kernelCI_app/tests/utils/client/treeClient.py +++ b/backend/kernelCI_app/tests/utils/client/treeClient.py @@ -117,3 +117,21 @@ def get_tree_details_specific_direct( path = reverse(base_path, kwargs=path_params.model_dump()) url = self.get_endpoint(path=path, query=query.model_dump(), filters=filters) return requests.get(url) + + def get_tree_commits_history_list(self, *, query: dict) -> requests.Response: + path = reverse("treeCommitsHistory") + url = self.get_endpoint(path=path, query=query) + return requests.get(url) + + def get_tree_commits_list( + self, *, tree_name: str, git_branch: str, query: dict + ) -> requests.Response: + path = reverse( + "treeCommitsList", + kwargs={ + "tree_name": tree_name, + "git_branch": git_branch, + }, + ) + url = self.get_endpoint(path=path, query=query) + return requests.get(url) diff --git a/backend/kernelCI_app/typeModels/treeCommits.py b/backend/kernelCI_app/typeModels/treeCommits.py index 100d37d5d..997696410 100644 --- a/backend/kernelCI_app/typeModels/treeCommits.py +++ b/backend/kernelCI_app/typeModels/treeCommits.py @@ -63,6 +63,44 @@ class TreeCommitsQueryParameters(DirectTreeCommitsQueryParameters): ) +class TreeCommitsListQueryParameters(BaseModel): + origin: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_ORIGIN_DESCRIPTION + ) + git_url: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_GIT_URL_DESCRIPTION + ) + + +class TreeCommitsHistoryQueryParameters(DirectTreeCommitsQueryParameters): + tree_name: Optional[str] = Field( + None, description=DocStrings.TREE_NAME_PATH_DESCRIPTION + ) + git_branch: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_GIT_BRANCH_DESCRIPTION + ) + git_url: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_GIT_URL_DESCRIPTION + ) + commit_hashes: list[str] = Field( + None, description=DocStrings.TREE_LIST_COMMIT_HASH_DESCRIPTION + ) + + @field_validator("commit_hashes", mode="before") + @classmethod + def validate_commit_hashes(cls, value): + if not value: + return None + if isinstance(value, str): + return [h.strip() for h in value.split(",") if h.strip()] + return value + + +class TreeCommitItem(BaseModel): + git_commit_hash: Checkout__GitCommitHash + last_checkout: Optional[datetime] = Field(None, alias="start_time_end") + + class TreeCommitsData(BaseModel): git_commit_hash: Checkout__GitCommitHash git_commit_name: Checkout__GitCommitName @@ -75,3 +113,7 @@ class TreeCommitsData(BaseModel): class TreeCommitsResponse(RootModel): root: List[TreeCommitsData] + + +class TreeCommitsListResponse(RootModel): + root: List[TreeCommitItem] diff --git a/backend/kernelCI_app/typeModels/treeListing.py b/backend/kernelCI_app/typeModels/treeListing.py index f67f5bc3c..919f6f18b 100644 --- a/backend/kernelCI_app/typeModels/treeListing.py +++ b/backend/kernelCI_app/typeModels/treeListing.py @@ -1,7 +1,8 @@ -from typing import List +from typing import List, Optional from pydantic import BaseModel, Field, RootModel +from kernelCI_app.helpers.logger import log_message from kernelCI_app.typeModels.common import StatusCount from kernelCI_app.typeModels.commonListing import StatusCountV2 from kernelCI_app.typeModels.databases import ( @@ -24,13 +25,13 @@ class TestStatusCount(BaseModel): # Disables automatic pytest test discovery for this class __test__ = False - pass_count: int = Field(alias="pass") - error_count: int = Field(alias="error") - fail_count: int = Field(alias="fail") - skip_count: int = Field(alias="skip") - miss_count: int = Field(alias="miss") - done_count: int = Field(alias="done") - null_count: int = Field(alias="null") + pass_count: int = Field(alias="pass", default=0) + error_count: int = Field(alias="error", default=0) + fail_count: int = Field(alias="fail", default=0) + skip_count: int = Field(alias="skip", default=0) + miss_count: int = Field(alias="miss", default=0) + done_count: int = Field(alias="done", default=0) + null_count: int = Field(alias="null", default=0) def __add__(self, other: "TestStatusCount") -> "TestStatusCount": return TestStatusCount( @@ -45,6 +46,15 @@ def __add__(self, other: "TestStatusCount") -> "TestStatusCount": } ) + def increment(self, status: Optional[str], count: int = 1) -> None: + if status is None: + status = "NULL" + try: + status_prop = f"{status.lower()}_count" + setattr(self, status_prop, getattr(self, status_prop) + count) + except AttributeError: + log_message(f"Unknown status: {status}") + class BaseCheckouts(BaseModel): git_repository_url: Checkout__GitRepositoryUrl diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index dc3891417..6b2ae6d49 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -57,6 +57,16 @@ def view_cache(view): view_cache(views.TreeCommitsHistory), name="treeCommits", ), + path( + "tree/commits-history", + view_cache(views.TreeCommitsHistoryList), + name="treeCommitsHistory", + ), + path( + "tree///commits", + view_cache(views.TreeCommitsListView), + name="treeCommitsList", + ), path( "tree////commits", view_cache(views.TreeCommitsHistoryDirect), diff --git a/backend/kernelCI_app/views/treeCommitsHistory.py b/backend/kernelCI_app/views/treeCommitsHistory.py index 0fe134cbf..a6b1796ac 100644 --- a/backend/kernelCI_app/views/treeCommitsHistory.py +++ b/backend/kernelCI_app/views/treeCommitsHistory.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timezone from http import HTTPStatus from typing import Optional @@ -18,13 +19,19 @@ from kernelCI_app.helpers.filters import ( FilterParams, InvalidComparisonOPError, + is_filtered_out, ) +from kernelCI_app.helpers.issueExtras import parse_issue from kernelCI_app.helpers.logger import log_message from kernelCI_app.helpers.misc import ( handle_misc, misc_value_or_default, ) -from kernelCI_app.queries.tree import get_tree_commit_history +from kernelCI_app.queries.tree import ( + get_tree_commit_history, + get_tree_commit_history_hashes_aggregated, +) +from kernelCI_app.typeModels.common import StatusCount from kernelCI_app.typeModels.commonOpenApiParameters import ( COMMIT_HASH_PATH_PARAM, GIT_BRANCH_PATH_PARAM, @@ -33,10 +40,13 @@ from kernelCI_app.typeModels.databases import FAIL_STATUS, NULL_STATUS, StatusValues from kernelCI_app.typeModels.treeCommits import ( DirectTreeCommitsQueryParameters, + TreeCommitsData, + TreeCommitsHistoryQueryParameters, TreeCommitsQueryParameters, TreeCommitsResponse, TreeEntityTypes, ) +from kernelCI_app.typeModels.treeListing import TestStatusCount from kernelCI_app.utils import is_boot, sanitize_dict @@ -538,3 +548,222 @@ class TreeCommitsHistory(BaseTreeCommitsHistory): ) def get(self, request, commit_hash: str) -> Response: return super().get(request=request, commit_hash=commit_hash) + + +class TreeCommitsHistoryList(BaseTreeCommitsHistory): + def get_filter_type( + self, is_build: bool, is_boot: bool, is_test: bool, **kwargs + ) -> str: + if is_build: + return "build" + if is_boot: + return "boot" + if is_test: + return "test" + raise ValueError("Invalid filter type") + + def filter_instance( + self, + *, + hardware_id: str, + config: str, + lab: str, + compiler: str, + architecture: str, + compatibles: set[str], + status: str, + known_issues: set[str], + is_build: bool, + is_boot: bool, + is_test: bool, + ) -> bool: + filters: FilterParams = self.filterParams + filter_type = self.get_filter_type(is_build, is_boot, is_test) + status_filter_map = { + "build": filters.filterBuildStatus, + "boot": filters.filterBootStatus, + "test": filters.filterTestStatus, + } + if is_filtered_out(status, status_filter_map[filter_type]): + return True + if is_filtered_out(compiler, filters.filterCompiler): + return True + if is_filtered_out(config, filters.filterConfigs): + return True + if is_filtered_out(lab, filters.filter_labs): + return True + if is_filtered_out(architecture, filters.filterArchitecture): + return True + + if filters.filterHardware and filters.filterHardware.isdisjoint(compatibles): + return True + + filtered_issues = filters.filterIssues.get(filter_type, set()) + if filtered_issues and not known_issues.issubset(filtered_issues): + return True + + return False + + def aggregate_commits(self, commit_hashes: list[str], instances: list[dict]): + results = { + commit_hash: TreeCommitsData( + git_commit_hash=commit_hash, + git_commit_name="", + git_commit_tags=[], + earliest_start_time=datetime.now(timezone.utc), + builds=StatusCount(), + boots=TestStatusCount(), + tests=TestStatusCount(), + ) + for commit_hash in commit_hashes + } + + for instance in instances: + count = instance["count"] + commit_hash = instance["git_commit_hash"] + commit_name = instance["git_commit_name"] + status = instance["status"] + config = instance["config_name"] + lab = instance["lab"] + (compiler, architecture) = [ + (val or UNKNOWN_STRING).strip(" []''") # noqa: B005 + for val in (instance["compiler_arch"] or [None, None]) + ] + known_issues = instance["known_issues"] + start_time = instance["start_time"] + status = instance["status"] + commit_tags = set(instance["git_commit_tags"] or []) + known_issues = set( + [parse_issue(issue) for issue in (instance["known_issues"] or [])] + ) + raw_compatibles = instance["compatibles"] or [] + compatibles = ( + {compatible for compatible in json.loads(raw_compatibles)} + if raw_compatibles + else {} + ) + is_build = instance["is_build"] + is_test = instance["is_test"] + is_boot = instance["is_boot"] + + if self.filter_instance( + hardware_id=None, + config=config, + lab=lab, + compiler=compiler, + architecture=architecture, + compatibles=compatibles, + known_issues=known_issues, + status=status, + is_build=is_build, + is_boot=is_boot, + is_test=is_test, + ): + continue + + data = results[commit_hash] + data.git_commit_hash = commit_hash + data.git_commit_name = commit_name + data.git_commit_tags = list({*data.git_commit_tags, *commit_tags}) + data.earliest_start_time = min(data.earliest_start_time, start_time) + if is_build: + data.builds.increment(status, count) + elif is_boot: + data.boots.increment(status, count) + else: + data.tests.increment(status, count) + return results + + @extend_schema( + responses=TreeCommitsResponse, + parameters=[TreeCommitsHistoryQueryParameters], + methods=["GET"], + ) + def get(self, request: HttpRequest) -> Response: + try: + params = TreeCommitsHistoryQueryParameters( + origin=request.GET.get("origin"), + git_url=request.GET.get("git_url"), + tree_name=request.GET.get("tree_name"), + git_branch=request.GET.get("git_branch"), + commit_hashes=request.GET.get("commit_hashes"), + start_timestamp_in_seconds=request.GET.get( + "start_timestamp_in_seconds" + ), + end_timestamp_in_seconds=request.GET.get("end_timestamp_in_seconds"), + types=request.GET.get("types"), + builds_related_to_filtered_tests_only=request.GET.get( + "builds_related_to_filtered_tests_only", False + ), + ) + except ValidationError as e: + return create_api_error_response(error_message=e.json()) + + commit_hashes = params.commit_hashes + if not commit_hashes: + return create_api_error_response( + error_message="commit_hashes query parameter is required", + status_code=HTTPStatus.BAD_REQUEST, + ) + + start_timestamp = params.start_timestamp_in_seconds + end_timestamp = params.end_timestamp_in_seconds + if None not in (start_timestamp, end_timestamp): + self._process_time_range( + start_timestamp=start_timestamp, end_timestamp=end_timestamp + ) + + try: + self.filterParams = FilterParams(request) + self.setup_filters() + except InvalidComparisonOPError as e: + return create_api_error_response(error_message=str(e)) + + self.builds_related_to_filtered_tests_only = ( + params.builds_related_to_filtered_tests_only + ) + + include_types = params.types + if params.types == ["builds"] and ( + self.builds_related_to_filtered_tests_only or len(self.filterHardware) > 0 + ): + include_types = ["builds", "boots", "tests"] + + instances = get_tree_commit_history_hashes_aggregated( + commit_hashes=commit_hashes, + origin=params.origin, + git_url=params.git_url, + git_branch=params.git_branch, + tree_name=params.tree_name, + include_types=include_types, + platform_filter=list(self.filterParams.filterHardware), + builds_duration=( + self.filterParams.filterBuildDurationMin, + self.filterParams.filterBuildDurationMax, + ), + boots_duration=( + self.filterParams.filterBootDurationMin, + self.filterParams.filterBootDurationMax, + ), + tests_duration=( + self.filterParams.filterTestDurationMin, + self.filterParams.filterTestDurationMax, + ), + ) + + if not instances: + return create_api_error_response( + error_message=ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND, + status_code=HTTPStatus.OK, + ) + + results = self.aggregate_commits(commit_hashes, instances) + + try: + valid_response = TreeCommitsResponse( + root=[results[commit_hash] for commit_hash in commit_hashes] + ) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return Response(valid_response.model_dump(by_alias=True)) diff --git a/backend/kernelCI_app/views/treeCommitsListView.py b/backend/kernelCI_app/views/treeCommitsListView.py new file mode 100644 index 000000000..50cd68c82 --- /dev/null +++ b/backend/kernelCI_app/views/treeCommitsListView.py @@ -0,0 +1,51 @@ +from http import HTTPStatus + +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.constants.localization import ClientStrings +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.queries.tree import get_tree_commits +from kernelCI_app.typeModels.treeCommits import ( + TreeCommitsListQueryParameters, + TreeCommitsListResponse, +) + + +class TreeCommitsListView(APIView): + @extend_schema( + parameters=[TreeCommitsListQueryParameters], + responses=TreeCommitsListResponse, + ) + def get(self, request: HttpRequest, tree_name: str, git_branch: str) -> Response: + + try: + query_params = TreeCommitsListQueryParameters( + origin=request.GET.get("origin"), + git_url=request.GET.get("git_url"), + ) + except ValidationError: + return create_api_error_response( + status_code=HTTPStatus.BAD_REQUEST, + error_message=ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND, + ) + + commits = get_tree_commits( + origin=query_params.origin, + tree_name=tree_name, + git_branch=git_branch, + git_url=query_params.git_url, + ) + + if not commits: + return create_api_error_response( + status_code=HTTPStatus.OK, + error_message=ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND, + ) + + result = TreeCommitsListResponse(root=commits) + + return Response(data=result.model_dump(), status=HTTPStatus.OK) diff --git a/backend/schema.yml b/backend/schema.yml index dc10acfa1..ba606c1bc 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -1552,7 +1552,7 @@ paths: description: '' /api/tree/{tree_name}/{git_branch}/{commit_hash}/commits: get: - operationId: tree_commits_retrieve_2 + operationId: tree_commits_retrieve_3 parameters: - in: query name: builds_related_to_filtered_tests_only @@ -1763,6 +1763,148 @@ paths: schema: $ref: '#/components/schemas/CommonDetailsTestsResponse' description: '' + /api/tree/{tree_name}/{git_branch}/commits: + get: + operationId: tree_commits_retrieve_2 + parameters: + - in: path + name: git_branch + schema: + type: string + required: true + - in: query + name: git_url + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Url + description: Git repository URL to retrieve the tree commits + - in: query + name: origin + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Origin + description: Origin to retrieve the tree commits + - in: path + name: tree_name + schema: + type: string + required: true + tags: + - tree + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TreeCommitsListResponse' + description: '' + /api/tree/commits-history: + get: + operationId: tree_commits_history_retrieve + parameters: + - in: query + name: builds_related_to_filtered_tests_only + schema: + default: false + title: Builds Related To Filtered Tests Only + type: boolean + description: When true, and requesting only builds, count only builds related + to tests/boots that pass the current filters. + - in: query + name: commit_hashes + schema: + default: null + items: + type: string + title: Commit Hashes + type: array + description: Comma-separated list of commit hashes to get history for + - in: query + name: end_timestamp_in_seconds + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: End Timestamp In Seconds + description: End time filter in seconds for tree commits + - in: query + name: git_branch + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Branch + description: Git branch name to retrieve the tree commits + - in: query + name: git_url + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Url + description: Git repository URL to retrieve the tree commits + - in: query + name: origin + schema: + default: maestro + title: Origin + type: string + description: Origin to retrieve the tree commits + - in: query + name: start_timestamp_in_seconds + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Start Timestamp In Seconds + description: Start time filter in seconds for tree commits + - in: query + name: tree_name + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Tree Name + description: Name of the tree + - in: query + name: types + schema: + anyOf: + - items: + $ref: '#/components/schemas/TreeEntityTypes' + type: array + - type: 'null' + default: null + title: Types + description: List of types to include (builds, boots, tests) + tags: + - tree + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TreeCommitsResponse' + description: '' components: schemas: BuildArchitectures: @@ -3707,34 +3849,33 @@ components: TestStatusCount: properties: pass: + default: 0 title: Pass type: integer error: + default: 0 title: Error type: integer fail: + default: 0 title: Fail type: integer skip: + default: 0 title: Skip type: integer miss: + default: 0 title: Miss type: integer done: + default: 0 title: Done type: integer 'null': + default: 0 title: 'Null' type: integer - required: - - pass - - error - - fail - - skip - - miss - - done - - 'null' title: TestStatusCount type: object TestStatusHistoryItem: @@ -3970,6 +4111,21 @@ components: - is_selected title: Tree type: object + TreeCommitItem: + properties: + git_commit_hash: + $ref: '#/components/schemas/Checkout__GitCommitHash' + start_time_end: + anyOf: + - format: date-time + type: string + - type: 'null' + default: null + title: Start Time End + required: + - git_commit_hash + title: TreeCommitItem + type: object TreeCommitsData: properties: git_commit_hash: @@ -3998,6 +4154,11 @@ components: - tests title: TreeCommitsData type: object + TreeCommitsListResponse: + items: + $ref: '#/components/schemas/TreeCommitItem' + title: TreeCommitsListResponse + type: array TreeCommitsResponse: items: $ref: '#/components/schemas/TreeCommitsData' diff --git a/dashboard/src/api/commitHistory.ts b/dashboard/src/api/commitHistory.ts index 6123946f9..bfd15045d 100644 --- a/dashboard/src/api/commitHistory.ts +++ b/dashboard/src/api/commitHistory.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { treeDetailsDirectRouteName } from '@/types/tree/TreeDetails'; import type { + TreeCommitsResponse, TreeDetailsRouteFrom, TTreeCommitHistoryResponse, TTreeDetailsFilter, @@ -16,7 +17,7 @@ import type { TFilter, TreeEntityTypes } from '@/types/general'; import { RequestData } from './commonRequest'; const fetchCommitHistory = async ( - commitHash: string, + commitHash: string | string[], origin: string, gitUrl: string, gitBranch: string, @@ -41,6 +42,20 @@ const fetchCommitHistory = async ( ...filtersFormatted, }; + // TODO: may be create a new function??? + if (Array.isArray(commitHash)) { + return await RequestData.get( + '/api/tree/commits-history', + { + params: { + ...params, + tree_name: treeName, + commit_hashes: commitHash.join(), + }, + }, + ); + } + const baseUrl = treeUrlFrom === treeDetailsDirectRouteName ? `/api/tree/${treeName}/${gitBranch}/${commitHash}` @@ -69,7 +84,7 @@ export const useCommitHistory = ({ types, buildsRelatedToFilteredTestsOnly, }: { - commitHash: string; + commitHash: string | string[]; origin: string; gitUrl: string; gitBranch: string; @@ -118,5 +133,40 @@ export const useCommitHistory = ({ types, buildsRelatedToFilteredTestsOnly, ), + enabled: !!commitHash?.length, + }); +}; + +const fetchCommits = async ( + origin: string, + gitUrl: string, + gitBranch: string, + treeName?: string, +): Promise => { + const params = { + origin, + git_url: gitUrl, + }; + + const url = `/api/tree/${treeName}/${gitBranch}/commits`; + const data = await RequestData.get(url, { params }); + + return data; +}; + +export const useCommits = ({ + origin, + gitUrl, + gitBranch, + treeName, +}: { + origin: string; + gitUrl: string; + gitBranch: string; + treeName?: string; +}): UseQueryResult => { + return useQuery({ + queryKey: ['treeCommits', origin, gitUrl, gitBranch, treeName], + queryFn: () => fetchCommits(origin, gitUrl, gitBranch, treeName), }); }; diff --git a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx index 8659655e9..57aff8e66 100644 --- a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx +++ b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx @@ -16,13 +16,13 @@ import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; import type { MessagesKey } from '@/locales/messages'; import { formatDate } from '@/utils/utils'; import { mapFilterToReq } from '@/components/Tabs/Filters'; -import { useCommitHistory } from '@/api/commitHistory'; +import { useCommitHistory, useCommits } from '@/api/commitHistory'; import type { TFilter, TreeEntityTypes } from '@/types/general'; import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; import type { gitValues } from '@/components/Tooltip/CommitTagTooltip'; -import type { TreeDetailsRouteFrom } from '@/types/tree/TreeDetails'; +import type { Commit, TreeDetailsRouteFrom } from '@/types/tree/TreeDetails'; const graphDisplaySize = 8; @@ -61,6 +61,19 @@ interface ICommitNavigationGraph { buildsRelatedToFilteredTestsOnly?: boolean; } +const selectedCommits = ( + allCommits: Commit[] | undefined, + headCommit: string | undefined, +): string[] => { + allCommits = allCommits || []; + headCommit = headCommit || ''; + const NUM_SELECTED_COMMITS = 6; + const headIndex = allCommits.findIndex(x => x.git_commit_hash === headCommit); + return allCommits + .slice(headIndex, headIndex + NUM_SELECTED_COMMITS) + .map(x => x.git_commit_hash); +}; + const CommitNavigationGraph = ({ origin, currentPageTab, @@ -93,10 +106,17 @@ const CommitNavigationGraph = ({ } }, [currentPageTab]); + const commitsList = useCommits({ + gitBranch: gitBranch ?? '', + gitUrl: gitUrl ?? '', + origin: origin, + treeName, + }); + const { data, status, error, isLoading } = useCommitHistory({ gitBranch: gitBranch ?? '', gitUrl: gitUrl ?? '', - commitHash: headCommitHash ?? '', + commitHash: selectedCommits(commitsList.data, headCommitHash), origin: origin, filter: reqFilter, endTimestampInSeconds, diff --git a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx index 864c4db7b..9bc275961 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx @@ -259,6 +259,7 @@ const BootsTab = ({ key="commitGraph" urlFrom={urlFrom} treeName={sanitizedTreeInfo.treeName} + summaryTreeUrl={summaryData?.common.tree_url} />, ], bodyCards: [ @@ -316,6 +317,7 @@ const BootsTab = ({ hardwareData, sanitizedTreeInfo, summaryBootsData, + summaryData?.common.tree_url, toggleFilterBySection, treeDetailsLazyLoaded.issuesExtras, urlFrom, @@ -371,6 +373,7 @@ const BootsTab = ({ )} diff --git a/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx index 990886c4b..916ada30e 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx @@ -209,6 +209,7 @@ const BuildTab = ({ key="commitGraph" urlFrom={urlFrom} treeName={sanitizedTreeInfo.treeName} + summaryTreeUrl={summaryData?.common.tree_url} />, ], bodyCards: [ @@ -252,6 +253,7 @@ const BuildTab = ({ }, [ diffFilter, sanitizedTreeInfo, + summaryData?.common.tree_url, toggleFilterBySection, treeDetailsData, treeDetailsLazyLoaded.issuesExtras, @@ -319,6 +321,7 @@ const BuildTab = ({ )} diff --git a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx index f961b89db..9bcd96765 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx @@ -257,6 +257,7 @@ const TestsTab = ({ key="commitGraph" urlFrom={urlFrom} treeName={sanitizedTreeInfo.treeName} + summaryTreeUrl={summaryData?.common.tree_url} />, ], bodyCards: [ @@ -314,6 +315,7 @@ const TestsTab = ({ hardwareData, sanitizedTreeInfo, summaryTestsData, + summaryData?.common.tree_url, toggleFilterBySection, treeDetailsLazyLoaded.issuesExtras, urlFrom, @@ -370,6 +372,7 @@ const TestsTab = ({ )} diff --git a/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx b/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx index 7461e61c1..f4d3cd58b 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx @@ -17,9 +17,11 @@ import { sanitizeTreeinfo } from '@/utils/treeDetails'; const TreeCommitNavigationGraph = ({ urlFrom, treeName, + summaryTreeUrl, }: { urlFrom: TreeDetailsRouteFrom; treeName?: string; + summaryTreeUrl?: string; }): React.ReactNode => { const { origin, currentPageTab, diffFilter, treeInfo } = useSearch({ from: urlFrom, @@ -32,8 +34,13 @@ const TreeCommitNavigationGraph = ({ const sanitizedTreeInfo = useMemo((): TTreeInformation & { hash: string; } => { - return sanitizeTreeinfo({ treeInfo, params, urlFrom }); - }, [params, treeInfo, urlFrom]); + return sanitizeTreeinfo({ + treeInfo, + params, + urlFrom, + summaryUrl: summaryTreeUrl, + }); + }, [params, summaryTreeUrl, treeInfo, urlFrom]); const navigate = useNavigate({ from: treeDetailsFromMap[urlFrom], diff --git a/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx b/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx index 35947f846..9869ddc0f 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx @@ -60,6 +60,7 @@ const HardwareCommitNavigationGraph = ({ gitBranch={tree.git_repository_branch} gitUrl={tree.git_repository_url} treeId={treeId} + treeName={tree.tree_name} headCommitHash={tree.head_git_commit_hash} onMarkClick={markClickHandle} diffFilter={diffFilterWithHardware} diff --git a/dashboard/src/types/tree/TreeDetails.tsx b/dashboard/src/types/tree/TreeDetails.tsx index 559b3e0c3..b3afc6702 100644 --- a/dashboard/src/types/tree/TreeDetails.tsx +++ b/dashboard/src/types/tree/TreeDetails.tsx @@ -184,6 +184,11 @@ export type PaginatedCommitHistoryByTree = { tests: TableTestStatus; }; +export type Commit = { + git_commit_hash: string; + earliest_checkout: string; +}; + export type BuildCountsResponse = { log_excerpt?: string; build_counts: { @@ -212,6 +217,8 @@ export type LogFilesResponse = { export type TTreeCommitHistoryResponse = PaginatedCommitHistoryByTree[]; +export type TreeCommitsResponse = Commit[]; + // TODO: These variables could be defined in the route files but it would cause // a circular dependency, requiring rewiring of the imports. export const treeDetailsDirectRouteName = '/_main/tree/$treeName/$branch/$hash';