intermittent_failures.py (6121B)
1 # This Source Code Form is subject to the terms of the Mozilla Public 2 # License, v. 2.0. If a copy of the MPL was not distributed with this 3 # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 5 """ 6 Shared module for fetching intermittent test failure data from Treeherder and Bugzilla. 7 """ 8 9 import datetime 10 import re 11 from typing import Literal, Optional, TypedDict 12 13 import requests 14 from wpt_path_utils import resolve_wpt_path 15 16 USER_AGENT = "mach-intermittent-failures/1.0" 17 18 19 class BugzillaFailure(TypedDict): 20 bug_id: int 21 bug_count: int 22 23 24 class BugzillaSummary(TypedDict): 25 summary: str 26 id: int 27 status: Optional[str] 28 resolution: Optional[str] 29 creation_time: Optional[str] 30 last_change_time: Optional[str] 31 comment_count: Optional[int] 32 33 34 class IntermittentFailure(TypedDict): 35 bug_id: int 36 failure_count: int 37 summary: str 38 status: str 39 resolution: str 40 test_path: Optional[str] 41 creation_time: Optional[str] 42 last_change_time: Optional[str] 43 comment_count: Optional[int] 44 45 46 class IntermittentFailuresFetcher: 47 """Fetches intermittent test failure data from Treeherder and Bugzilla APIs.""" 48 49 def __init__(self, days: int = 7, threshold: int = 30, verbose: bool = False): 50 self.days = days 51 self.threshold = threshold 52 self.verbose = verbose 53 self.end_date = datetime.datetime.now() 54 self.start_date = self.end_date - datetime.timedelta(days=self.days) 55 56 def get_failures(self, branch: str = "trunk") -> list[IntermittentFailure]: 57 """ 58 Fetch intermittent failures that meet the threshold. 59 60 Returns a list of intermittent failures with bug information. 61 """ 62 bugzilla_failures = self._get_bugzilla_failures(branch) 63 bug_list = self._keep_bugs_above_threshold(bugzilla_failures) 64 65 if not bug_list: 66 return [] 67 68 bug_summaries = self._get_bugzilla_summaries(bug_list) 69 70 results = [] 71 for bug in bug_summaries: 72 bug_id = bug["id"] 73 if bug_id in bug_list: 74 result: IntermittentFailure = { 75 "bug_id": bug_id, 76 "failure_count": self._get_failure_count(bugzilla_failures, bug_id), 77 "summary": bug["summary"], 78 "status": bug.get("status", "UNKNOWN"), 79 "resolution": bug.get("resolution", ""), 80 "test_path": None, 81 "creation_time": bug.get("creation_time"), 82 "last_change_time": bug.get("last_change_time"), 83 "comment_count": bug.get("comment_count"), 84 } 85 86 if "single tracking bug" in bug["summary"]: 87 match = re.findall( 88 r" ([^\s]+\/?\.[a-z0-9-A-Z]+) \|", bug["summary"] 89 ) 90 if match: 91 test_path = match[0] 92 if test_path.startswith("/"): 93 test_path = resolve_wpt_path(test_path) 94 result["test_path"] = test_path 95 96 results.append(result) 97 98 return results 99 100 def get_single_tracking_bugs_with_paths( 101 self, branch: str = "trunk" 102 ) -> list[tuple[int, str]]: 103 """ 104 Get only single tracking bugs that have test paths. 105 This is what high_freq_skipfails uses. 106 107 Returns a list of (bug_id, test_path) tuples. 108 """ 109 failures = self.get_failures(branch) 110 111 results = [] 112 for failure in failures: 113 if failure["test_path"] and "single tracking bug" in failure["summary"]: 114 results.append((failure["bug_id"], failure["test_path"])) 115 116 return results 117 118 def _get_bugzilla_failures(self, branch: str = "trunk") -> list[BugzillaFailure]: 119 """Fetch failure data from Treeherder API.""" 120 url = ( 121 f"https://treeherder.mozilla.org/api/failures/" 122 f"?startday={self.start_date.date()}&endday={self.end_date.date()}" 123 f"&tree={branch}&failurehash=all" 124 ) 125 if self.verbose: 126 print(f"[DEBUG] Fetching failures from Treeherder: {url}") 127 response = requests.get(url, headers={"User-agent": USER_AGENT}) 128 response.raise_for_status() 129 130 return [ 131 item 132 for item in response.json() 133 if "bug_id" in item and isinstance(item["bug_id"], int) 134 ] 135 136 def _keep_bugs_above_threshold( 137 self, failure_list: list[BugzillaFailure] 138 ) -> list[int]: 139 """Filter bugs that have failure counts above the threshold.""" 140 if not failure_list: 141 return [] 142 143 bug_counts = {} 144 for failure in failure_list: 145 bug_id = failure["bug_id"] 146 bug_counts[bug_id] = bug_counts.get(bug_id, 0) + failure.get("bug_count", 1) 147 148 return [ 149 bug_id for bug_id, count in bug_counts.items() if count >= self.threshold 150 ] 151 152 def _get_failure_count( 153 self, failure_list: list[BugzillaFailure], bug_id: int 154 ) -> int: 155 """Get the total failure count for a specific bug.""" 156 total = 0 157 for failure in failure_list: 158 if failure["bug_id"] == bug_id: 159 total += failure.get("bug_count", 1) 160 return total 161 162 def _get_bugzilla_summaries(self, bug_id_list: list[int]) -> list[BugzillaSummary]: 163 """Fetch bug summaries from Bugzilla REST API.""" 164 if not bug_id_list: 165 return [] 166 167 url = ( 168 f"https://bugzilla.mozilla.org/rest/bug" 169 f"?include_fields=summary,id,status,resolution,creation_time,last_change_time,comment_count" 170 f"&id={','.join(str(id) for id in bug_id_list)}" 171 ) 172 if self.verbose: 173 print(f"[DEBUG] Fetching bug details from Bugzilla: {url}") 174 response = requests.get(url, headers={"User-agent": USER_AGENT}) 175 response.raise_for_status() 176 177 json_response: dict[Literal["bugs"], list[BugzillaSummary]] = response.json() 178 return json_response.get("bugs", [])