tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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()