diff --git a/Makefile b/Makefile index d5c4b5e36..59d2a4d82 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,9 @@ ci: ## Reproduce full CI pipeline (lint + build + test + integration) dev: ## Start development environment with docker-compose.dev.yml docker compose -f docker-compose.dev.yml up -d +dev-down: ## Stop development environment with docker-compose.dev.yml + docker compose -f docker-compose.dev.yml down + dev-build: ## Start development environment and rebuild images docker compose -f docker-compose.dev.yml up --build -d diff --git a/backend/kernelCI_app/helpers/treeCompare.py b/backend/kernelCI_app/helpers/treeCompare.py new file mode 100644 index 000000000..9cceb4765 --- /dev/null +++ b/backend/kernelCI_app/helpers/treeCompare.py @@ -0,0 +1,404 @@ +from dataclasses import dataclass, field +from typing import Literal, Optional + +from kernelCI_app.constants.general import UNKNOWN_STRING +from kernelCI_app.constants.process_pending import ROLLUP_STATUS_FIELDS +from kernelCI_app.helpers.filters import FilterParams +from kernelCI_app.typeModels.databases import NULL_STATUS +from kernelCI_app.typeModels.treeCompare import ( + CompareChangeCounts, + CompareDelta, + CompareEntitySummary, + CompareStatusCounts, + CompareSummary, + TreeCompareResponse, +) + +BucketKey = Literal["pass", "fail", "inconclusive"] + + +@dataclass(frozen=True) +class CompareFilterSql: + """Identity dims are safe pre-join; status must be applied after the A/B pair.""" + + pre_join: str = "" + post_join: str = "" + params: dict = field(default_factory=dict) + + +def _to_grouped_status_filters(statuses: set[str]) -> list[str]: + """Map raw FilterParams statuses onto compare PASS/FAIL/INCONCLUSIVE buckets.""" + grouped: set[str] = set() + for status in statuses: + if status == NULL_STATUS: + grouped.add("INCONCLUSIVE") + continue + upper = status.upper() + if upper == "PASS": + grouped.add("PASS") + elif upper == "FAIL": + grouped.add("FAIL") + else: + grouped.add("INCONCLUSIVE") + return list(grouped) + + +def _post_join_status_clause( + statuses: set[str], + *, + param_key: str, + params: dict, +) -> str: + grouped = _to_grouped_status_filters(statuses) + if not grouped: + return "" + params[param_key] = grouped + return ( + f"AND (a.grouped_status = ANY(%({param_key})s)" + f" OR b.grouped_status = ANY(%({param_key})s))" + ) + + +def build_build_compare_filter_clauses( # noqa: C901 - maps FilterParams fields to SQL AND clauses + filters: Optional["FilterParams"], +) -> CompareFilterSql: + """Build compare filters for builds. + + Identity filters (config/arch/compiler/…) apply before the A/B join. + Status filters apply after pairing so PASS→FAIL is not rewritten as newFailure. + Duration/issue filters are skipped on compare: they need per-side columns we + do not select on the paired row (ponytail: wire post-join when those land). + """ + if filters is None: + return CompareFilterSql() + + pre_join: list[str] = [] + params: dict = {} + + if filters.filter_build_origin: + pre_join.append("AND b.origin = ANY(%(build_origins)s)") + params["build_origins"] = list(filters.filter_build_origin) + + if filters.filterConfigs: + pre_join.append( + "AND COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s)" + " = ANY(%(configs)s)" + ) + params["configs"] = list(filters.filterConfigs) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterArchitecture: + pre_join.append( + "AND COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s)" + " = ANY(%(architectures)s)" + ) + params["architectures"] = list(filters.filterArchitecture) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterCompiler: + pre_join.append( + "AND COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s)" + " = ANY(%(compilers)s)" + ) + params["compilers"] = list(filters.filterCompiler) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filter_labs: + pre_join.append( + "AND COALESCE(NULLIF(b.misc->>'lab', ''), %(unknown_string)s)" + " = ANY(%(labs)s)" + ) + params["labs"] = list(filters.filter_labs) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterHardware: + # Keep hardware filter as EXISTS so we don't multiply build rows by tests + pre_join.append( + """ + AND EXISTS ( + SELECT 1 FROM tests ht + WHERE ht.build_id = b.id + AND ( + ht.environment_compatible && %(hardware)s::text[] + OR ht.environment_misc->>'platform' = ANY(%(hardware)s::text[]) + ) + ) + """ + ) + params["hardware"] = list(filters.filterHardware) + + post_join = "" + if filters.filterBuildStatus: + post_join = _post_join_status_clause( + filters.filterBuildStatus, + param_key="grouped_build_statuses", + params=params, + ) + + return CompareFilterSql( + pre_join="\n".join(pre_join), + post_join=post_join, + params=params, + ) + + +def build_boot_test_compare_filter_clauses( # noqa: C901 - maps FilterParams fields to SQL AND clauses + filters: Optional["FilterParams"], + data_type: Literal["boots", "tests"], +) -> CompareFilterSql: + """Build compare filters for boots/tests (identity pre-join, status post-join). + + Duration/issue filters are skipped on compare for the same reason as builds + (ponytail: need paired-row columns before they can be filtered safely). + """ + if filters is None: + return CompareFilterSql() + + tab: Literal["boot", "test"] = "boot" if data_type == "boots" else "test" + pre_join: list[str] = [] + params: dict = {} + + path_filter = ( + filters.filterBootPath if data_type == "boots" else filters.filterTestPath + ) + if path_filter: + pre_join.append("AND COALESCE(t.path, '') LIKE '%%' || %(test_path)s || '%%'") + params["test_path"] = path_filter + + origin_filter = ( + filters.filter_boot_origin + if data_type == "boots" + else filters.filter_test_origin + ) + if origin_filter: + pre_join.append( + "AND COALESCE(NULLIF(t.origin, ''), %(unknown_string)s)" + " = ANY(%(test_origins)s)" + ) + params["test_origins"] = list(origin_filter) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterPlatforms[tab]: + pre_join.append( + "AND COALESCE(NULLIF(t.environment_misc->>'platform', '')," + " %(unknown_string)s) = ANY(%(platforms)s)" + ) + params["platforms"] = list(filters.filterPlatforms[tab]) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterConfigs: + pre_join.append( + "AND COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s)" + " = ANY(%(configs)s)" + ) + params["configs"] = list(filters.filterConfigs) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterArchitecture: + pre_join.append( + "AND COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s)" + " = ANY(%(architectures)s)" + ) + params["architectures"] = list(filters.filterArchitecture) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterCompiler: + pre_join.append( + "AND COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s)" + " = ANY(%(compilers)s)" + ) + params["compilers"] = list(filters.filterCompiler) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filter_labs: + pre_join.append( + "AND COALESCE(NULLIF(t.misc->>'runtime', ''), %(unknown_string)s)" + " = ANY(%(labs)s)" + ) + params["labs"] = list(filters.filter_labs) + params["unknown_string"] = UNKNOWN_STRING + + if filters.filterHardware: + pre_join.append( + """ + AND ( + t.environment_compatible && %(hardware)s::text[] + OR t.environment_misc->>'platform' = ANY(%(hardware)s::text[]) + ) + """ + ) + params["hardware"] = list(filters.filterHardware) + + status_filter = ( + filters.filterBootStatus if data_type == "boots" else filters.filterTestStatus + ) + post_join = "" + if status_filter: + post_join = _post_join_status_clause( + status_filter, + param_key="grouped_test_statuses", + params=params, + ) + + return CompareFilterSql( + pre_join="\n".join(pre_join), + post_join=post_join, + params=params, + ) + + +@dataclass +class _HashAccumulator: + builds: CompareStatusCounts = field(default_factory=CompareStatusCounts) + boots: CompareStatusCounts = field(default_factory=CompareStatusCounts) + tests: CompareStatusCounts = field(default_factory=CompareStatusCounts) + + +def _status_to_bucket(status: Optional[str]) -> BucketKey: + if status is None: + return "inconclusive" + normalized = status.upper() + if normalized == "PASS": + return "pass" + if normalized == "FAIL": + return "fail" + return "inconclusive" + + +def _increment_bucket( + counts: CompareStatusCounts, bucket: BucketKey, amount: int +) -> None: + if amount <= 0: + return + if bucket == "pass": + counts.pass_count += amount + elif bucket == "fail": + counts.fail_count += amount + else: + counts.inconclusive += amount + + +def _rollup_status_to_bucket(status_name: str) -> BucketKey: + if status_name == "PASS": + return "pass" + if status_name == "FAIL": + return "fail" + return "inconclusive" + + +def process_rollup_rows( + *, + rows: list[dict], + commit_hashes: list[str], +) -> dict[str, _HashAccumulator]: + accumulators = {commit_hash: _HashAccumulator() for commit_hash in commit_hashes} + + for row_dict in rows: + commit_hash = row_dict["git_commit_hash"] + is_boot_row = row_dict["is_boot"] + acc = accumulators.setdefault(commit_hash, _HashAccumulator()) + target = acc.boots if is_boot_row else acc.tests + + for status_name, field_name in ROLLUP_STATUS_FIELDS.items(): + count = row_dict.get(field_name, 0) or 0 + if count <= 0: + continue + bucket = _rollup_status_to_bucket(status_name) + _increment_bucket(target, bucket, count) + + return accumulators + + +def process_build_rows( + *, + rows: list[dict], + commit_hashes: list[str], +) -> dict[str, _HashAccumulator]: + accumulators = {commit_hash: _HashAccumulator() for commit_hash in commit_hashes} + + for row in rows: + commit_hash = row["git_commit_hash"] + count = row.get("count") or 0 + acc = accumulators.setdefault(commit_hash, _HashAccumulator()) + bucket = _status_to_bucket(row.get("status")) + _increment_bucket(acc.builds, bucket, count) + + return accumulators + + +def _make_delta( + side_a: CompareStatusCounts, side_b: CompareStatusCounts +) -> CompareDelta: + return CompareDelta( + **{ + "pass": side_b.pass_count - side_a.pass_count, + "fail": side_b.fail_count - side_a.fail_count, + } + ) + + +def change_counts_from_row(row: Optional[dict]) -> CompareChangeCounts: + """Map a SQL aggregate row (snake_case keys) into CompareChangeCounts.""" + if not row: + return CompareChangeCounts() + return CompareChangeCounts( + regression=row.get("regression") or 0, + fixed=row.get("fixed") or 0, + new_failure=row.get("new_failure") or 0, + still_failing=row.get("still_failing") or 0, + new_pass=row.get("new_pass") or 0, + appeared=row.get("appeared") or 0, + disappeared=row.get("disappeared") or 0, + ) + + +def _make_entity_summary( + *, + side_a: CompareStatusCounts, + side_b: CompareStatusCounts, + changes: Optional[CompareChangeCounts] = None, +) -> CompareEntitySummary: + return CompareEntitySummary( + sideA=side_a, + sideB=side_b, + delta=_make_delta(side_a, side_b), + changes=changes or CompareChangeCounts(), + ) + + +def build_compare_response( + *, + hash_a: str, + hash_b: str, + tree_name: str, + branch: str, + git_url: str, + accumulators: dict[str, _HashAccumulator], + changes: Optional[dict[str, CompareChangeCounts]] = None, +) -> TreeCompareResponse: + acc_a = accumulators.get(hash_a, _HashAccumulator()) + acc_b = accumulators.get(hash_b, _HashAccumulator()) + change_map = changes or {} + + return TreeCompareResponse( + treeName=tree_name, + branch=branch, + gitUrl=git_url, + summary=CompareSummary( + builds=_make_entity_summary( + side_a=acc_a.builds, + side_b=acc_b.builds, + changes=change_map.get("builds"), + ), + boots=_make_entity_summary( + side_a=acc_a.boots, + side_b=acc_b.boots, + changes=change_map.get("boots"), + ), + tests=_make_entity_summary( + side_a=acc_a.tests, + side_b=acc_b.tests, + changes=change_map.get("tests"), + ), + ), + ) diff --git a/backend/kernelCI_app/queries/tree.py b/backend/kernelCI_app/queries/tree.py index 52f755a1f..8fc1e4818 100644 --- a/backend/kernelCI_app/queries/tree.py +++ b/backend/kernelCI_app/queries/tree.py @@ -1,10 +1,15 @@ -from typing import Literal, Optional +from typing import TYPE_CHECKING, Literal, Optional from django.db import connection from django.db.models import Q from kernelCI_app.cache import get_query_cache, set_query_cache +from kernelCI_app.constants.general import UNKNOWN_STRING from kernelCI_app.helpers.database import dict_fetchall +from kernelCI_app.helpers.treeCompare import ( + build_boot_test_compare_filter_clauses, + build_build_compare_filter_clauses, +) from kernelCI_app.helpers.treeDetails import create_checkouts_where_clauses from kernelCI_app.models import Checkouts from kernelCI_app.queries.duration import ( @@ -12,6 +17,9 @@ get_build_duration_clause, ) +if TYPE_CHECKING: + from kernelCI_app.helpers.filters import FilterParams + def _get_tree_listing_count_clause() -> str: build_count_clause = """ @@ -565,6 +573,9 @@ def get_tree_data( issues."_timestamp" DESC """ + print(query) + print(params) + with connection.cursor() as cursor: cursor.execute(query, params) rows = cursor.fetchall() @@ -750,20 +761,26 @@ def get_tree_commits( url_clause = "\nAND git_repository_url = %(git_repository_url)s" query = f""" - select + SELECT git_commit_hash, - max(start_time) as start_time_end - from + MAX(start_time) AS start_time_end, + MAX(git_commit_name) AS git_commit_name, + COALESCE( + ARRAY_AGG(DISTINCT tag) FILTER (WHERE tag IS NOT NULL), + ARRAY[]::text[] + ) AS git_commit_tags + FROM checkouts - where + LEFT JOIN LATERAL unnest(git_commit_tags) AS tag ON true + WHERE tree_name = %(tree_name)s - and git_repository_branch = %(git_branch)s + AND git_repository_branch = %(git_branch)s {url_clause} {origin_clause} - group by + GROUP BY git_commit_hash - order by - start_time_end desc; + ORDER BY + start_time_end DESC; """ with connection.cursor() as cursor: @@ -977,6 +994,174 @@ def get_tree_commit_history_hashes_aggregated( return rows +def get_tree_compare_boots_tests_diff( + *, + data_type: Literal["boots", "tests"], + hash_a: str, + hash_b: str, + origin: str, + git_branch: str, + tree_name: str, + filters: Optional["FilterParams"] = None, + boots_duration: tuple[Optional[int], Optional[int]] = (None, None), + tests_duration: tuple[Optional[int], Optional[int]] = (None, None), +) -> list[dict]: + """Return boot/test rows whose latest grouped status differs between commits. + + Identity key: path + config_name + architecture + platform. + Uses the latest checkout per hash and the latest test row per identity key + (same latest-wins semantics as get_tree_compare_builds_diff). + + Duration kwargs are accepted for call-site compat but ignored: filtering by + duration before the A/B join invents false appeared/disappeared rows. + """ + del ( + boots_duration, + tests_duration, + ) # ponytail: post-join duration needs selected cols + commit_hashes = [hash_a, hash_b] + filter_sql = build_boot_test_compare_filter_clauses(filters, data_type) + + params = { + "hash_a": hash_a, + "hash_b": hash_b, + "commit_hashes": commit_hashes, + "origin_param": origin, + "git_branch_param": git_branch, + "tree_name": tree_name, + "unknown_string": UNKNOWN_STRING, + **filter_sql.params, + } + + cache_key = "treeCompareBootsTestsDiff" + cache_params = { + **params, + "data_type": data_type, + "commit_hashes": tuple(sorted(commit_hashes)), + "filter_pre_join": filter_sql.pre_join, + "filter_post_join": filter_sql.post_join, + } + rows = get_query_cache(cache_key, cache_params) + if rows is not None: + return rows + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch, + tree_name=tree_name, + ) + + if data_type == "boots": + path_filter = "AND (t.path = 'boot' OR t.path LIKE 'boot.%%')" + else: + path_filter = "AND t.path IS DISTINCT FROM 'boot' AND t.path NOT LIKE 'boot.%%'" + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.id AS checkout_id, + c.git_commit_hash + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ), + TEST_ROWS AS ( + SELECT DISTINCT ON ( + rc.git_commit_hash, + COALESCE(NULLIF(t.path, ''), %(unknown_string)s), + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE( + NULLIF(t.environment_misc->>'platform', ''), + %(unknown_string)s + ) + ) + rc.git_commit_hash, + COALESCE(NULLIF(t.path, ''), %(unknown_string)s) AS path, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s) + AS config_name, + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s) + AS architecture, + COALESCE( + NULLIF(t.environment_misc->>'platform', ''), + %(unknown_string)s + ) AS platform, + CASE + WHEN UPPER(t.status) = 'PASS' THEN 'PASS' + WHEN UPPER(t.status) = 'FAIL' THEN 'FAIL' + ELSE 'INCONCLUSIVE' + END AS grouped_status + FROM + RELEVANT_CHECKOUTS rc + INNER JOIN builds b ON b.checkout_id = rc.checkout_id + INNER JOIN tests t ON t.build_id = b.id + {path_filter} + WHERE + b.id NOT LIKE 'maestro:dummy_%%' + {filter_sql.pre_join} + ORDER BY + rc.git_commit_hash, + COALESCE(NULLIF(t.path, ''), %(unknown_string)s), + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE( + NULLIF(t.environment_misc->>'platform', ''), + %(unknown_string)s + ), + t.start_time DESC NULLS LAST, + t._timestamp DESC NULLS LAST + ), + SIDE_A AS ( + SELECT path, config_name, architecture, platform, grouped_status + FROM TEST_ROWS + WHERE git_commit_hash = %(hash_a)s + ), + SIDE_B AS ( + SELECT path, config_name, architecture, platform, grouped_status + FROM TEST_ROWS + WHERE git_commit_hash = %(hash_b)s + ) + SELECT + COALESCE(a.path, b.path) AS path, + COALESCE(a.config_name, b.config_name) AS config_name, + COALESCE(a.architecture, b.architecture) AS architecture, + COALESCE(a.platform, b.platform) AS platform, + a.grouped_status AS status_a, + b.grouped_status AS status_b + FROM + SIDE_A a + FULL OUTER JOIN SIDE_B b ON ( + a.path = b.path + AND a.config_name = b.config_name + AND a.architecture = b.architecture + AND a.platform = b.platform + ) + WHERE + ( + a.grouped_status IS DISTINCT FROM b.grouped_status + OR ( + a.grouped_status = 'FAIL' + AND b.grouped_status = 'FAIL' + ) + ) + {filter_sql.post_join} + ORDER BY + 1, 2, 3, 4 + """ + + 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, @@ -1204,3 +1389,638 @@ def get_latest_tree( query = query.order_by("-start_time").first() return query + + +def _get_compare_checkout_clauses( + *, + git_branch_param: Optional[str], + tree_name: Optional[str], +) -> tuple[str, str]: + checkout_clauses = create_checkouts_where_clauses( + git_url=None, + git_branch=git_branch_param, + 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 = "AND " + tree_name_clause if tree_name_clause else "" + + return git_branch_clause, tree_name_full_clause + + +def get_tree_compare_rollup( + *, + commit_hashes: list[str], + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> list[dict]: + if not commit_hashes: + return [] + + cache_key = "treeCompareRollup" + params = { + "commit_hashes": commit_hashes, + "tree_name": tree_name, + "origin_param": origin_param, + "git_branch_param": git_branch_param, + } + + rows = get_query_cache(cache_key, params) + if rows is not None: + return rows + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.git_commit_hash, + c.tree_name, + c.git_repository_branch, + c.git_repository_url, + c.origin + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ) + SELECT + tr.git_commit_hash, + tr.path_group, + tr.build_architecture, + tr.hardware_key, + tr.test_platform, + tr.is_boot, + tr.pass_tests, + tr.fail_tests, + tr.skip_tests, + tr.error_tests, + tr.miss_tests, + tr.done_tests, + tr.null_tests, + tr.total_tests + FROM + tree_tests_rollup tr + INNER JOIN RELEVANT_CHECKOUTS rc ON ( + tr.git_commit_hash = rc.git_commit_hash + AND tr.origin = rc.origin + AND tr.tree_name IS NOT DISTINCT FROM rc.tree_name + AND tr.git_repository_branch IS NOT DISTINCT FROM rc.git_repository_branch + AND tr.git_repository_url IS NOT DISTINCT FROM rc.git_repository_url + ) + ORDER BY + tr.total_tests DESC + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor=cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + + return rows + + +def get_tree_compare_builds( + *, + commit_hashes: list[str], + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> list[dict]: + if not commit_hashes: + return [] + + cache_key = "treeCompareBuilds" + params = { + "commit_hashes": commit_hashes, + "tree_name": tree_name, + "origin_param": origin_param, + "git_branch_param": git_branch_param, + } + + rows = get_query_cache(cache_key, params) + if rows is not None: + return rows + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.id AS checkout_id, + c.git_commit_hash, + c.git_repository_url + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ) + SELECT + rc.git_commit_hash, + rc.git_repository_url, + b.architecture, + b.status, + COUNT(DISTINCT b.id) AS count + FROM + RELEVANT_CHECKOUTS rc + INNER JOIN builds b ON b.checkout_id = rc.checkout_id + WHERE + b.id NOT LIKE 'maestro:dummy_%%' + GROUP BY + rc.git_commit_hash, + rc.git_repository_url, + b.architecture, + b.status + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor=cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + + return rows + + +def get_tree_compare_builds_diff( + *, + hash_a: str, + hash_b: str, + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, + filters: Optional["FilterParams"] = None, +) -> list[dict]: + """Return build rows whose grouped status differs between two commits. + + Identity key: config_name + architecture + compiler. + Statuses are bucketed to PASS / FAIL / INCONCLUSIVE before compare so + SKIP vs MISS is not treated as a difference. + + Named *_diff to avoid colliding with get_tree_compare_builds (summary aggregates). + """ + commit_hashes = [hash_a, hash_b] + filter_sql = build_build_compare_filter_clauses(filters) + + cache_key = "treeCompareBuildsDiff" + params = { + "hash_a": hash_a, + "hash_b": hash_b, + "commit_hashes": commit_hashes, + "tree_name": tree_name, + "origin_param": origin_param, + "git_branch_param": git_branch_param, + "unknown_string": UNKNOWN_STRING, + **filter_sql.params, + } + cache_params = { + **params, + "filter_pre_join": filter_sql.pre_join, + "filter_post_join": filter_sql.post_join, + } + + rows = get_query_cache(cache_key, cache_params) + if rows is not None: + return rows + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.id AS checkout_id, + c.git_commit_hash + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ), + BUILD_ROWS AS ( + SELECT DISTINCT ON ( + rc.git_commit_hash, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s) + ) + rc.git_commit_hash, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s) + AS config_name, + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s) + AS architecture, + COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s) AS compiler, + CASE + WHEN UPPER(b.status) = 'PASS' THEN 'PASS' + WHEN UPPER(b.status) = 'FAIL' THEN 'FAIL' + ELSE 'INCONCLUSIVE' + END AS grouped_status + FROM + RELEVANT_CHECKOUTS rc + INNER JOIN builds b ON b.checkout_id = rc.checkout_id + WHERE + b.id NOT LIKE 'maestro:dummy_%%' + {filter_sql.pre_join} + ORDER BY + rc.git_commit_hash, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s), + b.start_time DESC NULLS LAST, + b._timestamp DESC NULLS LAST + ), + SIDE_A AS ( + SELECT config_name, architecture, compiler, grouped_status + FROM BUILD_ROWS + WHERE git_commit_hash = %(hash_a)s + ), + SIDE_B AS ( + SELECT config_name, architecture, compiler, grouped_status + FROM BUILD_ROWS + WHERE git_commit_hash = %(hash_b)s + ) + SELECT + COALESCE(a.config_name, b.config_name) AS config_name, + COALESCE(a.architecture, b.architecture) AS architecture, + COALESCE(a.compiler, b.compiler) AS compiler, + a.grouped_status AS status_a, + b.grouped_status AS status_b + FROM + SIDE_A a + FULL OUTER JOIN SIDE_B b ON ( + a.config_name = b.config_name + AND a.architecture = b.architecture + AND a.compiler = b.compiler + ) + WHERE + ( + a.grouped_status IS DISTINCT FROM b.grouped_status + OR ( + a.grouped_status = 'FAIL' + AND b.grouped_status = 'FAIL' + ) + ) + {filter_sql.post_join} + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor=cursor) + set_query_cache(key=cache_key, params=cache_params, rows=rows) + + return rows + + +# Keep in sync with dashboard deriveCompareChange (INCONCLUSIVE transitions included). +_CHANGE_COUNT_SELECT = """ + COUNT(*) FILTER ( + WHERE status_a = 'PASS' AND status_b IN ('FAIL', 'INCONCLUSIVE') + ) AS regression, + COUNT(*) FILTER ( + WHERE status_a = 'FAIL' AND status_b IN ('PASS', 'INCONCLUSIVE') + ) AS fixed, + COUNT(*) FILTER ( + WHERE (status_a IS NULL OR status_a = 'INCONCLUSIVE') + AND status_b = 'FAIL' + ) AS new_failure, + COUNT(*) FILTER ( + WHERE status_a = 'FAIL' AND status_b = 'FAIL' + ) AS still_failing, + COUNT(*) FILTER ( + WHERE (status_a IS NULL OR status_a = 'INCONCLUSIVE') + AND status_b = 'PASS' + ) AS new_pass, + COUNT(*) FILTER ( + WHERE status_a IS NULL AND status_b = 'INCONCLUSIVE' + ) AS appeared, + COUNT(*) FILTER ( + WHERE status_a IS NOT NULL AND status_b IS NULL + ) AS disappeared +""" + + +def _empty_change_counts() -> dict: + return { + "regression": 0, + "fixed": 0, + "new_failure": 0, + "still_failing": 0, + "new_pass": 0, + "appeared": 0, + "disappeared": 0, + } + + +def get_tree_compare_builds_change_counts( + *, + hash_a: str, + hash_b: str, + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> dict: + """Aggregate build A/B change categories (including still-failing).""" + commit_hashes = [hash_a, hash_b] + cache_key = "treeCompareBuildsChangeCounts" + params = { + "hash_a": hash_a, + "hash_b": hash_b, + "commit_hashes": commit_hashes, + "tree_name": tree_name, + "origin_param": origin_param, + "git_branch_param": git_branch_param, + "unknown_string": UNKNOWN_STRING, + } + + cached = get_query_cache(cache_key, params) + if cached is not None: + return cached[0] if cached else _empty_change_counts() + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.id AS checkout_id, + c.git_commit_hash + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ), + BUILD_ROWS AS ( + SELECT DISTINCT ON ( + rc.git_commit_hash, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s) + ) + rc.git_commit_hash, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s) + AS config_name, + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s) + AS architecture, + COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s) AS compiler, + CASE + WHEN UPPER(b.status) = 'PASS' THEN 'PASS' + WHEN UPPER(b.status) = 'FAIL' THEN 'FAIL' + ELSE 'INCONCLUSIVE' + END AS grouped_status + FROM + RELEVANT_CHECKOUTS rc + INNER JOIN builds b ON b.checkout_id = rc.checkout_id + WHERE + b.id NOT LIKE 'maestro:dummy_%%' + ORDER BY + rc.git_commit_hash, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE(NULLIF(b.compiler, ''), %(unknown_string)s), + b.start_time DESC NULLS LAST, + b._timestamp DESC NULLS LAST + ), + SIDE_A AS ( + SELECT config_name, architecture, compiler, grouped_status + FROM BUILD_ROWS + WHERE git_commit_hash = %(hash_a)s + ), + SIDE_B AS ( + SELECT config_name, architecture, compiler, grouped_status + FROM BUILD_ROWS + WHERE git_commit_hash = %(hash_b)s + ), + PAIRED AS ( + SELECT + a.grouped_status AS status_a, + b.grouped_status AS status_b + FROM + SIDE_A a + FULL OUTER JOIN SIDE_B b ON ( + a.config_name = b.config_name + AND a.architecture = b.architecture + AND a.compiler = b.compiler + ) + ) + SELECT + {_CHANGE_COUNT_SELECT} + FROM + PAIRED + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor=cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + + return rows[0] if rows else _empty_change_counts() + + +def get_tree_compare_boots_change_counts( + *, + hash_a: str, + hash_b: str, + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> dict: + """Aggregate boot A/B change categories (including still-failing).""" + return _get_tree_compare_boots_tests_change_counts( + data_type="boots", + hash_a=hash_a, + hash_b=hash_b, + origin_param=origin_param, + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + +def get_tree_compare_tests_change_counts( + *, + hash_a: str, + hash_b: str, + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> dict: + """Aggregate non-boot test A/B change categories (including still-failing).""" + return _get_tree_compare_boots_tests_change_counts( + data_type="tests", + hash_a=hash_a, + hash_b=hash_b, + origin_param=origin_param, + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + +def _get_tree_compare_boots_tests_change_counts( + *, + data_type: Literal["boots", "tests"], + hash_a: str, + hash_b: str, + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> dict: + """Identity key matches get_tree_compare_boots_tests_diff: path+config+arch+platform.""" + commit_hashes = [hash_a, hash_b] + cache_key = ( + "treeCompareBootsChangeCounts" + if data_type == "boots" + else "treeCompareTestsChangeCounts" + ) + params = { + "hash_a": hash_a, + "hash_b": hash_b, + "commit_hashes": commit_hashes, + "tree_name": tree_name, + "origin_param": origin_param, + "git_branch_param": git_branch_param, + "unknown_string": UNKNOWN_STRING, + } + + cached = get_query_cache(cache_key, params) + if cached is not None: + return cached[0] if cached else _empty_change_counts() + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + if data_type == "boots": + path_filter = "AND (t.path = 'boot' OR t.path LIKE 'boot.%%')" + else: + path_filter = "AND t.path IS DISTINCT FROM 'boot' AND t.path NOT LIKE 'boot.%%'" + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.id AS checkout_id, + c.git_commit_hash + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ), + TEST_ROWS AS ( + SELECT DISTINCT ON ( + rc.git_commit_hash, + COALESCE(NULLIF(t.path, ''), %(unknown_string)s), + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE( + NULLIF(t.environment_misc->>'platform', ''), + %(unknown_string)s + ) + ) + rc.git_commit_hash, + COALESCE(NULLIF(t.path, ''), %(unknown_string)s) AS path, + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s) + AS config_name, + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s) + AS architecture, + COALESCE( + NULLIF(t.environment_misc->>'platform', ''), + %(unknown_string)s + ) AS platform, + CASE + WHEN UPPER(t.status) = 'PASS' THEN 'PASS' + WHEN UPPER(t.status) = 'FAIL' THEN 'FAIL' + ELSE 'INCONCLUSIVE' + END AS grouped_status + FROM + RELEVANT_CHECKOUTS rc + INNER JOIN builds b ON b.checkout_id = rc.checkout_id + INNER JOIN tests t ON t.build_id = b.id + {path_filter} + WHERE + b.id NOT LIKE 'maestro:dummy_%%' + ORDER BY + rc.git_commit_hash, + COALESCE(NULLIF(t.path, ''), %(unknown_string)s), + COALESCE(NULLIF(b.config_name, ''), %(unknown_string)s), + COALESCE(NULLIF(b.architecture, ''), %(unknown_string)s), + COALESCE( + NULLIF(t.environment_misc->>'platform', ''), + %(unknown_string)s + ), + t.start_time DESC NULLS LAST, + t._timestamp DESC NULLS LAST + ), + SIDE_A AS ( + SELECT path, config_name, architecture, platform, grouped_status + FROM TEST_ROWS + WHERE git_commit_hash = %(hash_a)s + ), + SIDE_B AS ( + SELECT path, config_name, architecture, platform, grouped_status + FROM TEST_ROWS + WHERE git_commit_hash = %(hash_b)s + ), + PAIRED AS ( + SELECT + a.grouped_status AS status_a, + b.grouped_status AS status_b + FROM + SIDE_A a + FULL OUTER JOIN SIDE_B b ON ( + a.path = b.path + AND a.config_name = b.config_name + AND a.architecture = b.architecture + AND a.platform = b.platform + ) + ) + SELECT + {_CHANGE_COUNT_SELECT} + FROM + PAIRED + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor=cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + + return rows[0] if rows else _empty_change_counts() 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..a6d19a0af --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/helpers/treeCompare_test.py @@ -0,0 +1,74 @@ +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_boot_test_compare_filter_clauses, + build_build_compare_filter_clauses, +) + + +class TestBuildCompareFilterClauses(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + + def _filters(self, params: dict | None = None) -> FilterParams: + request = self.factory.get( + "/api/tree/mainline/master/compare/builds", params or {} + ) + return FilterParams(request) + + def test_empty_filters(self): + result = build_build_compare_filter_clauses(self._filters()) + self.assertEqual(result.pre_join, "") + self.assertEqual(result.post_join, "") + self.assertEqual(result.params, {}) + + def test_architecture_is_pre_join(self): + result = build_build_compare_filter_clauses( + self._filters({"filter_architecture": "arm64"}) + ) + self.assertIn("architectures", result.params) + self.assertIn("b.architecture", result.pre_join) + self.assertEqual(result.post_join, "") + + def test_status_is_post_join_grouped(self): + result = build_build_compare_filter_clauses( + self._filters({"filter_build.status": ["FAIL", "NULL"]}) + ) + self.assertEqual(result.pre_join, "") + self.assertIn( + "a.grouped_status = ANY(%(grouped_build_statuses)s)", result.post_join + ) + self.assertCountEqual( + result.params["grouped_build_statuses"], + ["FAIL", "INCONCLUSIVE"], + ) + + +class TestBootTestCompareFilterClauses(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + + def _filters(self, params: dict | None = None) -> FilterParams: + request = self.factory.get( + "/api/tree/mainline/master/compare/boots", params or {} + ) + return FilterParams(request) + + def test_path_filter_for_boots(self): + result = build_boot_test_compare_filter_clauses( + self._filters({"filter_boot.path": "boot.login"}), + "boots", + ) + self.assertIn("test_path", result.params) + self.assertIn("t.path", result.pre_join) + + def test_boot_status_is_post_join(self): + result = build_boot_test_compare_filter_clauses( + self._filters({"filter_boot.status": "PASS"}), + "boots", + ) + self.assertEqual(result.pre_join, "") + self.assertIn("grouped_test_statuses", result.params) + self.assertIn("a.grouped_status", result.post_join) diff --git a/backend/kernelCI_app/tests/unitTests/queries/treeCompareQueries_test.py b/backend/kernelCI_app/tests/unitTests/queries/treeCompareQueries_test.py new file mode 100644 index 000000000..10a8ae9e6 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/queries/treeCompareQueries_test.py @@ -0,0 +1,227 @@ +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase + +from kernelCI_app.queries.tree import ( + get_tree_compare_boots_change_counts, + get_tree_compare_boots_tests_diff, + get_tree_compare_builds, + get_tree_compare_builds_change_counts, + get_tree_compare_builds_diff, + get_tree_compare_rollup, +) + + +class TestTreeCompareQueries(SimpleTestCase): + """Query-shape checks for the compare endpoint performance path.""" + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch("kernelCI_app.queries.tree.dict_fetchall", return_value=[]) + def test_compare_rollup_uses_single_any_query( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + get_tree_compare_rollup( + commit_hashes=["hash_a", "hash_b"], + origin_param="maestro", + git_branch_param="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + params = mock_cursor.execute.call_args[0][1] + self.assertIn("git_commit_hash = ANY(%(commit_hashes)s)", executed_query) + self.assertIn("DISTINCT ON (c.git_commit_hash)", executed_query) + self.assertIn("tree_tests_rollup", executed_query) + self.assertNotIn("git_url_param", params) + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch("kernelCI_app.queries.tree.dict_fetchall", return_value=[]) + def test_compare_builds_aggregates_in_sql( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + get_tree_compare_builds( + commit_hashes=["hash_a", "hash_b"], + origin_param="maestro", + git_branch_param="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + self.assertIn("COUNT(DISTINCT b.id)", executed_query) + self.assertIn("GROUP BY", executed_query) + self.assertIn("b.architecture", executed_query) + self.assertNotIn("b.architecture IS NOT NULL", executed_query) + self.assertNotIn("b.config_name", executed_query) + self.assertNotIn("known_issues", executed_query) + self.assertNotIn("SELECT\n b.id AS build_id", executed_query) + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch("kernelCI_app.queries.tree.dict_fetchall", return_value=[]) + def test_compare_builds_diff_joins_and_diffs_in_sql( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + get_tree_compare_builds_diff( + hash_a="hash_a", + hash_b="hash_b", + origin_param="maestro", + git_branch_param="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + params = mock_cursor.execute.call_args[0][1] + self.assertIn("NULLIF(b.config_name, '')", executed_query) + self.assertIn("NULLIF(b.architecture, '')", executed_query) + self.assertIn("NULLIF(b.compiler, '')", executed_query) + self.assertIn("FULL OUTER JOIN", executed_query) + self.assertIn("IS DISTINCT FROM", executed_query) + self.assertIn("a.grouped_status = 'FAIL'", executed_query) + self.assertIn("WHEN UPPER(b.status) = 'PASS' THEN 'PASS'", executed_query) + self.assertIn("WHEN UPPER(b.status) = 'FAIL' THEN 'FAIL'", executed_query) + self.assertIn("ELSE 'INCONCLUSIVE'", executed_query) + self.assertNotIn("incidents", executed_query) + self.assertNotIn("FROM tests", executed_query) + self.assertEqual(params["commit_hashes"], ["hash_a", "hash_b"]) + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch( + "kernelCI_app.queries.tree.dict_fetchall", + return_value=[ + { + "regression": 1, + "fixed": 0, + "new_failure": 2, + "still_failing": 3, + "new_pass": 4, + "appeared": 5, + "disappeared": 6, + } + ], + ) + def test_compare_builds_change_counts_aggregates_categories( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + result = get_tree_compare_builds_change_counts( + hash_a="hash_a", + hash_b="hash_b", + origin_param="maestro", + git_branch_param="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + self.assertIn("COUNT(*) FILTER", executed_query) + self.assertIn("still_failing", executed_query) + self.assertIn("appeared", executed_query) + self.assertIn("disappeared", executed_query) + self.assertIn("FULL OUTER JOIN", executed_query) + self.assertNotIn("IS DISTINCT FROM", executed_query) + self.assertEqual(result["regression"], 1) + self.assertEqual(result["still_failing"], 3) + self.assertEqual(result["appeared"], 5) + self.assertEqual(result["disappeared"], 6) + self.assertIn("status_b IN ('FAIL', 'INCONCLUSIVE')", executed_query) + self.assertIn("status_a = 'INCONCLUSIVE'", executed_query) + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch("kernelCI_app.queries.tree.dict_fetchall", return_value=[]) + def test_compare_boots_change_counts_include_architecture( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + get_tree_compare_boots_change_counts( + hash_a="hash_a", + hash_b="hash_b", + origin_param="maestro", + git_branch_param="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + self.assertIn("NULLIF(b.architecture, '')", executed_query) + self.assertIn("a.architecture = b.architecture", executed_query) + self.assertIn("t.path = 'boot' OR t.path LIKE 'boot.%%'", executed_query) + self.assertIn("status_b IN ('FAIL', 'INCONCLUSIVE')", executed_query) + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch("kernelCI_app.queries.tree.dict_fetchall", return_value=[]) + def test_compare_boots_tests_diff_uses_latest_wins_and_diffs_in_sql( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + get_tree_compare_boots_tests_diff( + data_type="tests", + hash_a="hash_a", + hash_b="hash_b", + origin="maestro", + git_branch="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + params = mock_cursor.execute.call_args[0][1] + self.assertIn("git_commit_hash = ANY(%(commit_hashes)s)", executed_query) + self.assertIn("DISTINCT ON (c.git_commit_hash)", executed_query) + self.assertIn("DISTINCT ON (", executed_query) + self.assertIn("t.start_time DESC NULLS LAST", executed_query) + self.assertIn("FULL OUTER JOIN", executed_query) + self.assertIn("IS DISTINCT FROM", executed_query) + self.assertIn("WHEN UPPER(t.status) = 'FAIL' THEN 'FAIL'", executed_query) + self.assertIn("maestro:dummy_%%", executed_query) + self.assertIn("NULLIF(b.config_name, '')", executed_query) + self.assertIn("NULLIF(b.architecture, '')", executed_query) + self.assertNotIn("WHEN UPPER(t.status) = 'FAIL' THEN 2", executed_query) + self.assertNotIn("ARRAY_AGG", executed_query) + self.assertEqual(params["commit_hashes"], ["hash_a", "hash_b"]) diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py index 20b345f1d..e0c1d9be8 100644 --- a/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py +++ b/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py @@ -15,7 +15,12 @@ def setUp(self): @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"} + { + "git_commit_hash": "abc123", + "start_time_end": "2025-11-10T10:00:00Z", + "git_commit_name": "v6.12-rc1", + "git_commit_tags": ["v6.12-rc1"], + } ] request = self.factory.get( @@ -33,6 +38,8 @@ def test_tree_commits_list_view_success(self, mock_get_commits): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["git_commit_name"], "v6.12-rc1") + self.assertEqual(response.data[0]["git_commit_tags"], ["v6.12-rc1"]) @patch("kernelCI_app.views.treeCommitsListView.get_tree_commits") def test_tree_commits_list_view_empty(self, mock_get_commits): @@ -57,7 +64,12 @@ def test_tree_commits_list_view_empty(self, mock_get_commits): @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"} + { + "git_commit_hash": "abc123", + "start_time_end": "2025-11-10T10:00:00Z", + "git_commit_name": None, + "git_commit_tags": [], + } ] request = self.factory.get( diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCompareBuildsView_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCompareBuildsView_test.py new file mode 100644 index 000000000..9ad66da73 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/treeCompareBuildsView_test.py @@ -0,0 +1,137 @@ +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.helpers.treeCompare import build_build_compare_filter_clauses +from kernelCI_app.views.treeCompareBuildsView import TreeCompareBuildsView + +HASH_A = "a" * 40 +HASH_B = "b" * 40 + +BUILD_DIFF_ROW = { + "config_name": "defconfig", + "architecture": "arm64", + "compiler": "gcc-12", + "status_a": "PASS", + "status_b": "FAIL", +} + + +class TestBuildBuildCompareFilterClauses(SimpleTestCase): + def test_empty_filters_add_no_clauses(self): + result = build_build_compare_filter_clauses(None) + self.assertEqual(result.pre_join, "") + self.assertEqual(result.post_join, "") + self.assertEqual(result.params, {}) + + def test_build_status_post_join_and_config_pre_join(self): + filters = MagicMock() + filters.filterBuildStatus = {"FAIL", "NULL"} + filters.filterBuildDurationMin = None + filters.filterBuildDurationMax = None + filters.filter_build_origin = set() + filters.filterConfigs = {"defconfig"} + filters.filterArchitecture = set() + filters.filterCompiler = set() + filters.filter_labs = set() + filters.filterHardware = set() + filters.filterIssues = {"boot": set(), "build": set(), "test": set()} + + result = build_build_compare_filter_clauses(filters) + self.assertIn("= ANY(%(configs)s)", result.pre_join) + self.assertIn( + "a.grouped_status = ANY(%(grouped_build_statuses)s)", + result.post_join, + ) + self.assertNotIn("b.status", result.pre_join) + self.assertCountEqual( + result.params["grouped_build_statuses"], + ["FAIL", "INCONCLUSIVE"], + ) + self.assertEqual(result.params["configs"], ["defconfig"]) + + +class TestTreeCompareBuildsView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = TreeCompareBuildsView.as_view() + self.url = "/api/tree/linux/master/compare/builds" + + @patch("kernelCI_app.views.treeCompareBuildsView.get_tree_compare_builds_diff") + def test_get_returns_builds_payload(self, mock_builds): + mock_builds.return_value = [BUILD_DIFF_ROW] + + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "hash_b": HASH_B, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, [BUILD_DIFF_ROW]) + self.assertIn("config_name", response.data[0]) + self.assertIn("architecture", response.data[0]) + self.assertIn("compiler", response.data[0]) + self.assertIn("status_a", response.data[0]) + self.assertNotIn("configName", response.data[0]) + mock_builds.assert_called_once() + self.assertEqual(mock_builds.call_args.kwargs["hash_a"], HASH_A) + + def test_missing_hashes_returns_400(self): + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + self.assertEqual(response.status_code, 400) + + @patch("kernelCI_app.views.treeCompareBuildsView.get_tree_compare_builds_diff") + def test_skip_vs_miss_same_bucket_yields_empty_diff(self, mock_builds): + mock_builds.return_value = [] + + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "hash_b": HASH_B, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) + + @patch("kernelCI_app.views.treeCompareBuildsView.get_tree_compare_builds_diff") + def test_one_sided_null_status(self, mock_builds): + mock_builds.return_value = [ + { + "config_name": "defconfig", + "architecture": "arm64", + "compiler": "gcc-12", + "status_a": "PASS", + "status_b": None, + } + ] + + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "hash_b": HASH_B, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["status_a"], "PASS") + self.assertIsNone(response.data[0]["status_b"]) diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCompareView_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCompareView_test.py new file mode 100644 index 000000000..de7933b2e --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/treeCompareView_test.py @@ -0,0 +1,277 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.helpers.treeCompare import ( + build_compare_response, + change_counts_from_row, + process_build_rows, + process_rollup_rows, +) +from kernelCI_app.typeModels.treeCompare import CompareChangeCounts +from kernelCI_app.views.treeCompareView import TreeCompareView + +HASH_A = "a" * 40 +HASH_B = "b" * 40 + +ROLLUP_BOOT_ROW = { + "git_commit_hash": HASH_A, + "path_group": "boot", + "build_architecture": "arm64", + "hardware_key": "qemu-arm64", + "test_platform": "qemu-arm64", + "is_boot": True, + "pass_tests": 10, + "fail_tests": 2, + "skip_tests": 0, + "error_tests": 1, + "miss_tests": 0, + "done_tests": 0, + "null_tests": 0, + "total_tests": 13, +} + +ROLLUP_TEST_ROW_A = { + **ROLLUP_BOOT_ROW, + "git_commit_hash": HASH_A, + "path_group": "kselftest", + "build_architecture": "x86_64", + "is_boot": False, + "pass_tests": 20, + "fail_tests": 5, + "error_tests": 0, + "total_tests": 25, +} + +ROLLUP_TEST_ROW_B = { + **ROLLUP_TEST_ROW_A, + "git_commit_hash": HASH_B, + "pass_tests": 18, + "fail_tests": 7, + "total_tests": 25, +} + +BUILD_ROW_A = { + "git_commit_hash": HASH_A, + "git_repository_url": "https://git.kernel.org/linux.git", + "architecture": "arm64", + "status": "PASS", + "count": 3, +} + +BUILD_ROW_B = { + **BUILD_ROW_A, + "git_commit_hash": HASH_B, + "status": "FAIL", + "count": 2, +} + + +class TestTreeCompareHelper(SimpleTestCase): + def test_process_rollup_rows_aggregates_summary(self): + accumulators = process_rollup_rows( + rows=[ROLLUP_BOOT_ROW, ROLLUP_TEST_ROW_A], + commit_hashes=[HASH_A], + ) + + acc = accumulators[HASH_A] + self.assertEqual(acc.boots.pass_count, 10) + self.assertEqual(acc.boots.fail_count, 2) + self.assertEqual(acc.boots.inconclusive, 1) + self.assertEqual(acc.tests.pass_count, 20) + self.assertEqual(acc.tests.fail_count, 5) + + def test_process_build_rows_aggregates_status_counts(self): + accumulators = process_build_rows( + rows=[BUILD_ROW_A, BUILD_ROW_B], + commit_hashes=[HASH_A, HASH_B], + ) + + self.assertEqual(accumulators[HASH_A].builds.pass_count, 3) + self.assertEqual(accumulators[HASH_B].builds.fail_count, 2) + + def test_build_compare_response_computes_deltas(self): + rollup_data = process_rollup_rows( + rows=[ROLLUP_TEST_ROW_A, ROLLUP_TEST_ROW_B], + commit_hashes=[HASH_A, HASH_B], + ) + build_data = process_build_rows( + rows=[BUILD_ROW_A, BUILD_ROW_B], + commit_hashes=[HASH_A, HASH_B], + ) + + accumulators = { + HASH_A: rollup_data[HASH_A], + HASH_B: rollup_data[HASH_B], + } + accumulators[HASH_A].builds = build_data[HASH_A].builds + accumulators[HASH_B].builds = build_data[HASH_B].builds + + response = build_compare_response( + hash_a=HASH_A, + hash_b=HASH_B, + tree_name="linux", + branch="master", + git_url="https://git.kernel.org/linux.git", + accumulators=accumulators, + changes={ + "builds": CompareChangeCounts(regression=1), + "boots": CompareChangeCounts(), + "tests": CompareChangeCounts(new_failure=2, still_failing=3), + }, + ) + + payload = response.model_dump(by_alias=True) + self.assertNotIn("groups", payload) + self.assertEqual(payload["summary"]["tests"]["delta"]["pass"], -2) + self.assertEqual(payload["summary"]["tests"]["delta"]["fail"], 2) + self.assertEqual(payload["summary"]["builds"]["delta"]["pass"], -3) + self.assertEqual(payload["summary"]["builds"]["delta"]["fail"], 2) + self.assertEqual(payload["summary"]["builds"]["changes"]["regression"], 1) + self.assertEqual(payload["summary"]["tests"]["changes"]["newFailure"], 2) + self.assertEqual(payload["summary"]["tests"]["changes"]["stillFailing"], 3) + + def test_change_counts_from_row(self): + counts = change_counts_from_row( + { + "regression": 4, + "fixed": 1, + "new_failure": 2, + "still_failing": 7, + "new_pass": 9, + "appeared": 3, + "disappeared": 8, + } + ) + self.assertEqual(counts.regression, 4) + self.assertEqual(counts.new_failure, 2) + self.assertEqual(counts.appeared, 3) + self.assertEqual(counts.disappeared, 8) + self.assertEqual(counts.model_dump(by_alias=True)["stillFailing"], 7) + + +class TestTreeCompareView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = TreeCompareView.as_view() + self.url = "/api/tree/linux/master/compare" + + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_tests_change_counts") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_boots_change_counts") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_builds_change_counts") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_rollup") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_builds") + def test_get_returns_compare_payload( + self, + mock_builds, + mock_rollup, + mock_build_changes, + mock_boot_changes, + mock_test_changes, + ): + mock_builds.return_value = [BUILD_ROW_A, BUILD_ROW_B] + mock_rollup.return_value = [ + ROLLUP_BOOT_ROW, + ROLLUP_TEST_ROW_A, + ROLLUP_TEST_ROW_B, + ] + mock_build_changes.return_value = { + "regression": 1, + "fixed": 0, + "new_failure": 0, + "still_failing": 0, + "new_pass": 0, + } + mock_boot_changes.return_value = { + "regression": 0, + "fixed": 0, + "new_failure": 0, + "still_failing": 0, + "new_pass": 0, + } + mock_test_changes.return_value = { + "regression": 2, + "fixed": 1, + "new_failure": 3, + "still_failing": 4, + "new_pass": 5, + } + + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "hash_b": HASH_B, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["treeName"], "linux") + self.assertEqual(response.data["branch"], "master") + self.assertIn("summary", response.data) + self.assertNotIn("groups", response.data) + self.assertEqual(response.data["summary"]["builds"]["changes"]["regression"], 1) + self.assertEqual( + response.data["summary"]["tests"]["changes"]["stillFailing"], 4 + ) + mock_builds.assert_called_once() + mock_rollup.assert_called_once() + mock_build_changes.assert_called_once() + mock_boot_changes.assert_called_once() + mock_test_changes.assert_called_once() + self.assertNotIn("git_url_param", mock_builds.call_args.kwargs) + + def test_missing_hashes_returns_400(self): + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + self.assertEqual(response.status_code, 400) + + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_tests_change_counts") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_boots_change_counts") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_builds_change_counts") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_rollup") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_builds") + def test_missing_checkout_returns_zero_counts( + self, + mock_builds, + mock_rollup, + mock_build_changes, + mock_boot_changes, + mock_test_changes, + ): + mock_builds.return_value = [] + mock_rollup.return_value = [] + empty = { + "regression": 0, + "fixed": 0, + "new_failure": 0, + "still_failing": 0, + "new_pass": 0, + } + mock_build_changes.return_value = empty + mock_boot_changes.return_value = empty + mock_test_changes.return_value = empty + + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "hash_b": HASH_B, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["summary"]["builds"]["sideA"]["pass"], 0) + self.assertEqual(response.data["summary"]["tests"]["sideB"]["fail"], 0) + self.assertEqual(response.data["summary"]["builds"]["changes"]["regression"], 0) 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..921fea50b --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/treeDetailsCompareView_test.py @@ -0,0 +1,119 @@ +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.hash_a = "aaa111" + self.hash_b = "bbb222" + + def _diff_row( + self, + *, + status_a: str | None = "PASS", + status_b: str | None = "FAIL", + path: str = "boot", + ) -> dict: + return { + "path": path, + "config_name": "defconfig", + "architecture": "arm64", + "platform": "qemu", + "status_a": status_a, + "status_b": status_b, + } + + @patch( + "kernelCI_app.views.treeDetailsCompareView.get_tree_compare_boots_tests_diff" + ) + def test_boots_compare_returns_diff(self, mock_query): + mock_query.return_value = [self._diff_row()] + request = self.factory.get( + "/api/tree/mainline/master/compare/boots", + { + "origin": "maestro", + "hash_a": self.hash_a, + "hash_b": self.hash_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") + self.assertEqual(response.data[0]["architecture"], "arm64") + self.assertEqual(response.data[0]["config_name"], "defconfig") + mock_query.assert_called_once() + self.assertEqual(mock_query.call_args.kwargs["data_type"], "boots") + + @patch( + "kernelCI_app.views.treeDetailsCompareView.get_tree_compare_boots_tests_diff" + ) + def test_tests_compare_uses_tests_data_type(self, mock_query): + mock_query.return_value = [ + self._diff_row(path="ltp.smoke", status_a="PASS", status_b="INCONCLUSIVE") + ] + request = self.factory.get( + "/api/tree/mainline/master/compare/tests", + { + "origin": "maestro", + "hash_a": self.hash_a, + "hash_b": self.hash_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_boots_tests_diff" + ) + def test_issue_filter_uses_sql_diff_path(self, mock_diff): + mock_diff.return_value = [ + self._diff_row(path="ltp.smoke", status_a="PASS", status_b="FAIL") + ] + request = self.factory.get( + "/api/tree/mainline/master/compare/tests", + { + "origin": "maestro", + "hash_a": self.hash_a, + "hash_b": self.hash_b, + "filter_test.issue": "issue-1,1", + }, + ) + response = self.tests_view.get( + request, tree_name="mainline", git_branch="master" + ) + self.assertEqual(response.status_code, 200) + mock_diff.assert_called_once() + self.assertIsNotNone(mock_diff.call_args.kwargs["filters"]) + + @patch( + "kernelCI_app.views.treeDetailsCompareView.get_tree_compare_boots_tests_diff" + ) + def test_missing_hash_params_returns_error(self, mock_query): + request = self.factory.get( + "/api/tree/mainline/master/compare/boots", + {"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/common.py b/backend/kernelCI_app/typeModels/common.py index 977d1639b..8b69cb1e9 100644 --- a/backend/kernelCI_app/typeModels/common.py +++ b/backend/kernelCI_app/typeModels/common.py @@ -1,4 +1,4 @@ -from typing import Optional, TypedDict +from typing import Literal, Optional, TypedDict from pydantic import BaseModel, BeforeValidator @@ -35,6 +35,9 @@ def __add__(self, other: "StatusCount") -> "StatusCount": ) +GroupedStatusLiteral = Literal["PASS", "FAIL", "INCONCLUSIVE"] + + class GroupedStatus(TypedDict): success: int failed: int diff --git a/backend/kernelCI_app/typeModels/treeCommits.py b/backend/kernelCI_app/typeModels/treeCommits.py index 997696410..d8283760f 100644 --- a/backend/kernelCI_app/typeModels/treeCommits.py +++ b/backend/kernelCI_app/typeModels/treeCommits.py @@ -99,6 +99,8 @@ def validate_commit_hashes(cls, value): class TreeCommitItem(BaseModel): git_commit_hash: Checkout__GitCommitHash last_checkout: Optional[datetime] = Field(None, alias="start_time_end") + git_commit_name: Checkout__GitCommitName = None + git_commit_tags: Checkout__GitCommitTags = None class TreeCommitsData(BaseModel): diff --git a/backend/kernelCI_app/typeModels/treeCompare.py b/backend/kernelCI_app/typeModels/treeCompare.py new file mode 100644 index 000000000..017a973b8 --- /dev/null +++ b/backend/kernelCI_app/typeModels/treeCompare.py @@ -0,0 +1,80 @@ +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, RootModel + +from kernelCI_app.constants.general import DEFAULT_ORIGIN +from kernelCI_app.constants.localization import DocStrings + +CompareGroupedStatus = Literal["PASS", "FAIL", "INCONCLUSIVE"] + + +class CompareStatusCounts(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + pass_count: int = Field(alias="pass", default=0) + fail_count: int = Field(alias="fail", default=0) + inconclusive: int = 0 + + +class CompareDelta(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + pass_count: int = Field(alias="pass", default=0) + fail_count: int = Field(alias="fail", default=0) + + +class CompareChangeCounts(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + regression: int = 0 + fixed: int = 0 + new_failure: int = Field(alias="newFailure", default=0) + still_failing: int = Field(alias="stillFailing", default=0) + new_pass: int = Field(alias="newPass", default=0) + appeared: int = 0 + disappeared: int = 0 + + +class CompareEntitySummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + side_a: CompareStatusCounts = Field(alias="sideA") + side_b: CompareStatusCounts = Field(alias="sideB") + delta: CompareDelta + changes: CompareChangeCounts = Field(default_factory=CompareChangeCounts) + + +class CompareSummary(BaseModel): + builds: CompareEntitySummary + boots: CompareEntitySummary + tests: CompareEntitySummary + + +class TreeCompareResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + tree_name: str = Field(alias="treeName") + branch: str + git_url: str = Field(alias="gitUrl", default="") + summary: CompareSummary + + +class TreeCompareQueryParameters(BaseModel): + hash_a: str = Field(description="Commit hash for side A") + hash_b: str = Field(description="Commit hash for side B") + origin: str = Field( + default=DEFAULT_ORIGIN, + description=DocStrings.TREE_QUERY_ORIGIN_DESCRIPTION, + ) + + +class CompareBuildDiffRow(BaseModel): + config_name: str + architecture: str + compiler: str + status_a: Optional[CompareGroupedStatus] = None + status_b: Optional[CompareGroupedStatus] = None + + +class TreeCompareBuildsResponse(RootModel[List[CompareBuildDiffRow]]): + root: List[CompareBuildDiffRow] diff --git a/backend/kernelCI_app/typeModels/treeDetails.py b/backend/kernelCI_app/typeModels/treeDetails.py index 5657f62a8..9342d187d 100644 --- a/backend/kernelCI_app/typeModels/treeDetails.py +++ b/backend/kernelCI_app/typeModels/treeDetails.py @@ -1,9 +1,10 @@ from typing import List, 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 +from kernelCI_app.typeModels.common import GroupedStatusLiteral from kernelCI_app.typeModels.commonDetails import ( BuildHistoryItem, CommonDetailsBootsResponse, @@ -87,3 +88,16 @@ class TreeDetailsFullResponse( SummaryResponse, ): pass + + +class TreeCompareTest(BaseModel): + path: str + config_name: str + architecture: str + platform: str + status_a: Optional[GroupedStatusLiteral] + status_b: Optional[GroupedStatusLiteral] + + +class TreeCompareBootsTestsResponse(RootModel[List[TreeCompareTest]]): + root: List[TreeCompareTest] diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index a89890862..e2608afa9 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -62,6 +62,27 @@ def view_cache(view, timeout: int = settings.CACHE_TIMEOUT): view_cache(views.TreeCommitsListView), name="treeCommitsList", ), + # Compare routes must come before ...//boots|builds (else commit_hash="compare") + path( + "tree///compare/boots", + view_cache(views.TreeDetailsBootsCompare), + name="treeDetailsBootsCompareView", + ), + path( + "tree///compare/tests", + view_cache(views.TreeDetailsTestsCompare), + name="treeDetailsTestsCompareView", + ), + path( + "tree///compare/builds", + view_cache(views.TreeCompareBuildsView), + name="treeCompareBuilds", + ), + path( + "tree///compare", + view_cache(views.TreeCompareView), + name="treeCompare", + ), path( "tree////commits", view_cache(views.TreeCommitsHistoryDirect), diff --git a/backend/kernelCI_app/views/hardwareDetailsSummaryView.py b/backend/kernelCI_app/views/hardwareDetailsSummaryView.py index 499236e62..48d869c35 100644 --- a/backend/kernelCI_app/views/hardwareDetailsSummaryView.py +++ b/backend/kernelCI_app/views/hardwareDetailsSummaryView.py @@ -128,7 +128,7 @@ def filter_instance( return True filtered_issues = filters.filterIssues.get(filter_type, set()) - if filtered_issues and not known_issues.issubset(filtered_issues): + if filtered_issues and known_issues.isdisjoint(filtered_issues): return True return False diff --git a/backend/kernelCI_app/views/treeCommitsHistory.py b/backend/kernelCI_app/views/treeCommitsHistory.py index 0d1860580..f658194cc 100644 --- a/backend/kernelCI_app/views/treeCommitsHistory.py +++ b/backend/kernelCI_app/views/treeCommitsHistory.py @@ -592,7 +592,7 @@ def filter_instance( if is_filtered_out(architecture, filters.filterArchitecture): return True filtered_issues = filters.filterIssues.get(filter_type, set()) - if filtered_issues and not known_issues.issubset(filtered_issues): + if filtered_issues and known_issues.isdisjoint(filtered_issues): return True return False diff --git a/backend/kernelCI_app/views/treeCompareBuildsView.py b/backend/kernelCI_app/views/treeCompareBuildsView.py new file mode 100644 index 000000000..ceb1aa2d6 --- /dev/null +++ b/backend/kernelCI_app/views/treeCompareBuildsView.py @@ -0,0 +1,74 @@ +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.general import DEFAULT_ORIGIN +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.helpers.filters import FilterParams +from kernelCI_app.queries.tree import get_tree_compare_builds_diff +from kernelCI_app.typeModels.commonOpenApiParameters import ( + GIT_BRANCH_PATH_PARAM, + TREE_NAME_PATH_PARAM, +) +from kernelCI_app.typeModels.treeCompare import ( + TreeCompareBuildsResponse, + TreeCompareQueryParameters, +) + + +class TreeCompareBuildsView(APIView): + @extend_schema( + parameters=[ + TREE_NAME_PATH_PARAM, + GIT_BRANCH_PATH_PARAM, + TreeCompareQueryParameters, + ], + responses=TreeCompareBuildsResponse, + ) + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + try: + query_params = TreeCompareQueryParameters( + hash_a=request.GET.get("hash_a", ""), + hash_b=request.GET.get("hash_b", ""), + origin=request.GET.get("origin", DEFAULT_ORIGIN), + ) + except ValidationError as error: + return Response(data=error.json(), status=HTTPStatus.BAD_REQUEST) + + if not query_params.hash_a or not query_params.hash_b: + return create_api_error_response( + status_code=HTTPStatus.BAD_REQUEST, + error_message="hash_a and hash_b are required", + ) + + filters = FilterParams(request) + rows = get_tree_compare_builds_diff( + hash_a=query_params.hash_a, + hash_b=query_params.hash_b, + origin_param=query_params.origin, + git_branch_param=git_branch, + tree_name=tree_name, + filters=filters, + ) + + try: + response = TreeCompareBuildsResponse(root=rows) + except ValidationError as error: + return Response( + data=error.json(), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return Response( + data=response.model_dump(), + status=HTTPStatus.OK, + ) diff --git a/backend/kernelCI_app/views/treeCompareView.py b/backend/kernelCI_app/views/treeCompareView.py new file mode 100644 index 000000000..ccf8b8057 --- /dev/null +++ b/backend/kernelCI_app/views/treeCompareView.py @@ -0,0 +1,144 @@ +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.general import DEFAULT_ORIGIN +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.helpers.treeCompare import ( + _HashAccumulator, + build_compare_response, + change_counts_from_row, + process_build_rows, + process_rollup_rows, +) +from kernelCI_app.queries.tree import ( + get_tree_compare_boots_change_counts, + get_tree_compare_builds, + get_tree_compare_builds_change_counts, + get_tree_compare_rollup, + get_tree_compare_tests_change_counts, +) +from kernelCI_app.typeModels.commonOpenApiParameters import ( + GIT_BRANCH_PATH_PARAM, + TREE_NAME_PATH_PARAM, +) +from kernelCI_app.typeModels.treeCompare import ( + TreeCompareQueryParameters, + TreeCompareResponse, +) + + +class TreeCompareView(APIView): + def _merge_accumulators( + self, + *, + target: dict[str, _HashAccumulator], + builds_data: dict[str, _HashAccumulator], + boots_tests_data: dict[str, _HashAccumulator], + ) -> None: + for commit_hash, build_acc in builds_data.items(): + target_acc = target.setdefault(commit_hash, _HashAccumulator()) + target_acc.builds = build_acc.builds + + for commit_hash, entity_acc in boots_tests_data.items(): + target_acc = target.setdefault(commit_hash, _HashAccumulator()) + target_acc.boots = entity_acc.boots + target_acc.tests = entity_acc.tests + + @extend_schema( + parameters=[ + TREE_NAME_PATH_PARAM, + GIT_BRANCH_PATH_PARAM, + TreeCompareQueryParameters, + ], + responses=TreeCompareResponse, + ) + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + try: + query_params = TreeCompareQueryParameters( + hash_a=request.GET.get("hash_a", ""), + hash_b=request.GET.get("hash_b", ""), + origin=request.GET.get("origin", DEFAULT_ORIGIN), + ) + except ValidationError as error: + return Response(data=error.json(), status=HTTPStatus.BAD_REQUEST) + + if not query_params.hash_a or not query_params.hash_b: + return create_api_error_response( + status_code=HTTPStatus.BAD_REQUEST, + error_message="hash_a and hash_b are required", + ) + + commit_hashes = [query_params.hash_a, query_params.hash_b] + query_kwargs = { + "commit_hashes": commit_hashes, + "origin_param": query_params.origin, + "git_branch_param": git_branch, + "tree_name": tree_name, + } + change_kwargs = { + "hash_a": query_params.hash_a, + "hash_b": query_params.hash_b, + "origin_param": query_params.origin, + "git_branch_param": git_branch, + "tree_name": tree_name, + } + + build_rows = get_tree_compare_builds(**query_kwargs) + builds_data = process_build_rows( + rows=build_rows, + commit_hashes=commit_hashes, + ) + boots_tests_data = process_rollup_rows( + rows=get_tree_compare_rollup(**query_kwargs), + commit_hashes=commit_hashes, + ) + + accumulators = { + commit_hash: _HashAccumulator() for commit_hash in commit_hashes + } + self._merge_accumulators( + target=accumulators, + builds_data=builds_data, + boots_tests_data=boots_tests_data, + ) + + changes = { + "builds": change_counts_from_row( + get_tree_compare_builds_change_counts(**change_kwargs) + ), + "boots": change_counts_from_row( + get_tree_compare_boots_change_counts(**change_kwargs) + ), + "tests": change_counts_from_row( + get_tree_compare_tests_change_counts(**change_kwargs) + ), + } + + git_url = "" + if build_rows: + git_url = build_rows[0].get("git_repository_url") or "" + + response = build_compare_response( + hash_a=query_params.hash_a, + hash_b=query_params.hash_b, + tree_name=tree_name, + branch=git_branch, + git_url=git_url, + accumulators=accumulators, + changes=changes, + ) + + return Response( + data=response.model_dump(by_alias=True), + status=HTTPStatus.OK, + ) diff --git a/backend/kernelCI_app/views/treeDetailsCompareView.py b/backend/kernelCI_app/views/treeDetailsCompareView.py new file mode 100644 index 000000000..b54159c21 --- /dev/null +++ b/backend/kernelCI_app/views/treeDetailsCompareView.py @@ -0,0 +1,112 @@ +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.constants.general import DEFAULT_ORIGIN +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.helpers.filters import FilterParams +from kernelCI_app.queries.tree import get_tree_compare_boots_tests_diff +from kernelCI_app.typeModels.commonOpenApiParameters import ( + GIT_BRANCH_PATH_PARAM, + TREE_NAME_PATH_PARAM, +) +from kernelCI_app.typeModels.treeCompare import TreeCompareQueryParameters +from kernelCI_app.typeModels.treeDetails import TreeCompareBootsTestsResponse + + +class BaseTreeDetailsCompare(APIView): + data_type: Literal["boots", "tests"] + + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + try: + params = TreeCompareQueryParameters( + hash_a=request.GET.get("hash_a", ""), + hash_b=request.GET.get("hash_b", ""), + origin=request.GET.get("origin", DEFAULT_ORIGIN), + ) + except ValidationError as e: + return create_api_error_response(error_message=e.json()) + + if not params.hash_a or not params.hash_b: + return create_api_error_response( + status_code=HTTPStatus.BAD_REQUEST, + error_message="hash_a and hash_b are required", + ) + + filters = FilterParams(request) + rows = get_tree_compare_boots_tests_diff( + data_type=self.data_type, + hash_a=params.hash_a, + hash_b=params.hash_b, + origin=params.origin, + git_branch=git_branch, + tree_name=tree_name, + filters=filters, + boots_duration=( + filters.filterBootDurationMin, + filters.filterBootDurationMax, + ), + tests_duration=( + filters.filterTestDurationMin, + filters.filterTestDurationMax, + ), + ) + + try: + response = TreeCompareBootsTestsResponse(root=rows) + 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=TreeCompareBootsTestsResponse, + ) + 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=TreeCompareBootsTestsResponse, + ) + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + return super().get(request, tree_name, git_branch) diff --git a/backend/requests/tree-compare-builds-get.sh b/backend/requests/tree-compare-builds-get.sh new file mode 100644 index 000000000..c00715119 --- /dev/null +++ b/backend/requests/tree-compare-builds-get.sh @@ -0,0 +1,17 @@ +http 'http://localhost:8000/api/tree/linux/master/compare/builds' \ + origin==maestro \ + hash_a==abc1234567890abcdef1234567890abcdef12 \ + hash_b==def5678901234abcdef5678901234abcdef56 + +# HTTP/1.1 200 OK +# { +# "builds": [ +# { +# "config_name": "defconfig", +# "architecture": "arm64", +# "compiler": "gcc-12", +# "status_a": "PASS", +# "status_b": "FAIL" +# } +# ] +# } diff --git a/backend/requests/tree-compare-get.sh b/backend/requests/tree-compare-get.sh new file mode 100644 index 000000000..cc946d679 --- /dev/null +++ b/backend/requests/tree-compare-get.sh @@ -0,0 +1,16 @@ +http 'http://localhost:8000/api/tree/linux/master/compare' \ + origin==maestro \ + hash_a==abc1234567890abcdef1234567890abcdef12 \ + hash_b==def5678901234abcdef5678901234abcdef56 + +# HTTP/1.1 200 OK +# { +# "treeName": "linux", +# "branch": "master", +# "gitUrl": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", +# "summary": { +# "builds": { "sideA": { "pass": 0, "fail": 0, "inconclusive": 0 }, ... }, +# "boots": { ... }, +# "tests": { ... } +# } +# } diff --git a/backend/schema.yml b/backend/schema.yml index 8381ac0ae..036fb8b7c 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -4284,6 +4284,10 @@ components: - type: 'null' default: null title: Start Time End + git_commit_name: + $ref: '#/components/schemas/Checkout__GitCommitName' + git_commit_tags: + $ref: '#/components/schemas/Checkout__GitCommitTags' required: - git_commit_hash title: TreeCommitItem diff --git a/dashboard/e2e/tree-compare.spec.ts b/dashboard/e2e/tree-compare.spec.ts new file mode 100644 index 000000000..a22192502 --- /dev/null +++ b/dashboard/e2e/tree-compare.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; + +const FULL_HASH_LENGTH = 40; +const HASH_A = 'a'.repeat(FULL_HASH_LENGTH); +const HASH_B = 'b'.repeat(FULL_HASH_LENGTH); + +test('loads revisions and comparison data from the API', async ({ page }) => { + await page.route('**/api/tree/linux/master/commits?**', route => + route.fulfill({ + json: [ + { + git_commit_hash: HASH_A, + last_checkout: '2026-07-14T10:00:00Z', + }, + { + git_commit_hash: HASH_B, + last_checkout: '2026-07-13T10:00:00Z', + }, + ], + }), + ); + + await page.route('**/api/tree/linux/master/compare/builds?**', route => + route.fulfill({ + json: [ + { + config_name: 'defconfig+allmodconfig', + architecture: 'arm64', + compiler: 'clang-17', + status_a: 'PASS', + status_b: 'FAIL', + }, + ], + }), + ); + + await page.route('**/api/tree/linux/master/compare/boots?**', route => + route.fulfill({ json: [] }), + ); + + await page.route('**/api/tree/linux/master/compare/tests?**', route => + route.fulfill({ json: [] }), + ); + + await page.route('**/api/tree/linux/master/compare?**', route => + route.fulfill({ + json: { + treeName: 'linux', + branch: 'master', + gitUrl: 'https://git.kernel.org/linux.git', + summary: { + builds: { + sideA: { pass: 42, fail: 1, inconclusive: 0 }, + sideB: { pass: 40, fail: 3, inconclusive: 0 }, + delta: { pass: -2, fail: 2 }, + changes: { + regression: 2, + fixed: 0, + newFailure: 0, + stillFailing: 0, + newPass: 0, + }, + }, + boots: { + sideA: { pass: 20, fail: 0, inconclusive: 1 }, + sideB: { pass: 18, fail: 2, inconclusive: 1 }, + delta: { pass: -2, fail: 2 }, + changes: { + regression: 2, + fixed: 0, + newFailure: 0, + stillFailing: 0, + newPass: 0, + }, + }, + tests: { + sideA: { pass: 100, fail: 5, inconclusive: 2 }, + sideB: { pass: 95, fail: 10, inconclusive: 2 }, + delta: { pass: -5, fail: 5 }, + changes: { + regression: 5, + fixed: 0, + newFailure: 0, + stillFailing: 0, + newPass: 0, + }, + }, + }, + }, + }), + ); + + await page.goto( + `/tree/linux/master/compare?hashA=${HASH_A}&hashB=${HASH_B}&origin=maestro`, + ); + + await expect(page.getByText('Tree summary')).toBeVisible(); + await expect(page.getByText('Changed results')).toBeVisible(); + await expect(page.getByText('defconfig+allmodconfig')).toBeVisible(); + await expect(page.getByText('Regression')).toBeVisible(); +}); diff --git a/dashboard/src/api/treeCompare.ts b/dashboard/src/api/treeCompare.ts new file mode 100644 index 000000000..e42e17aac --- /dev/null +++ b/dashboard/src/api/treeCompare.ts @@ -0,0 +1,139 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; + +import type { + TreeCompareBuildDiffApiRow, + TreeCompareData, + TreeCompareTestDiffApiRow, +} from '@/types/tree/TreeCompare'; + +import { RequestData } from './commonRequest'; + +type CompareParams = { + treeName: string; + branch: string; + hashA: string; + hashB: string; + origin: string; +}; + +type CompareParamsRequestParams = { + hash_a: string; + hash_b: string; + origin: string; +}; + +const compareParams = ({ + hashA, + hashB, + origin, +}: Pick< + CompareParams, + 'hashA' | 'hashB' | 'origin' +>): CompareParamsRequestParams => ({ + hash_a: hashA, + hash_b: hashB, + origin, +}); + +const fetchTreeCompare = async ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): Promise => + RequestData.get(`/api/tree/${treeName}/${branch}/compare`, { + params: compareParams({ hashA, hashB, origin }), + }); + +const fetchTreeCompareBuilds = async ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): Promise => + RequestData.get( + `/api/tree/${treeName}/${branch}/compare/builds`, + { params: compareParams({ hashA, hashB, origin }) }, + ); + +const fetchTreeCompareBoots = async ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): Promise => + RequestData.get( + `/api/tree/${treeName}/${branch}/compare/boots`, + { params: compareParams({ hashA, hashB, origin }) }, + ); + +const fetchTreeCompareTests = async ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): Promise => + RequestData.get( + `/api/tree/${treeName}/${branch}/compare/tests`, + { params: compareParams({ hashA, hashB, origin }) }, + ); + +export const useTreeCompare = ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): UseQueryResult => + useQuery({ + queryKey: ['treeCompare', treeName, branch, hashA, hashB, origin], + queryFn: () => fetchTreeCompare({ treeName, branch, hashA, hashB, origin }), + enabled: !!hashA && !!hashB, + }); + +export const useTreeCompareBuilds = ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): UseQueryResult => + useQuery({ + queryKey: ['treeCompareBuilds', treeName, branch, hashA, hashB, origin], + queryFn: () => + fetchTreeCompareBuilds({ treeName, branch, hashA, hashB, origin }), + enabled: !!hashA && !!hashB, + }); + +export const useTreeCompareBoots = ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): UseQueryResult => + useQuery({ + queryKey: ['treeCompareBoots', treeName, branch, hashA, hashB, origin], + queryFn: () => + fetchTreeCompareBoots({ treeName, branch, hashA, hashB, origin }), + enabled: !!hashA && !!hashB, + }); + +export const useTreeCompareTests = ({ + treeName, + branch, + hashA, + hashB, + origin, +}: CompareParams): UseQueryResult => + useQuery({ + queryKey: ['treeCompareTests', treeName, branch, hashA, hashB, origin], + queryFn: () => + fetchTreeCompareTests({ treeName, branch, hashA, hashB, origin }), + enabled: !!hashA && !!hashB, + }); diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 82347166e..9e3dbc011 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -379,10 +379,62 @@ export const messages = { 'title.hardwareDetails': 'Hardware: {hardwareName}', 'title.issueDetails': 'Issue: {issueName}', 'title.testDetails': 'Test: {testName}', + 'title.treeCompare': 'Compare: {treeName}', 'title.treeDetails': 'Tree: {treeName}', 'tree.details': 'Trees Details', 'tree.path': 'Trees', 'tree.searchPlaceholder': 'Search by tree, branch or tag with a regex', + 'treeCompare.backToDetails': 'Back to tree details', + 'treeCompare.breadcrumb': 'Compare', + 'treeCompare.breakdownTitle': 'Changed results', + 'treeCompare.change.appeared': 'Appeared', + 'treeCompare.change.disappeared': 'Disappeared', + 'treeCompare.change.fixed': 'Fixed', + 'treeCompare.change.glossary': + 'Regression — PASS → FAIL or PASS → INCONCLUSIVE{br}' + + 'Fixed — FAIL → PASS or FAIL → INCONCLUSIVE{br}' + + 'New failure — absent/INCONCLUSIVE on Side A → FAIL on Side B{br}' + + 'Still failing — FAIL → FAIL{br}' + + 'New pass — absent/INCONCLUSIVE on Side A → PASS on Side B{br}' + + 'Appeared — absent on Side A → INCONCLUSIVE on Side B{br}' + + 'Disappeared — present on Side A → absent on Side B', + 'treeCompare.change.newFailure': 'New failure', + 'treeCompare.change.newFailures': 'New failures', + 'treeCompare.change.newPass': 'New pass', + 'treeCompare.change.newPasses': 'New passes', + 'treeCompare.change.regression': 'Regression', + 'treeCompare.change.regressions': 'Regressions', + 'treeCompare.change.stillFailing': 'Still failing', + 'treeCompare.changed': 'Changed', + 'treeCompare.description': + 'Compare pass/fail counts between two revisions on the same tree and branch.', + 'treeCompare.drilldownHint': + 'Individual builds, boots, and tests that changed between Side A and Side B.', + 'treeCompare.failures.change': 'Change', + 'treeCompare.failures.configArch': 'Config / Arch', + 'treeCompare.failures.pathArch': 'Path / Arch', + 'treeCompare.group.boots': 'Platform', + 'treeCompare.group.builds': 'Architecture', + 'treeCompare.group.tests': 'Path', + 'treeCompare.matrix.boots': 'boots', + 'treeCompare.matrix.builds': 'builds', + 'treeCompare.matrix.fail': 'fail', + 'treeCompare.matrix.fixes': 'fixes', + 'treeCompare.matrix.other': 'other', + 'treeCompare.matrix.pass': 'pass', + 'treeCompare.matrix.regressions': 'regressions', + 'treeCompare.matrix.tests': 'tests', + 'treeCompare.needTwoRevisions': + 'Select two different revisions to compare. This tree needs at least two commits with results.', + 'treeCompare.openCompare': 'Compare revisions', + 'treeCompare.selectRevision': 'Select a revision', + 'treeCompare.sideA': 'Side A', + 'treeCompare.sideB': 'Side B', + 'treeCompare.suggestion.branchHead': 'Branch head', + 'treeCompare.suggestion.previous': 'Previous commit', + 'treeCompare.suggestion.swap': 'Swap sides', + 'treeCompare.suggestions': 'Suggestions', + 'treeCompare.summaryTitle': 'Tree summary', 'treeDetails.bootsHistory': 'Boots History', 'treeDetails.branch': 'Branch', 'treeDetails.buildsHistory': 'Builds History', diff --git a/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx b/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx new file mode 100644 index 000000000..48806f30b --- /dev/null +++ b/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx @@ -0,0 +1,35 @@ +import { Link } from '@tanstack/react-router'; +import type { JSX } from 'react'; + +import { GitCompareArrows } from 'lucide-react'; +import { FormattedMessage } from 'react-intl'; + +import { Button } from '@/components/ui/button'; + +interface TreeCompareLinkProps { + treeName: string; + branch: string; + hash: string; + origin: string; +} + +export function TreeCompareLink({ + treeName, + branch, + hash, + origin, +}: TreeCompareLinkProps): JSX.Element { + return ( + + ); +} diff --git a/dashboard/src/pages/TreeCompare/TreeComparePage.tsx b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx new file mode 100644 index 000000000..e51d0ed62 --- /dev/null +++ b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useMemo, type JSX } from 'react'; + +import { + Link, + useNavigate, + useParams, + useSearch, +} from '@tanstack/react-router'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/Breadcrumb/Breadcrumb'; +import ColoredCircle from '@/components/ColoredCircle/ColoredCircle'; +import PageWithTitle from '@/components/PageWithTitle'; +import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; +import Tabs from '@/components/Tabs/Tabs'; +import type { ITabItem } from '@/components/Tabs/Tabs'; + +import { useCommits } from '@/api/commitHistory'; +import { + useTreeCompare, + useTreeCompareBoots, + useTreeCompareBuilds, + useTreeCompareTests, +} from '@/api/treeCompare'; + +import { + compareDefaultChangeFilters, + compareNavigateFrom, + compareRouteName, + type CompareBootFailureRow, + type CompareChangeFilter, + type CompareTestFailureRow, +} from '@/types/tree/TreeCompare'; +import type { PossibleTabs } from '@/types/tree/TreeDetails'; +import { + applyChangeFilter, + mapBootOrTestDiffRows, + mapBuildDiffRows, +} from '@/utils/treeCompareDiff'; + +import { CompareChangeFilterBar } from './components/CompareChangeStats'; +import { + CompareBootsFailuresTable, + CompareBuildsFailuresTable, + CompareTestsFailuresTable, +} from './components/CompareFailuresTables'; +import { CompareSummary } from './components/CompareSummary'; +import { RevisionSelectorBar } from './components/RevisionSelector'; + +const SHORT_HASH_LENGTH = 7; + +const TreeComparePage = (): JSX.Element => { + const { formatMessage } = useIntl(); + const { treeName, branch } = useParams({ from: compareRouteName }); + const { hashA, hashB, origin, currentPageTab, changeFilter } = useSearch({ + from: compareRouteName, + }); + const navigate = useNavigate({ from: compareNavigateFrom }); + + const commitsQuery = useCommits({ + origin, + gitUrl: '', + gitBranch: branch, + treeName, + }); + + const revisions = useMemo( + () => + (commitsQuery.data ?? []).map(commit => ({ + hash: commit.git_commit_hash, + shortHash: commit.git_commit_hash.slice(0, SHORT_HASH_LENGTH), + commitName: commit.git_commit_name ?? '', + date: commit.last_checkout ?? commit.earliest_checkout ?? '', + tags: commit.git_commit_tags ?? [], + })), + [commitsQuery.data], + ); + + const resolvedHashA = hashA || revisions[0]?.hash || ''; + const resolvedHashB = + hashB || + revisions.find(revision => revision.hash !== resolvedHashA)?.hash || + ''; + + const canCompare = Boolean(resolvedHashA && resolvedHashB); + + const compareParams = { + treeName, + branch, + hashA: resolvedHashA, + hashB: resolvedHashB, + origin, + }; + + const compareQuery = useTreeCompare(compareParams); + const buildsDiffQuery = useTreeCompareBuilds(compareParams); + const bootsDiffQuery = useTreeCompareBoots(compareParams); + const testsDiffQuery = useTreeCompareTests(compareParams); + + const updateSearch = useCallback( + (updates: { + hashA?: string; + hashB?: string; + currentPageTab?: PossibleTabs; + changeFilter?: CompareChangeFilter[]; + }) => { + navigate({ + search: previous => ({ + ...previous, + hashA: updates.hashA ?? previous.hashA, + hashB: updates.hashB ?? previous.hashB, + currentPageTab: + updates.currentPageTab ?? + previous.currentPageTab ?? + 'global.builds', + changeFilter: + updates.changeFilter ?? + previous.changeFilter ?? + compareDefaultChangeFilters, + }), + params: { treeName, branch }, + resetScroll: false, + }); + }, + [navigate, treeName, branch], + ); + + useEffect(() => { + if ((!hashA || !hashB) && resolvedHashA && resolvedHashB) { + updateSearch({ hashA: resolvedHashA, hashB: resolvedHashB }); + } + }, [hashA, hashB, resolvedHashA, resolvedHashB, updateSearch]); + + const handleSwap = useCallback(() => { + updateSearch({ hashA: resolvedHashB, hashB: resolvedHashA }); + }, [resolvedHashA, resolvedHashB, updateSearch]); + + const handleSideAction = useCallback( + (side: 'A' | 'B', action: 'previous' | 'branchHead') => { + const currentHash = side === 'A' ? resolvedHashA : resolvedHashB; + const currentIndex = revisions.findIndex(r => r.hash === currentHash); + + if (action === 'previous') { + const previousIndex = Math.min( + revisions.length - 1, + Math.max(currentIndex, 0) + 1, + ); + const nextHash = revisions[previousIndex]?.hash ?? currentHash; + if (side === 'A') { + updateSearch({ hashA: nextHash }); + } else { + updateSearch({ hashB: nextHash }); + } + return; + } + + const headHash = revisions[0]?.hash ?? currentHash; + if (side === 'A') { + updateSearch({ hashA: headHash }); + } else { + updateSearch({ hashB: headHash }); + } + }, + [revisions, resolvedHashA, resolvedHashB, updateSearch], + ); + + const buildRows = useMemo( + () => mapBuildDiffRows(buildsDiffQuery.data ?? []), + [buildsDiffQuery.data], + ); + const bootRows = useMemo( + () => + mapBootOrTestDiffRows( + bootsDiffQuery.data ?? [], + 'boot', + ) as CompareBootFailureRow[], + [bootsDiffQuery.data], + ); + const testRows = useMemo( + () => + mapBootOrTestDiffRows( + testsDiffQuery.data ?? [], + 'test', + ) as CompareTestFailureRow[], + [testsDiffQuery.data], + ); + + const filteredBuilds = useMemo( + () => applyChangeFilter(buildRows, changeFilter), + [buildRows, changeFilter], + ); + const filteredBoots = useMemo( + () => applyChangeFilter(bootRows, changeFilter), + [bootRows, changeFilter], + ); + const filteredTests = useMemo( + () => applyChangeFilter(testRows, changeFilter), + [changeFilter, testRows], + ); + + const tabs: ITabItem[] = useMemo( + () => [ + { + name: 'global.builds', + rightElement: ( + + ), + content: ( + + + + ), + }, + { + name: 'global.boots', + rightElement: ( + + ), + content: ( + + + + ), + }, + { + name: 'global.tests', + rightElement: ( + + ), + content: ( + + + + ), + }, + ], + [ + bootsDiffQuery.data, + bootsDiffQuery.error, + bootsDiffQuery.status, + buildsDiffQuery.data, + buildsDiffQuery.error, + buildsDiffQuery.status, + filteredBoots, + filteredBuilds, + filteredTests, + testsDiffQuery.data, + testsDiffQuery.error, + testsDiffQuery.status, + ], + ); + + const pageTitle = formatMessage( + { id: 'title.treeCompare' }, + { treeName: `${treeName}/${branch}` }, + ); + + return ( + +
+ + + + s}> + + + + + + s} + > + + + + + + + + + + + + +
+

{pageTitle}

+

+ +

+
+ +
+
+
+ {treeName} + · + {branch} +
+ s} + > + + +
+ + updateSearch({ hashA: value })} + onHashBChange={value => updateSearch({ hashB: value })} + onSideAction={handleSideAction} + onSwap={handleSwap} + /> + +
+ + {!canCompare && commitsQuery.status === 'success' && ( +
+ +
+ )} + + {canCompare && ( + <> + + {compareQuery.data && ( + + )} + + +
+

+ +

+
+ +
+
+ updateSearch({ changeFilter: value })} + /> +
+ + updateSearch({ currentPageTab: value as PossibleTabs }) + } + /> +
+ + )} +
+
+ ); +}; + +export default TreeComparePage; diff --git a/dashboard/src/pages/TreeCompare/components/CompareChangeDisplay.tsx b/dashboard/src/pages/TreeCompare/components/CompareChangeDisplay.tsx new file mode 100644 index 000000000..0cf53ddda --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/CompareChangeDisplay.tsx @@ -0,0 +1,81 @@ +import type { JSX } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import type { + CompareChangeType, + CompareItemStatus, +} from '@/types/tree/TreeCompare'; + +import type { MessagesKey } from '@/locales/messages'; + +import { cn } from '@/lib/utils'; + +const STATUS_STYLES: Record = { + PASS: 'bg-light-green text-dark-green', + FAIL: 'bg-light-red text-red', + INCONCLUSIVE: 'bg-medium-gray text-dim-gray', + '—': 'bg-medium-gray text-dim-gray', +}; + +export function CompareStatusChip({ + status, +}: { + status: CompareItemStatus; +}): JSX.Element { + return ( + + {status} + + ); +} + +const CHANGE_STYLES: Record = { + regression: 'bg-light-red text-red', + fixed: 'bg-light-green text-dark-green', + newFailure: 'bg-orange-100 text-orange-700', + stillFailing: 'bg-medium-gray text-dim-gray', + newPass: 'bg-light-blue text-dark-blue', + appeared: 'bg-yellow text-dim-black', + disappeared: 'bg-dark-gray text-dim-gray', +}; + +const CHANGE_MESSAGE_IDS: Record = { + regression: 'treeCompare.change.regression', + fixed: 'treeCompare.change.fixed', + newFailure: 'treeCompare.change.newFailure', + stillFailing: 'treeCompare.change.stillFailing', + newPass: 'treeCompare.change.newPass', + appeared: 'treeCompare.change.appeared', + disappeared: 'treeCompare.change.disappeared', +}; + +export function CompareChangeBadge({ + change, +}: { + change: CompareChangeType; +}): JSX.Element { + return ( + + + + ); +} + +export function isFailureHighlight(change: CompareChangeType): boolean { + return ( + change === 'regression' || + change === 'newFailure' || + change === 'stillFailing' + ); +} diff --git a/dashboard/src/pages/TreeCompare/components/CompareChangeStats.tsx b/dashboard/src/pages/TreeCompare/components/CompareChangeStats.tsx new file mode 100644 index 000000000..777eb74c1 --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/CompareChangeStats.tsx @@ -0,0 +1,69 @@ +import type { JSX } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import type { CompareChangeFilter } from '@/types/tree/TreeCompare'; + +import type { MessagesKey } from '@/locales/messages'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { TooltipIcon } from '@/components/Icons/TooltipIcon'; + +import { toggleChangeFilter } from '@/utils/treeCompareDiff'; + +const FILTER_OPTIONS: { + value: CompareChangeFilter; + labelId: MessagesKey; +}[] = [ + { value: 'regression', labelId: 'treeCompare.change.regressions' }, + { value: 'fixed', labelId: 'treeCompare.change.fixed' }, + { value: 'newFailure', labelId: 'treeCompare.change.newFailures' }, + { value: 'stillFailing', labelId: 'treeCompare.change.stillFailing' }, + { value: 'newPass', labelId: 'treeCompare.change.newPasses' }, + { value: 'appeared', labelId: 'treeCompare.change.appeared' }, + { value: 'disappeared', labelId: 'treeCompare.change.disappeared' }, +]; + +export function CompareChangeFilterBar({ + value, + onChange, +}: { + value: readonly CompareChangeFilter[]; + onChange: (value: CompareChangeFilter[]) => void; +}): JSX.Element { + return ( +
+ + + + +
+ {FILTER_OPTIONS.map(option => { + const selected = value.includes(option.value); + return ( + + ); + })} +
+
+ ); +} diff --git a/dashboard/src/pages/TreeCompare/components/CompareFailuresTables.tsx b/dashboard/src/pages/TreeCompare/components/CompareFailuresTables.tsx new file mode 100644 index 000000000..d47f502ff --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/CompareFailuresTables.tsx @@ -0,0 +1,524 @@ +import type { ChangeEvent, JSX, ReactNode } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { useVirtualizer } from '@tanstack/react-virtual'; +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import type { + CompareBootFailureRow, + CompareBuildFailureRow, + CompareTestFailureRow, +} from '@/types/tree/TreeCompare'; + +import type { MessagesKey } from '@/locales/messages'; + +import DebounceInput from '@/components/DebounceInput/DebounceInput'; +import { + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +import { cn } from '@/lib/utils'; + +import { + CompareChangeBadge, + CompareStatusChip, + isFailureHighlight, +} from './CompareChangeDisplay'; + +const ESTIMATED_ROW_HEIGHT = 56; +const VIRTUALIZER_OVERSCAN = 10; +const VIRTUAL_TABLE_MAX_HEIGHT = 480; + +/** Fixed widths keep columns stable while virtualized rows swap in/out. */ +const BUILDS_COLGROUP = ( + + + + + + + +); + +const PATH_COLGROUP = ( + + + + + + + + +); + +type SortDirection = 'asc' | 'desc'; +type SortState = { + key: Key; + direction: SortDirection; +} | null; + +type BuildSortKey = 'config' | 'sideA' | 'sideB' | 'change'; +type PathSortKey = 'path' | 'hardware' | 'sideA' | 'sideB' | 'change'; + +function cycleSort( + current: SortState, + key: Key, +): SortState { + if (current?.key !== key) { + return { key, direction: 'asc' }; + } + if (current.direction === 'asc') { + return { key, direction: 'desc' }; + } + return null; +} + +function compareSortValues(a: unknown, b: unknown): number { + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + return String(a ?? '').localeCompare(String(b ?? ''), undefined, { + numeric: true, + sensitivity: 'base', + }); +} + +function sortRows( + rows: T[], + sort: SortState, + getters: Record unknown>, +): T[] { + if (!sort) { + return rows; + } + const getValue = getters[sort.key]; + const direction = sort.direction === 'asc' ? 1 : -1; + return [...rows].sort( + (left, right) => + direction * compareSortValues(getValue(left), getValue(right)), + ); +} + +function rowMatchesSearch(values: unknown[], query: string): boolean { + if (!query) { + return true; + } + const needle = query.toLowerCase(); + return values.some(value => + String(value ?? '') + .toLowerCase() + .includes(needle), + ); +} + +function CompareTableSearch({ + onSearchChange, +}: { + onSearchChange: (event: ChangeEvent) => void; +}): JSX.Element { + const { formatMessage } = useIntl(); + + // mt keeps the input clear of the sticky tabs header, which overlaps its top border. + return ( +
+ +
+ ); +} + +function SortableHead({ + intlKey, + sortKey, + sort, + onSort, + className, +}: { + intlKey: MessagesKey; + sortKey: Key; + sort: SortState; + onSort: (key: Key) => void; + className?: string; +}): JSX.Element { + const isActive = sort?.key === sortKey; + const ArrowIcon = !isActive + ? ArrowUpDown + : sort.direction === 'asc' + ? ArrowUp + : ArrowDown; + + return ( + + + + ); +} + +function SideCells({ + sideA, + sideB, +}: { + sideA: CompareBuildFailureRow['sideA']; + sideB: CompareBuildFailureRow['sideB']; +}): JSX.Element { + return ( + <> + +
+ +
+
+ + +
+ +
+
+ + ); +} + +function VirtualizedCompareTable({ + rowCount, + getRowId, + headerRow, + renderRow, + colGroup, + colCount, +}: { + rowCount: number; + getRowId: (index: number) => string; + headerRow: ReactNode; + renderRow: (index: number) => ReactNode; + colGroup: ReactNode; + colCount: number; +}): JSX.Element { + const parentRef = useRef(null); + const virtualizer = useVirtualizer({ + count: rowCount, + estimateSize: () => ESTIMATED_ROW_HEIGHT, + getScrollElement: () => parentRef.current, + overscan: VIRTUALIZER_OVERSCAN, + getItemKey: index => getRowId(index), + }); + const virtualItems = virtualizer.getVirtualItems(); + + const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0; + const paddingBottom = + virtualItems.length > 0 + ? virtualizer.getTotalSize() - virtualItems[virtualItems.length - 1].end + : 0; + + // Raw : ui/Table wraps in its own overflow+border box and breaks rounded + sticky. + // Spacers live in tbody so sticky thead can pin to the scroll container. + return ( +
+
+ {colGroup} + + {headerRow} + + + {paddingTop > 0 && ( + + + )} + {virtualItems.map(item => renderRow(item.index))} + {paddingBottom > 0 && ( + + + )} + +
+
+
+ + ); +} + +const BUILD_SORT_GETTERS: Record< + BuildSortKey, + (row: CompareBuildFailureRow) => unknown +> = { + config: row => row.config, + sideA: row => row.sideA, + sideB: row => row.sideB, + change: row => row.change, +}; + +export function CompareBuildsFailuresTable({ + rows, +}: { + rows: CompareBuildFailureRow[]; +}): JSX.Element { + const [sort, setSort] = useState>(null); + const [search, setSearch] = useState(''); + + const onSearchChange = useCallback((event: ChangeEvent) => { + setSearch(event.target.value); + }, []); + + const visibleRows = useMemo(() => { + const filtered = rows.filter(row => + rowMatchesSearch( + [row.config, row.arch, row.compiler, row.sideA, row.sideB, row.change], + search, + ), + ); + return sortRows(filtered, sort, BUILD_SORT_GETTERS); + }, [rows, search, sort]); + + return ( +
+ + visibleRows[index]?.id ?? String(index)} + colGroup={BUILDS_COLGROUP} + colCount={5} + headerRow={ + + setSort(current => cycleSort(current, key))} + /> + setSort(current => cycleSort(current, key))} + /> + + setSort(current => cycleSort(current, key))} + /> + setSort(current => cycleSort(current, key))} + /> + + } + renderRow={index => { + const row = visibleRows[index]; + if (!row) { + return null; + } + return ( + + +
+ {row.config} +
+
+ {row.arch} · {row.compiler} +
+
+ + +
+ +
+
+
+ ); + }} + /> +
+ ); +} + +const PATH_SORT_GETTERS: Record< + PathSortKey, + (row: CompareBootFailureRow | CompareTestFailureRow) => unknown +> = { + path: row => row.path, + hardware: row => row.hardware, + sideA: row => row.sideA, + sideB: row => row.sideB, + change: row => row.change, +}; + +function PathHardwareTable({ + rows, +}: { + rows: Array; +}): JSX.Element { + const [sort, setSort] = useState>(null); + const [search, setSearch] = useState(''); + + const onSearchChange = useCallback((event: ChangeEvent) => { + setSearch(event.target.value); + }, []); + + const visibleRows = useMemo(() => { + const filtered = rows.filter(row => + rowMatchesSearch( + [ + row.path, + row.config, + row.arch, + row.hardware, + row.sideA, + row.sideB, + row.change, + ], + search, + ), + ); + return sortRows(filtered, sort, PATH_SORT_GETTERS); + }, [rows, search, sort]); + + return ( +
+ + visibleRows[index]?.id ?? String(index)} + colGroup={PATH_COLGROUP} + colCount={6} + headerRow={ + + setSort(current => cycleSort(current, key))} + /> + setSort(current => cycleSort(current, key))} + /> + setSort(current => cycleSort(current, key))} + /> + + setSort(current => cycleSort(current, key))} + /> + setSort(current => cycleSort(current, key))} + /> + + } + renderRow={index => { + const row = visibleRows[index]; + if (!row) { + return null; + } + return ( + + +
+ {row.path} +
+
+ {row.arch} +
+
+ +
+ {row.hardware} +
+
+ + +
+ +
+
+
+ ); + }} + /> +
+ ); +} + +export function CompareBootsFailuresTable({ + rows, +}: { + rows: CompareBootFailureRow[]; +}): JSX.Element { + return ; +} + +export function CompareTestsFailuresTable({ + rows, +}: { + rows: CompareTestFailureRow[]; +}): JSX.Element { + return ; +} diff --git a/dashboard/src/pages/TreeCompare/components/CompareSummary.tsx b/dashboard/src/pages/TreeCompare/components/CompareSummary.tsx new file mode 100644 index 000000000..8342eb490 --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/CompareSummary.tsx @@ -0,0 +1,136 @@ +import type { JSX } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import type { MessagesKey } from '@/locales/messages'; +import type { CompareEntitySummary } from '@/types/tree/TreeCompare'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +import { cn } from '@/lib/utils'; + +type MatrixRowId = 'fixes' | 'regressions' | 'pass' | 'fail' | 'other'; + +type MatrixRow = { + id: MatrixRowId; + labelId: MessagesKey; + valueClassName: string; + striped?: boolean; + getValue: (summary: CompareEntitySummary) => number; +}; + +const MATRIX_ROWS: MatrixRow[] = [ + { + id: 'fixes', + labelId: 'treeCompare.matrix.fixes', + valueClassName: 'text-dark-green', + getValue: s => s.changes.fixed, + }, + { + id: 'regressions', + labelId: 'treeCompare.matrix.regressions', + valueClassName: 'text-red', + striped: true, + getValue: s => s.changes.regression, + }, + { + id: 'pass', + labelId: 'treeCompare.matrix.pass', + valueClassName: 'text-dark-green', + getValue: s => s.sideB.pass, + }, + { + id: 'fail', + labelId: 'treeCompare.matrix.fail', + valueClassName: 'text-red', + striped: true, + getValue: s => s.sideB.fail, + }, + { + id: 'other', + labelId: 'treeCompare.matrix.other', + valueClassName: 'text-dim-gray', + getValue: s => s.sideB.inconclusive, + }, +]; + +const ENTITY_COLUMNS: { + key: 'builds' | 'boots' | 'tests'; + labelId: MessagesKey; +}[] = [ + { key: 'builds', labelId: 'treeCompare.matrix.builds' }, + { key: 'boots', labelId: 'treeCompare.matrix.boots' }, + { key: 'tests', labelId: 'treeCompare.matrix.tests' }, +]; + +interface CompareSummaryProps { + builds: CompareEntitySummary; + boots: CompareEntitySummary; + tests: CompareEntitySummary; +} + +export function CompareSummary({ + builds, + boots, + tests, +}: CompareSummaryProps): JSX.Element { + const summaries = { builds, boots, tests }; + + return ( +
+

+ +

+
+ + + + + {ENTITY_COLUMNS.map(column => ( + + + + ))} + + + + {MATRIX_ROWS.map(row => ( + + + + + {ENTITY_COLUMNS.map(column => ( + + {row.getValue(summaries[column.key]).toLocaleString()} + + ))} + + ))} + +
+
+
+ ); +} diff --git a/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx b/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx new file mode 100644 index 000000000..ca68b07fb --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx @@ -0,0 +1,203 @@ +import type { JSX } from 'react'; + +import { ArrowLeftRight, GitBranch, History } from 'lucide-react'; +import { FormattedMessage } from 'react-intl'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import type { CompareRevision } from '@/types/tree/TreeCompare'; + +import { cn } from '@/lib/utils'; + +type RevisionSide = 'A' | 'B'; + +function TagChips({ tags }: { tags: string[] }): JSX.Element | null { + if (tags.length === 0) { + return null; + } + + return ( + + {tags.map(tag => ( + + {tag} + + ))} + + ); +} + +function RevisionCard({ + side, + selectedHash, + revisions, + onSelect, + onPrevious, + onBranchHead, +}: { + side: RevisionSide; + selectedHash: string; + revisions: CompareRevision[]; + onSelect: (hash: string) => void; + onPrevious: () => void; + onBranchHead: () => void; +}): JSX.Element { + const selected = revisions.find(r => r.hash === selectedHash); + + return ( +
+
+ + {side} + + + + +
+ + + +
+ + +
+ + {selected && ( +
+ {selected.commitName && ( +

+ {selected.commitName} +

+ )} + +

{selected.date}

+
+ )} +
+ ); +} + +interface RevisionSelectorBarProps { + hashA: string; + hashB: string; + revisions: CompareRevision[]; + onHashAChange: (hash: string) => void; + onHashBChange: (hash: string) => void; + onSideAction: (side: RevisionSide, action: 'previous' | 'branchHead') => void; + onSwap: () => void; +} + +export function RevisionSelectorBar({ + hashA, + hashB, + revisions, + onHashAChange, + onHashBChange, + onSideAction, + onSwap, +}: RevisionSelectorBarProps): JSX.Element { + return ( +
+
+ onSideAction('A', 'previous')} + onBranchHead={() => onSideAction('A', 'branchHead')} + /> + +
+ +
+ + onSideAction('B', 'previous')} + onBranchHead={() => onSideAction('B', 'branchHead')} + /> +
+
+ ); +} diff --git a/dashboard/src/pages/TreeDetails/TreeDetails.tsx b/dashboard/src/pages/TreeDetails/TreeDetails.tsx index 2374dbe4b..be4020be2 100644 --- a/dashboard/src/pages/TreeDetails/TreeDetails.tsx +++ b/dashboard/src/pages/TreeDetails/TreeDetails.tsx @@ -69,6 +69,8 @@ import { isEmptyObject } from '@/utils/utils'; import { sanitizeTreeinfo } from '@/utils/treeDetails'; +import { TreeCompareLink } from '@/pages/TreeCompare/TreeCompareLink'; + import TreeDetailsFilter from './TreeDetailsFilter'; import TreeDetailsTab from './Tabs/TreeDetailsTab'; @@ -411,7 +413,7 @@ const TreeDetails = ({ -
+
+ {sanitizedTreeInfo.treeName && sanitizedTreeInfo.gitBranch && ( +
+ +
+ )}
MainalternativesBBuildIdRouteRoute, } as any) +const MainTreeTreeNameBranchCompareRouteRoute = + MainTreeTreeNameBranchCompareRouteRouteImport.update({ + id: '/$treeName/$branch/compare', + path: '/$treeName/$branch/compare', + getParentRoute: () => MainTreeRouteRoute, + } as any) const MainTreeTreeNameBranchHashRouteRoute = MainTreeTreeNameBranchHashRouteRouteImport.update({ id: '/$treeName/$branch/$hash', path: '/$treeName/$branch/$hash', getParentRoute: () => MainTreeRouteRoute, } as any) +const MainTreeTreeNameBranchCompareIndexRoute = + MainTreeTreeNameBranchCompareIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => MainTreeTreeNameBranchCompareRouteRoute, + } as any) const MainTreeTreeNameBranchHashIndexRoute = MainTreeTreeNameBranchHashIndexRouteImport.update({ id: '/', @@ -324,6 +338,7 @@ export interface FileRoutesByFullPath { '/test/$testId/': typeof MainTestTestIdIndexRoute '/tree/$treeId/': typeof MainTreeTreeIdIndexRoute '/tree/$treeName/$branch/$hash': typeof MainTreeTreeNameBranchHashRouteRouteWithChildren + '/tree/$treeName/$branch/compare': typeof MainTreeTreeNameBranchCompareRouteRouteWithChildren '/b/$buildId/': typeof MainalternativesBBuildIdIndexRoute '/i/$issueId/': typeof MainalternativesIIssueIdIndexRoute '/t/$testId/': typeof MainalternativesTTestIdIndexRoute @@ -340,6 +355,7 @@ export interface FileRoutesByFullPath { '/tree/$treeId/build/$buildId/': typeof MainTreeTreeIdBuildBuildIdIndexRoute '/tree/$treeId/test/$testId/': typeof MainTreeTreeIdTestTestIdIndexRoute '/tree/$treeName/$branch/$hash/': typeof MainTreeTreeNameBranchHashIndexRoute + '/tree/$treeName/$branch/compare/': typeof MainTreeTreeNameBranchCompareIndexRoute '/c/$treeName/$branch/$hash/': typeof MainalternativesCTreeNameBranchHashIndexRoute } export interface FileRoutesByTo { @@ -372,6 +388,7 @@ export interface FileRoutesByTo { '/tree/$treeId/build/$buildId': typeof MainTreeTreeIdBuildBuildIdIndexRoute '/tree/$treeId/test/$testId': typeof MainTreeTreeIdTestTestIdIndexRoute '/tree/$treeName/$branch/$hash': typeof MainTreeTreeNameBranchHashIndexRoute + '/tree/$treeName/$branch/compare': typeof MainTreeTreeNameBranchCompareIndexRoute '/c/$treeName/$branch/$hash': typeof MainalternativesCTreeNameBranchHashIndexRoute } export interface FileRoutesById { @@ -403,6 +420,7 @@ export interface FileRoutesById { '/_main/test/$testId/': typeof MainTestTestIdIndexRoute '/_main/tree/$treeId/': typeof MainTreeTreeIdIndexRoute '/_main/tree/$treeName/$branch/$hash': typeof MainTreeTreeNameBranchHashRouteRouteWithChildren + '/_main/tree/$treeName/$branch/compare': typeof MainTreeTreeNameBranchCompareRouteRouteWithChildren '/_main/(alternatives)/b/$buildId/': typeof MainalternativesBBuildIdIndexRoute '/_main/(alternatives)/i/$issueId/': typeof MainalternativesIIssueIdIndexRoute '/_main/(alternatives)/t/$testId/': typeof MainalternativesTTestIdIndexRoute @@ -419,6 +437,7 @@ export interface FileRoutesById { '/_main/tree/$treeId/build/$buildId/': typeof MainTreeTreeIdBuildBuildIdIndexRoute '/_main/tree/$treeId/test/$testId/': typeof MainTreeTreeIdTestTestIdIndexRoute '/_main/tree/$treeName/$branch/$hash/': typeof MainTreeTreeNameBranchHashIndexRoute + '/_main/tree/$treeName/$branch/compare/': typeof MainTreeTreeNameBranchCompareIndexRoute '/_main/(alternatives)/c/$treeName/$branch/$hash/': typeof MainalternativesCTreeNameBranchHashIndexRoute } export interface FileRouteTypes { @@ -450,6 +469,7 @@ export interface FileRouteTypes { | '/test/$testId/' | '/tree/$treeId/' | '/tree/$treeName/$branch/$hash' + | '/tree/$treeName/$branch/compare' | '/b/$buildId/' | '/i/$issueId/' | '/t/$testId/' @@ -466,6 +486,7 @@ export interface FileRouteTypes { | '/tree/$treeId/build/$buildId/' | '/tree/$treeId/test/$testId/' | '/tree/$treeName/$branch/$hash/' + | '/tree/$treeName/$branch/compare/' | '/c/$treeName/$branch/$hash/' fileRoutesByTo: FileRoutesByTo to: @@ -498,6 +519,7 @@ export interface FileRouteTypes { | '/tree/$treeId/build/$buildId' | '/tree/$treeId/test/$testId' | '/tree/$treeName/$branch/$hash' + | '/tree/$treeName/$branch/compare' | '/c/$treeName/$branch/$hash' id: | '__root__' @@ -528,6 +550,7 @@ export interface FileRouteTypes { | '/_main/test/$testId/' | '/_main/tree/$treeId/' | '/_main/tree/$treeName/$branch/$hash' + | '/_main/tree/$treeName/$branch/compare' | '/_main/(alternatives)/b/$buildId/' | '/_main/(alternatives)/i/$issueId/' | '/_main/(alternatives)/t/$testId/' @@ -544,6 +567,7 @@ export interface FileRouteTypes { | '/_main/tree/$treeId/build/$buildId/' | '/_main/tree/$treeId/test/$testId/' | '/_main/tree/$treeName/$branch/$hash/' + | '/_main/tree/$treeName/$branch/compare/' | '/_main/(alternatives)/c/$treeName/$branch/$hash/' fileRoutesById: FileRoutesById } @@ -793,6 +817,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainalternativesBBuildIdIndexRouteImport parentRoute: typeof MainalternativesBBuildIdRouteRoute } + '/_main/tree/$treeName/$branch/compare': { + id: '/_main/tree/$treeName/$branch/compare' + path: '/$treeName/$branch/compare' + fullPath: '/tree/$treeName/$branch/compare' + preLoaderRoute: typeof MainTreeTreeNameBranchCompareRouteRouteImport + parentRoute: typeof MainTreeRouteRoute + } '/_main/tree/$treeName/$branch/$hash': { id: '/_main/tree/$treeName/$branch/$hash' path: '/$treeName/$branch/$hash' @@ -800,6 +831,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainTreeTreeNameBranchHashRouteRouteImport parentRoute: typeof MainTreeRouteRoute } + '/_main/tree/$treeName/$branch/compare/': { + id: '/_main/tree/$treeName/$branch/compare/' + path: '/' + fullPath: '/tree/$treeName/$branch/compare/' + preLoaderRoute: typeof MainTreeTreeNameBranchCompareIndexRouteImport + parentRoute: typeof MainTreeTreeNameBranchCompareRouteRoute + } '/_main/tree/$treeName/$branch/$hash/': { id: '/_main/tree/$treeName/$branch/$hash/' path: '/' @@ -962,10 +1000,26 @@ const MainTreeTreeNameBranchHashRouteRouteWithChildren = MainTreeTreeNameBranchHashRouteRouteChildren, ) +interface MainTreeTreeNameBranchCompareRouteRouteChildren { + MainTreeTreeNameBranchCompareIndexRoute: typeof MainTreeTreeNameBranchCompareIndexRoute +} + +const MainTreeTreeNameBranchCompareRouteRouteChildren: MainTreeTreeNameBranchCompareRouteRouteChildren = + { + MainTreeTreeNameBranchCompareIndexRoute: + MainTreeTreeNameBranchCompareIndexRoute, + } + +const MainTreeTreeNameBranchCompareRouteRouteWithChildren = + MainTreeTreeNameBranchCompareRouteRoute._addFileChildren( + MainTreeTreeNameBranchCompareRouteRouteChildren, + ) + interface MainTreeRouteRouteChildren { MainTreeTreeIdRouteRoute: typeof MainTreeTreeIdRouteRouteWithChildren MainTreeIndexRoute: typeof MainTreeIndexRoute MainTreeTreeNameBranchHashRouteRoute: typeof MainTreeTreeNameBranchHashRouteRouteWithChildren + MainTreeTreeNameBranchCompareRouteRoute: typeof MainTreeTreeNameBranchCompareRouteRouteWithChildren MainTreeTreeNameBranchIndexRoute: typeof MainTreeTreeNameBranchIndexRoute } @@ -974,6 +1028,8 @@ const MainTreeRouteRouteChildren: MainTreeRouteRouteChildren = { MainTreeIndexRoute: MainTreeIndexRoute, MainTreeTreeNameBranchHashRouteRoute: MainTreeTreeNameBranchHashRouteRouteWithChildren, + MainTreeTreeNameBranchCompareRouteRoute: + MainTreeTreeNameBranchCompareRouteRouteWithChildren, MainTreeTreeNameBranchIndexRoute: MainTreeTreeNameBranchIndexRoute, } diff --git a/dashboard/src/routes/_main/tree/$treeName/$branch/compare/index.tsx b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/index.tsx new file mode 100644 index 000000000..128be32f9 --- /dev/null +++ b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import TreeComparePage from '@/pages/TreeCompare/TreeComparePage'; + +export const Route = createFileRoute('/_main/tree/$treeName/$branch/compare/')({ + component: TreeComparePage, +}); diff --git a/dashboard/src/routes/_main/tree/$treeName/$branch/compare/route.tsx b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/route.tsx new file mode 100644 index 000000000..56b723535 --- /dev/null +++ b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/route.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; + +import { + compareDefaultValues, + compareSearchSchema, +} from '@/types/tree/TreeCompare'; + +export const Route = createFileRoute('/_main/tree/$treeName/$branch/compare')({ + validateSearch: compareSearchSchema, + search: { middlewares: [stripSearchParams(compareDefaultValues)] }, +}); diff --git a/dashboard/src/types/tree/TreeCompare.ts b/dashboard/src/types/tree/TreeCompare.ts new file mode 100644 index 000000000..52505e8aa --- /dev/null +++ b/dashboard/src/types/tree/TreeCompare.ts @@ -0,0 +1,158 @@ +import { z } from 'zod'; + +import { possibleTabs } from '@/types/tree/TreeDetails'; + +export type CompareStatusCounts = { + pass: number; + fail: number; + inconclusive: number; +}; + +export type CompareDelta = { + pass: number; + fail: number; +}; + +export const compareChangeTypes = [ + 'regression', + 'fixed', + 'newFailure', + 'stillFailing', + 'newPass', + 'appeared', + 'disappeared', +] as const; + +export type CompareChangeType = (typeof compareChangeTypes)[number]; + +export type CompareChangeStats = Record; + +export type CompareEntitySummary = { + sideA: CompareStatusCounts; + sideB: CompareStatusCounts; + delta: CompareDelta; + changes: CompareChangeStats; +}; + +export type CompareRevision = { + hash: string; + shortHash: string; + commitName: string; + date: string; + tags: string[]; +}; + +export type TreeCompareData = { + treeName: string; + branch: string; + gitUrl: string; + summary: { + builds: CompareEntitySummary; + boots: CompareEntitySummary; + tests: CompareEntitySummary; + }; +}; + +/** Side status for an individual compared item. */ +export type CompareItemStatus = 'PASS' | 'FAIL' | 'INCONCLUSIVE' | '—'; + +export type CompareGroupedApiStatus = 'PASS' | 'FAIL' | 'INCONCLUSIVE'; + +/** Builds detail endpoint: GET .../compare/builds (bare array) */ +export type TreeCompareBuildDiffApiRow = { + config_name: string; + architecture: string; + compiler: string; + status_a: CompareGroupedApiStatus | null; + status_b: CompareGroupedApiStatus | null; +}; + +/** Boots/tests detail endpoints: GET .../compare/boots|tests (bare array) */ +export type TreeCompareTestDiffApiRow = { + path: string; + config_name: string; + architecture: string; + platform: string; + status_a: CompareGroupedApiStatus | null; + status_b: CompareGroupedApiStatus | null; +}; + +/** Filter chips for the breakdown tables — one per change type. */ +export const compareChangeFilters = compareChangeTypes; +export type CompareChangeFilter = (typeof compareChangeFilters)[number]; + +export const compareDefaultChangeFilters: CompareChangeFilter[] = [ + 'regression', + 'fixed', +]; + +type CompareFailureRowBase = { + id: string; + change: CompareChangeType; + sideA: CompareItemStatus; + sideB: CompareItemStatus; +}; + +export type CompareBuildFailureRow = CompareFailureRowBase & { + config: string; + arch: string; + compiler: string; +}; + +export type CompareBootFailureRow = CompareFailureRowBase & { + path: string; + config: string; + arch: string; + hardware: string; +}; + +export type CompareTestFailureRow = CompareFailureRowBase & { + path: string; + config: string; + arch: string; + hardware: string; +}; + +export type CompareFailureRow = + | CompareBuildFailureRow + | CompareBootFailureRow + | CompareTestFailureRow; + +export const compareDefaultValues = { + hashA: '', + hashB: '', + origin: 'maestro', + currentPageTab: 'global.builds' as const, + changeFilter: compareDefaultChangeFilters, +}; + +export const compareSearchSchema = z.object({ + hashA: z.string().catch(''), + hashB: z.string().catch(''), + origin: z + .string() + .default(compareDefaultValues.origin) + .catch(compareDefaultValues.origin), + currentPageTab: z + .enum(possibleTabs) + .default(compareDefaultValues.currentPageTab) + .catch(compareDefaultValues.currentPageTab), + // Single URL value becomes a string; normalize to an array. + changeFilter: z.preprocess( + value => { + if (!value) { + return compareDefaultChangeFilters; + } + return Array.isArray(value) ? value : [value]; + }, + z + .array(z.enum(compareChangeFilters)) + .default(compareDefaultChangeFilters) + .catch(compareDefaultChangeFilters), + ), +}); + +export type CompareSearch = z.infer; + +export const compareRouteName = '/_main/tree/$treeName/$branch/compare'; +export const compareNavigateFrom = '/tree/$treeName/$branch/compare'; diff --git a/dashboard/src/types/tree/TreeDetails.tsx b/dashboard/src/types/tree/TreeDetails.tsx index b3afc6702..a30682d0c 100644 --- a/dashboard/src/types/tree/TreeDetails.tsx +++ b/dashboard/src/types/tree/TreeDetails.tsx @@ -186,7 +186,10 @@ export type PaginatedCommitHistoryByTree = { export type Commit = { git_commit_hash: string; - earliest_checkout: string; + git_commit_name?: string | null; + git_commit_tags?: string[] | null; + earliest_checkout?: string; + last_checkout?: string; }; export type BuildCountsResponse = { diff --git a/dashboard/src/utils/treeCompareDiff.test.ts b/dashboard/src/utils/treeCompareDiff.test.ts new file mode 100644 index 000000000..684dbeb9c --- /dev/null +++ b/dashboard/src/utils/treeCompareDiff.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + deriveCompareChange, + applyChangeFilter, + mapBootOrTestDiffRows, + mapBuildDiffRows, + toggleChangeFilter, +} from './treeCompareDiff'; + +describe('deriveCompareChange', () => { + it('maps backend change-count transitions', () => { + expect(deriveCompareChange('PASS', 'FAIL')).toBe('regression'); + expect(deriveCompareChange('FAIL', 'PASS')).toBe('fixed'); + expect(deriveCompareChange('—', 'FAIL')).toBe('newFailure'); + expect(deriveCompareChange('FAIL', 'FAIL')).toBe('stillFailing'); + expect(deriveCompareChange('—', 'PASS')).toBe('newPass'); + }); + + it('classifies one-sided presence as appeared/disappeared', () => { + expect(deriveCompareChange('—', 'INCONCLUSIVE')).toBe('appeared'); + expect(deriveCompareChange('PASS', '—')).toBe('disappeared'); + expect(deriveCompareChange('FAIL', '—')).toBe('disappeared'); + expect(deriveCompareChange('INCONCLUSIVE', '—')).toBe('disappeared'); + }); + + it('classifies INCONCLUSIVE transitions like the backend counts', () => { + expect(deriveCompareChange('PASS', 'INCONCLUSIVE')).toBe('regression'); + expect(deriveCompareChange('FAIL', 'INCONCLUSIVE')).toBe('fixed'); + expect(deriveCompareChange('INCONCLUSIVE', 'FAIL')).toBe('newFailure'); + expect(deriveCompareChange('INCONCLUSIVE', 'PASS')).toBe('newPass'); + }); +}); + +describe('applyChangeFilter', () => { + const rows = [ + { id: '1', change: 'regression' as const }, + { id: '2', change: 'fixed' as const }, + { id: '3', change: 'stillFailing' as const }, + { id: '4', change: 'newPass' as const }, + ]; + + it('keeps only selected change types', () => { + expect( + applyChangeFilter(rows, ['regression', 'fixed']).map(r => r.id), + ).toEqual(['1', '2']); + }); + + it('returns all rows when nothing is selected', () => { + expect(applyChangeFilter(rows, [])).toEqual(rows); + }); + + it('returns all rows when every filter chip is selected', () => { + expect( + applyChangeFilter(rows, [ + 'regression', + 'fixed', + 'newFailure', + 'stillFailing', + 'newPass', + 'appeared', + 'disappeared', + ]), + ).toEqual(rows); + }); +}); + +describe('toggleChangeFilter', () => { + it('adds and removes values in stable order', () => { + expect(toggleChangeFilter(['regression'], 'fixed')).toEqual([ + 'regression', + 'fixed', + ]); + expect(toggleChangeFilter(['regression', 'fixed'], 'regression')).toEqual([ + 'fixed', + ]); + }); +}); + +describe('mapBuildDiffRows', () => { + it('maps snake_case API rows into table rows', () => { + const [row] = mapBuildDiffRows([ + { + config_name: 'defconfig', + architecture: 'arm64', + compiler: 'gcc', + status_a: 'PASS', + status_b: 'FAIL', + }, + ]); + + expect(row).toMatchObject({ + config: 'defconfig', + arch: 'arm64', + compiler: 'gcc', + sideA: 'PASS', + sideB: 'FAIL', + change: 'regression', + }); + }); +}); + +describe('mapBootOrTestDiffRows', () => { + it('maps platform to hardware and null status to dash', () => { + const [row] = mapBootOrTestDiffRows( + [ + { + path: 'boot', + config_name: 'defconfig', + architecture: 'arm64', + platform: 'qemu-arm64', + status_a: null, + status_b: 'FAIL', + }, + ], + 'boot', + ); + + expect(row).toMatchObject({ + path: 'boot', + config: 'defconfig', + arch: 'arm64', + hardware: 'qemu-arm64', + sideA: '—', + sideB: 'FAIL', + change: 'newFailure', + }); + }); +}); diff --git a/dashboard/src/utils/treeCompareDiff.ts b/dashboard/src/utils/treeCompareDiff.ts new file mode 100644 index 000000000..391ae8ac4 --- /dev/null +++ b/dashboard/src/utils/treeCompareDiff.ts @@ -0,0 +1,136 @@ +import type { + CompareBootFailureRow, + CompareBuildFailureRow, + CompareChangeFilter, + CompareChangeType, + CompareGroupedApiStatus, + CompareItemStatus, + CompareTestFailureRow, + TreeCompareBuildDiffApiRow, + TreeCompareTestDiffApiRow, +} from '@/types/tree/TreeCompare'; +import { compareChangeFilters } from '@/types/tree/TreeCompare'; + +export function apiStatusToItemStatus( + status: CompareGroupedApiStatus | null | undefined, +): CompareItemStatus { + if (!status) { + return '—'; + } + return status; +} + +/** Mirror backend _CHANGE_COUNT_SELECT categories for A→B transitions. */ +export function deriveCompareChange( + statusA: CompareItemStatus, + statusB: CompareItemStatus, +): CompareChangeType { + // Present on A, absent on B — was wrongly folded into regression/fixed. + if (statusA !== '—' && statusB === '—') { + return 'disappeared'; + } + // Absent on A, present on B. + if (statusA === '—' && statusB !== '—') { + if (statusB === 'FAIL') { + return 'newFailure'; + } + if (statusB === 'PASS') { + return 'newPass'; + } + return 'appeared'; + } + if (statusA === 'PASS' && statusB === 'FAIL') { + return 'regression'; + } + if (statusA === 'FAIL' && statusB === 'PASS') { + return 'fixed'; + } + if (statusA === 'FAIL' && statusB === 'FAIL') { + return 'stillFailing'; + } + // Both sides present; remaining transitions involve INCONCLUSIVE. + if (statusB === 'FAIL') { + return 'newFailure'; + } + if (statusB === 'PASS') { + return statusA === 'FAIL' ? 'fixed' : 'newPass'; + } + // PASS/FAIL → INCONCLUSIVE (INCONCLUSIVE→INCONCLUSIVE should not reach here). + if (statusA === 'PASS') { + return 'regression'; + } + if (statusA === 'FAIL') { + return 'fixed'; + } + return 'appeared'; +} + +export function mapBuildDiffRows( + rows: TreeCompareBuildDiffApiRow[], +): CompareBuildFailureRow[] { + return rows.map(row => { + const sideA = apiStatusToItemStatus(row.status_a); + const sideB = apiStatusToItemStatus(row.status_b); + return { + id: `build:${row.config_name}:${row.architecture}:${row.compiler}`, + config: row.config_name, + arch: row.architecture, + compiler: row.compiler, + sideA, + sideB, + change: deriveCompareChange(sideA, sideB), + }; + }); +} + +export function mapBootOrTestDiffRows( + rows: TreeCompareTestDiffApiRow[], + kind: 'boot' | 'test', +): Array { + return rows.map(row => { + const sideA = apiStatusToItemStatus(row.status_a); + const sideB = apiStatusToItemStatus(row.status_b); + return { + id: `${kind}:${row.path}:${row.config_name}:${row.architecture}:${row.platform}`, + path: row.path, + config: row.config_name, + arch: row.architecture, + hardware: row.platform, + sideA, + sideB, + change: deriveCompareChange(sideA, sideB), + }; + }); +} + +/** Keep URL/search order stable and drop unknown values. */ +export function normalizeChangeFilters( + filters: readonly CompareChangeFilter[], +): CompareChangeFilter[] { + return compareChangeFilters.filter(filter => filters.includes(filter)); +} + +export function toggleChangeFilter( + current: readonly CompareChangeFilter[], + value: CompareChangeFilter, +): CompareChangeFilter[] { + const next = current.includes(value) + ? current.filter(filter => filter !== value) + : [...current, value]; + return normalizeChangeFilters(next); +} + +/** + * Empty selection or every chip selected both mean "show all" — same as + * former "All changes". Partial selection keeps matching change types. + */ +export function applyChangeFilter( + rows: T[], + changeFilter: readonly CompareChangeFilter[], +): T[] { + if (changeFilter.length === 0) { + return rows; + } + const selected = new Set(changeFilter); + return rows.filter(row => selected.has(row.change)); +}