report.py (9420B)
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 https://mozilla.org/MPL/2.0/. 4 5 import hashlib 6 import json 7 import re 8 import sys 9 import webbrowser 10 from typing import Union 11 12 import bugzilla 13 import click 14 from click.utils import echo 15 16 from qm_try_analysis import stackanalysis, utils 17 from qm_try_analysis.logging import error, info, warning 18 19 # Flag for toggling development mod 20 DEV = False 21 22 # Constants for Bugzilla URLs 23 if DEV: 24 BUGZILLA_BASE_URL = "https://bugzilla-dev.allizom.org/" 25 else: 26 BUGZILLA_BASE_URL = "https://bugzilla.mozilla.org/" 27 28 BUGZILLA_API_URL = BUGZILLA_BASE_URL + "rest/" 29 BUGZILLA_ATTACHMENT_URL = BUGZILLA_BASE_URL + "attachment.cgi?id=" 30 BUGZILLA_BUG_URL = BUGZILLA_BASE_URL + "show_bug.cgi?id=" 31 32 # Constants for static bugs 33 QM_TRY_FAILURES_BUG = 1702411 34 WARNING_STACKS_BUG = 1711703 35 36 # Regex pattern for parsing anchor strings 37 ANCHOR_REGEX_PATTERN = re.compile(r"([^:]+):([^:]+)?:*([^:]+)?") 38 39 40 @click.command() 41 @click.option( 42 "-k", 43 "--key", 44 help="Your personal Bugzilla API key", 45 required=True, 46 ) 47 @click.option( 48 "--stacksfile", 49 type=click.File("rb"), 50 help="The output file of the previous analysis run. You only have to specify this, if the previous run does not include this info.", 51 ) 52 @click.option( 53 "--open-modified/--no-open-modified", 54 default=True, 55 help="Whether to open modified bugs in your default browser after updating them.", 56 ) 57 @click.option( 58 "-w", 59 "--workdir", 60 type=click.Path(file_okay=False, exists=True, writable=True), 61 default="output", 62 help="Working directory", 63 ) 64 def report_qm_failures(key, stacksfile, open_modified, workdir): 65 """ 66 Report QM failures to Bugzilla based on stack analysis. 67 """ 68 run = utils.getLastRunFromExecutionFile(workdir) 69 70 # Check for valid execution file from the previous run 71 if not {"errorfile", "warnfile"} <= run.keys(): 72 error("No analyzable execution from the previous run of analyze found.") 73 echo("Did you remember to run `poetry run qm-try-analysis analyze`?") 74 sys.exit(2) 75 76 # Handle missing stacksfile 77 if not stacksfile: 78 if "stacksfile" not in run: 79 error( 80 "The previous analyze run did not contain the location of the stacksfile." 81 ) 82 echo('Please provide the file location using the "--stacksfile" option.') 83 sys.exit(2) 84 stacksfile = open(run["stacksfile"], "rb") 85 86 # Create Bugzilla client 87 bugzilla_client = bugzilla.Bugzilla(url=BUGZILLA_API_URL, api_key=key) 88 89 # Initialize report data 90 report = run.get("report", {}) 91 run["report"] = report 92 attachment_id = report.get("stacksfile_attachment", None) 93 reported = report.get("reported", []) 94 report["reported"] = reported 95 96 def post_comment(bug_id, comment): 97 """ 98 Post a comment to a Bugzilla bug. 99 """ 100 data = {"id": bug_id, "comment": comment, "is_markdown": True} 101 res = bugzilla_client._post(f"bug/{bug_id}/comment", json.dumps(data)) 102 return res["id"] 103 104 # Handle missing attachment ID 105 if not attachment_id: 106 attachment = bugzilla.DotDict() 107 attachment.file_name = f"qmstacks_until_{run['lasteventtime']}.txt" 108 attachment.summary = attachment.file_name 109 attachment.content_type = "text/plain" 110 attachment.data = stacksfile.read().decode() 111 res = bugzilla_client.post_attachment(QM_TRY_FAILURES_BUG, attachment) 112 attachment_id = next(iter(res["attachments"].values()))["id"] 113 report["stacksfile_attachment"] = attachment_id 114 utils.updateLastRunToExecutionFile(workdir, run) 115 116 info( 117 f'Created attachment for "{attachment.file_name}": {BUGZILLA_ATTACHMENT_URL + str(attachment_id)}.' 118 ) 119 120 def generate_comment(stacks): 121 """ 122 Generate a comment for Bugzilla based on error stacks. 123 """ 124 comment = f"Taken from Attachment {attachment_id}\n\n" 125 comment += stackanalysis.printStacks(stacks) 126 return comment 127 128 # Handle missing warnings comment 129 if "warnings_comment" not in report: 130 warning_stacks = utils.readJSONFile(run["warnfile"]) 131 warning_stacks = filter(lambda stack: stack["hit_count"] >= 100, warning_stacks) 132 133 comment = generate_comment(warning_stacks) 134 comment_id = post_comment(WARNING_STACKS_BUG, comment) 135 136 report["warnings_comment"] = comment_id 137 utils.updateLastRunToExecutionFile(workdir, run) 138 139 info("Created comment for warning stacks.") 140 141 error_stacks = utils.readJSONFile(run["errorfile"]) 142 143 def reduce(search_results, by: str) -> Union[int, None]: 144 """ 145 Reduce bug search results automatically or based on user input. 146 """ 147 anchor = by 148 149 search_results = remove_duplicates(search_results, bugzilla_client) 150 151 if not search_results: 152 return 153 154 if len(search_results) == 1: 155 return search_results[0]["id"] 156 157 echo(f'Multiple bugs found for anchor "{anchor}":') 158 159 for i, result in enumerate(search_results, start=1): 160 echo( 161 f"{i}.{' [closed]' if result['resolution'] != '' else ''} {BUGZILLA_BUG_URL + str(result['id'])} - {result['summary']}" 162 ) 163 164 choice = click.prompt( 165 "Enter the number of the bug you want to use", 166 type=click.Choice( 167 [str(i) for i in range(1, len(search_results) + 1)] + ["skip"] 168 ), 169 default="skip", 170 show_default=True, 171 confirmation_prompt="Please confirm the selected choice", 172 ) 173 174 if choice == "skip": 175 return 176 177 return search_results[int(choice) - 1]["id"] 178 179 anchors = stackanalysis.groupStacksForAnchors(error_stacks) 180 181 for anchor in anchors: 182 if hash_str(anchor) in reported: 183 info(f'Skipping anchor "{anchor}" since it has already been reported.') 184 continue 185 186 if not (match := ANCHOR_REGEX_PATTERN.match(anchor)): 187 warning(f'"{anchor}" did not match the regex pattern.') 188 189 if "Unknown" in match.group(2): 190 warning(f'Skipping "{anchor}" since it is not a valid anchor.') 191 continue 192 193 search_string = " ".join(filter(None, match.groups())) 194 search_results = bugzilla_client.search_bugs([ 195 {"product": "Core", "summary": search_string} 196 ])["bugs"] 197 198 if bug_id := reduce(search_results, by=anchor): 199 info(f'Found bug {BUGZILLA_BUG_URL + str(bug_id)} for anchor "{anchor}".') 200 else: 201 warning(f'No bug found for anchor "{anchor}".') 202 203 if not click.confirm("Would you like to create one?"): 204 continue 205 206 bug = bugzilla.DotDict() 207 bug.product = "Core" 208 bug.component = "Storage: Quota Manager" 209 bug.summary = f"[QM_TRY] Failures in {anchor}" 210 bug.description = f"This bug keeps track of the semi-automatic monitoring of QM_TRY failures in `{anchor}`" 211 bug["type"] = "defect" 212 bug.blocks = QM_TRY_FAILURES_BUG 213 bug.version = "unspecified" 214 215 bug_id = bugzilla_client.post_bug(bug)["id"] 216 217 info(f'Created bug {BUGZILLA_BUG_URL + str(bug_id)} for anchor "{anchor}".') 218 219 comment = generate_comment(anchors[anchor]["stacks"]) 220 # This can happen if we hit a function like `RemoveNsIFileRecursively` 221 if len(comment) >= 2**16: 222 error(f'Skipping "{anchor}" since it exceeds bugzillas comment limit.') 223 continue 224 # This returns the incorrect id for Bugzilla Prod. 225 # See: https://bugzilla.mozilla.org/show_bug.cgi?id=1877201 226 comment_id = post_comment(bug_id, comment) 227 228 reported.append(hash_str(anchor)) 229 utils.updateLastRunToExecutionFile(workdir, run) 230 231 if open_modified: 232 # DOES NOT WORK, see above comment regarding the comment id 233 # 234 # res = bugzilla_client.get_comment(comment_id) 235 # comment_seq_number = res["comments"][ 236 # str(comment_id) 237 # ]["count"] 238 # webbrowser.open( 239 # BUGZILLA_BUG_URL + str(bug_id) + "#c" + str(comment_seq_number) 240 # ) 241 242 # Workaround 243 webbrowser.open( 244 BUGZILLA_BUG_URL 245 + str(bug_id) 246 + f"#:~:text=Attachment%20{attachment_id}" 247 ) 248 249 250 def hash_str(s): 251 """ 252 Hash a string using MD5. 253 """ 254 encoded_str = s.encode("utf-8") 255 return int(hashlib.md5(encoded_str).hexdigest(), 16) 256 257 258 def remove_duplicates(search_results, bugzilla_client): 259 """ 260 Remove duplicate bugs in search results. 261 """ 262 resolved_bugs = set(bug["id"] for bug in search_results if not bug.get("dupe_of")) 263 264 def resolve_if_dupe(bug): 265 if not (dupe_of := bug.get("dupe_of")): 266 return bug 267 268 if dupe_of in resolved_bugs: 269 return None 270 271 remote = resolve_if_dupe(bugzilla_client.get_bug(dupe_of)) 272 if remote: 273 resolved_bugs.add(remote["id"]) 274 275 return remote 276 277 return [non_dupe for bug in search_results if (non_dupe := resolve_if_dupe(bug))] 278 279 280 if __name__ == "__main__": 281 report_qm_failures()