|
| 1 | +from collections import defaultdict |
| 2 | +from dataclasses import dataclass, field |
| 3 | +from typing import Callable, Literal, Optional |
| 4 | + |
| 5 | +from kernelCI_app.constants.general import UNKNOWN_STRING |
| 6 | +from kernelCI_app.constants.process_pending import ROLLUP_STATUS_FIELDS |
| 7 | +from kernelCI_app.typeModels.treeCompare import ( |
| 8 | + CompareDelta, |
| 9 | + CompareEntitySummary, |
| 10 | + CompareGroupRow, |
| 11 | + CompareGroups, |
| 12 | + CompareStatusCounts, |
| 13 | + CompareSummary, |
| 14 | + TreeCompareResponse, |
| 15 | +) |
| 16 | + |
| 17 | +BucketKey = Literal["pass", "fail", "inconclusive"] |
| 18 | + |
| 19 | + |
| 20 | +@dataclass |
| 21 | +class _HashAccumulator: |
| 22 | + builds: CompareStatusCounts = field(default_factory=CompareStatusCounts) |
| 23 | + boots: CompareStatusCounts = field(default_factory=CompareStatusCounts) |
| 24 | + tests: CompareStatusCounts = field(default_factory=CompareStatusCounts) |
| 25 | + build_groups: dict[str, CompareStatusCounts] = field( |
| 26 | + default_factory=lambda: defaultdict(CompareStatusCounts) |
| 27 | + ) |
| 28 | + boot_groups: dict[str, CompareStatusCounts] = field( |
| 29 | + default_factory=lambda: defaultdict(CompareStatusCounts) |
| 30 | + ) |
| 31 | + test_groups: dict[str, CompareStatusCounts] = field( |
| 32 | + default_factory=lambda: defaultdict(CompareStatusCounts) |
| 33 | + ) |
| 34 | + |
| 35 | + |
| 36 | +def _empty_counts() -> CompareStatusCounts: |
| 37 | + return CompareStatusCounts() |
| 38 | + |
| 39 | + |
| 40 | +def _status_to_bucket(status: Optional[str]) -> BucketKey: |
| 41 | + if status is None: |
| 42 | + return "inconclusive" |
| 43 | + normalized = status.upper() |
| 44 | + if normalized == "PASS": |
| 45 | + return "pass" |
| 46 | + if normalized == "FAIL": |
| 47 | + return "fail" |
| 48 | + return "inconclusive" |
| 49 | + |
| 50 | + |
| 51 | +def _increment_bucket( |
| 52 | + counts: CompareStatusCounts, bucket: BucketKey, amount: int |
| 53 | +) -> None: |
| 54 | + if amount <= 0: |
| 55 | + return |
| 56 | + if bucket == "pass": |
| 57 | + counts.pass_count += amount |
| 58 | + elif bucket == "fail": |
| 59 | + counts.fail_count += amount |
| 60 | + else: |
| 61 | + counts.inconclusive += amount |
| 62 | + |
| 63 | + |
| 64 | +def _rollup_status_to_bucket(status_name: str) -> BucketKey: |
| 65 | + if status_name == "PASS": |
| 66 | + return "pass" |
| 67 | + if status_name == "FAIL": |
| 68 | + return "fail" |
| 69 | + return "inconclusive" |
| 70 | + |
| 71 | + |
| 72 | +def _boot_group_key(row_dict: dict) -> str: |
| 73 | + return ( |
| 74 | + row_dict.get("test_platform") |
| 75 | + or row_dict.get("hardware_key") |
| 76 | + or UNKNOWN_STRING |
| 77 | + ) |
| 78 | + |
| 79 | + |
| 80 | +def _test_group_key(row_dict: dict) -> str: |
| 81 | + path_group = row_dict.get("path_group") or UNKNOWN_STRING |
| 82 | + arch = row_dict.get("build_architecture") or UNKNOWN_STRING |
| 83 | + if arch == UNKNOWN_STRING: |
| 84 | + return path_group |
| 85 | + return f"{path_group}/{arch}" |
| 86 | + |
| 87 | + |
| 88 | +def process_rollup_rows( |
| 89 | + *, |
| 90 | + rows: list[dict], |
| 91 | + commit_hashes: list[str], |
| 92 | +) -> dict[str, _HashAccumulator]: |
| 93 | + accumulators = {commit_hash: _HashAccumulator() for commit_hash in commit_hashes} |
| 94 | + |
| 95 | + for row_dict in rows: |
| 96 | + commit_hash = row_dict["git_commit_hash"] |
| 97 | + is_boot_row = row_dict["is_boot"] |
| 98 | + acc = accumulators.setdefault(commit_hash, _HashAccumulator()) |
| 99 | + group_id = _boot_group_key(row_dict) if is_boot_row else _test_group_key(row_dict) |
| 100 | + target = acc.boots if is_boot_row else acc.tests |
| 101 | + group_map = acc.boot_groups if is_boot_row else acc.test_groups |
| 102 | + |
| 103 | + for status_name, field_name in ROLLUP_STATUS_FIELDS.items(): |
| 104 | + count = row_dict.get(field_name, 0) or 0 |
| 105 | + if count <= 0: |
| 106 | + continue |
| 107 | + bucket = _rollup_status_to_bucket(status_name) |
| 108 | + _increment_bucket(target, bucket, count) |
| 109 | + _increment_bucket(group_map[group_id], bucket, count) |
| 110 | + |
| 111 | + return accumulators |
| 112 | + |
| 113 | + |
| 114 | +def process_build_rows( |
| 115 | + *, |
| 116 | + rows: list[dict], |
| 117 | + commit_hashes: list[str], |
| 118 | +) -> dict[str, _HashAccumulator]: |
| 119 | + accumulators = {commit_hash: _HashAccumulator() for commit_hash in commit_hashes} |
| 120 | + |
| 121 | + for row in rows: |
| 122 | + commit_hash = row["git_commit_hash"] |
| 123 | + config = row.get("config_name") or UNKNOWN_STRING |
| 124 | + count = row.get("count") or 0 |
| 125 | + acc = accumulators.setdefault(commit_hash, _HashAccumulator()) |
| 126 | + bucket = _status_to_bucket(row.get("status")) |
| 127 | + _increment_bucket(acc.builds, bucket, count) |
| 128 | + _increment_bucket(acc.build_groups[config], bucket, count) |
| 129 | + |
| 130 | + return accumulators |
| 131 | + |
| 132 | + |
| 133 | +def _make_delta(side_a: CompareStatusCounts, side_b: CompareStatusCounts) -> CompareDelta: |
| 134 | + return CompareDelta( |
| 135 | + **{ |
| 136 | + "pass": side_b.pass_count - side_a.pass_count, |
| 137 | + "fail": side_b.fail_count - side_a.fail_count, |
| 138 | + } |
| 139 | + ) |
| 140 | + |
| 141 | + |
| 142 | +def _make_entity_summary( |
| 143 | + *, |
| 144 | + side_a: CompareStatusCounts, |
| 145 | + side_b: CompareStatusCounts, |
| 146 | +) -> CompareEntitySummary: |
| 147 | + return CompareEntitySummary( |
| 148 | + sideA=side_a, |
| 149 | + sideB=side_b, |
| 150 | + delta=_make_delta(side_a, side_b), |
| 151 | + ) |
| 152 | + |
| 153 | + |
| 154 | +def _make_group_rows( |
| 155 | + *, |
| 156 | + group_maps: tuple[dict[str, CompareStatusCounts], dict[str, CompareStatusCounts]], |
| 157 | + label_fn: Optional[Callable[[str], str]] = None, |
| 158 | +) -> list[CompareGroupRow]: |
| 159 | + side_a_groups, side_b_groups = group_maps |
| 160 | + all_ids = set(side_a_groups) | set(side_b_groups) |
| 161 | + rows: list[CompareGroupRow] = [] |
| 162 | + |
| 163 | + for group_id in all_ids: |
| 164 | + side_a = side_a_groups.get(group_id, _empty_counts()) |
| 165 | + side_b = side_b_groups.get(group_id, _empty_counts()) |
| 166 | + label = label_fn(group_id) if label_fn else group_id |
| 167 | + rows.append( |
| 168 | + CompareGroupRow( |
| 169 | + id=group_id, |
| 170 | + label=label, |
| 171 | + sideA=side_a, |
| 172 | + sideB=side_b, |
| 173 | + delta=_make_delta(side_a, side_b), |
| 174 | + ) |
| 175 | + ) |
| 176 | + |
| 177 | + return rows |
| 178 | + |
| 179 | + |
| 180 | +def _test_group_label(group_id: str) -> str: |
| 181 | + if "/" in group_id: |
| 182 | + path_group, arch = group_id.split("/", 1) |
| 183 | + return f"{path_group} · {arch}" |
| 184 | + return group_id |
| 185 | + |
| 186 | + |
| 187 | +def build_compare_response( |
| 188 | + *, |
| 189 | + hash_a: str, |
| 190 | + hash_b: str, |
| 191 | + tree_name: str, |
| 192 | + branch: str, |
| 193 | + git_url: str, |
| 194 | + accumulators: dict[str, _HashAccumulator], |
| 195 | +) -> TreeCompareResponse: |
| 196 | + acc_a = accumulators.get(hash_a, _HashAccumulator()) |
| 197 | + acc_b = accumulators.get(hash_b, _HashAccumulator()) |
| 198 | + |
| 199 | + return TreeCompareResponse( |
| 200 | + treeName=tree_name, |
| 201 | + branch=branch, |
| 202 | + gitUrl=git_url, |
| 203 | + summary=CompareSummary( |
| 204 | + builds=_make_entity_summary(side_a=acc_a.builds, side_b=acc_b.builds), |
| 205 | + boots=_make_entity_summary(side_a=acc_a.boots, side_b=acc_b.boots), |
| 206 | + tests=_make_entity_summary(side_a=acc_a.tests, side_b=acc_b.tests), |
| 207 | + ), |
| 208 | + groups=CompareGroups( |
| 209 | + builds=_make_group_rows( |
| 210 | + group_maps=(acc_a.build_groups, acc_b.build_groups), |
| 211 | + ), |
| 212 | + boots=_make_group_rows( |
| 213 | + group_maps=(acc_a.boot_groups, acc_b.boot_groups), |
| 214 | + ), |
| 215 | + tests=_make_group_rows( |
| 216 | + group_maps=(acc_a.test_groups, acc_b.test_groups), |
| 217 | + label_fn=_test_group_label, |
| 218 | + ), |
| 219 | + ), |
| 220 | + ) |
0 commit comments