sync/notify/msg.py (206 lines of code) (raw):

from __future__ import annotations from collections import defaultdict import urllib.parse from ..bug import bug_number_from_url, max_comment_length from ..env import Environment from .results import statuses, browsers from typing import Iterable, Mapping, TYPE_CHECKING if TYPE_CHECKING: from sync.notify.results import Result, Results, SubtestResult, TestResult from sync.notify.results import ResultsEntry env = Environment() def status_str(result: Result | SubtestResult | TestResult, browser: str = "firefox", include_status: str = "head", include_other_browser: bool = False, ) -> str | None: """Construct a string containing the statuses for a results. :param result: The Result object for which to construct the string. :param browser: The primary browser for which to construct the result :param include_status: Either "head" to just include the updated status or "both" to include both the head and base statuses. :param include_other_browser: Boolean indicating whether to include results from non-primary browsers. """ targets = {"head": ["head"], "both": ["base", "head"]}[include_status] if all(result.is_consistent(browser, target) for target in targets): value = "->".join(f"`{getattr(next(iter(result.statuses[browser].values())), target)}`" for target in targets) else: by_value = defaultdict(list) results = result.statuses[browser] for job_name, status in results.items(): key = tuple(getattr(status, target) for target in targets) by_value[key].append(job_name) value = ", ".join("{} [{}]".format("->".join(f"`{status}`" for status in statuses), ", ".join("`%s`" % item for item in sorted(job_names))) for statuses, job_names in sorted(by_value.items())) if include_other_browser: other_browser_values = [] for other_browser, job_results in sorted(result.statuses.items()): if other_browser == browser: continue browser_status = job_results.get("GitHub") if not browser_status: return None other_browser_values.append("{}: {}".format(other_browser.title(), "->".join( f"`{getattr(browser_status, target)}`" for target in targets))) if other_browser_values: value += " (%s)" % ", ".join(other_browser_values) return value def summary_value(result_data: Mapping[str, int]) -> str: by_result = defaultdict(list) for job_name, value in result_data.items(): by_result[value].append(job_name) if len(by_result) == 1: return str(next(iter(by_result.keys()))) return " ".join("{}[{}]".format(count, ", ".join(sorted(jobs))) for count, jobs in sorted(by_result.items())) def bug_str(url: str) -> str: """Create a bug string for a given bug url""" if url.startswith(env.bz.bz_url): return "Bug %s" % bug_number_from_url(url) elif url.startswith("https://github.com"): return "[Issue {}]({})".format(urllib.parse.urlsplit(url).path.split("/")[-1], url) return "[%s]()" % url def list_join(items_iter: Iterable) -> str: """Join a list of strings using commands, with "and" before the final item.""" items = list(items_iter) if len(items) == 0: return "" if len(items) == 1: return items[0] rv = ", ".join(items[:-1]) rv += ", and %s" % items[-1] return rv def summary_message(results: Results) -> str: """Generate a summary message for results indicating how many tests ran""" summary = results.summary() job_names = {browser: results.job_names(browser) for browser in browsers} github_browsers = list_join(browser.title() for browser in browsers if "GitHub" in job_names[browser]) gecko_configs = len([item for item in job_names["firefox"] if item != "GitHub"]) data = [f"Ran {gecko_configs} Firefox configurations based on mozilla-central"] if github_browsers: data[-1] += f", and {github_browsers} on GitHub CI" data.append("") data.append("Total %s tests" % summary.parent_tests) subtests = summary.subtests if subtests: data[-1] += " and %s subtests" % subtests data[-1] += "\n" result_statuses = [status for status in statuses if status in summary.job_results] if not result_statuses: return "\n".join(data) data.append("## Status Summary\n") max_width = len(max(result_statuses, key=len)) for browser in browsers: if not job_names[browser]: continue data.append("### %s" % browser.title()) for result in ["OK", "PASS", "CRASH", "FAIL", "PRECONDITION_FAILED", "TIMEOUT", "ERROR", "NOTRUN"]: if browser in summary.job_results[result]: result_data = summary.job_results[result][browser] data.append("{}: {}".format(f"`{result}`".ljust(max_width + 2), summary_value(result_data))) data.append("") return "\n".join(data) def links_message(results: Results) -> str: """Generate a list of relevant links for the results""" data = [] if results.treeherder_url is not None: data.append("[Gecko CI (Treeherder)](%s)" % results.treeherder_url) if results.wpt_sha is not None: data.append("[GitHub PR Head](https://wpt.fyi/results/?sha=%s&label=pr_head)" % results.wpt_sha) data.append("[GitHub PR Base](https://wpt.fyi/results/?sha=%s&label=pr_base)" % results.wpt_sha) if data: data.insert(0, "## Links") data.append("") return "\n".join(data) def detail_message(results: Results) -> list[str]: """Generate a message for results highlighting specific noteworthy test outcomes""" data = [] for (details_type, iterator, include_bugs, include_status, include_other_browser) in [ ("Crashes", results.iter_crashes("firefox"), ("bugzilla",), "head", False), ("Firefox-only Failures", results.iter_browser_only("firefox"), ("bugzilla", "github"), "head", False), ("Tests With a Worse Result After Changes", results.iter_regressions("firefox"), (), "both", True), ("New Tests That Don't Pass", results.iter_new_non_passing("firefox"), (), "head", True), ("Tests Disabled in Gecko Infrastructure", results.iter_disabled(), ("bugzilla", "github"), "head", True)]: part = detail_part(details_type, iterator, include_bugs, include_status, include_other_browser) if part: data.append(part) if data: data.insert(0, "## Details\n") return data def detail_part(details_type: str | None, iterator: Iterable[ResultsEntry], include_bugs: tuple[str, ...] | None, include_status: str, include_other_browser: bool, ) -> str | None: """Generate a message for a specific class of notable results. :param details_type: The name of the results class :param iterator: An iterator over all results that belong to the class :param include bugs: A list of bug systems' whose bug links should go in the status string (currently can be "bugzilla" and/or "github" :param include_status: "head" or "both" indicating whether only the status with changes or both the statuses before and after changes should be included :param include_other_browser: A boolean indicating whether to include statuses from non-Firefox browsers :returns: A text string containing the message """ bug_prefixes = {"bugzilla": env.bz.bz_url, "github": "https://github.com/"} item_data = [] results = list(iterator) if not results: return None if details_type: item_data.append("### %s" % details_type) prev_test = None for test, subtest, result in results: msg_line = "" if prev_test != test: msg_line = (f"* [{test}](https://wpt.live{test}) " + f"[[wpt.fyi](https://wpt.fyi/results{test})]") prev_test = test status = status_str(result, include_status=include_status, include_other_browser=include_other_browser) if not subtest: msg_line += ": %s" % status else: if msg_line: msg_line += "\n" msg_line += f" * {subtest}: {status}" if include_bugs: prefixes = [bug_prefixes[item] for item in include_bugs] bug_links = [bug_link for bug_link in result.bug_links if any(bug_link.url.startswith(prefix) for prefix in prefixes)] if bug_links: msg_line += " linked bug{}:{}".format("s" if len(bug_links) > 1 else "", ", ". join(bug_str(link.url) for link in bug_links)) item_data.append(msg_line) return "\n".join(item_data) + "\n" def for_results(results: Results) -> tuple[str, str | None]: """Generate a notification message for results :param results: a Results object :returns: A string message, and truncated_message which is None if the message all fits in a bugzilla comment, or the length-truncated version if it doesn't.""" msg_parts = ["# CI Results\n", summary_message(results), links_message(results)] msg_parts += detail_message(results) msg_parts = [item for item in msg_parts if item] truncated, message = truncate_message(msg_parts) if truncated: truncated_message: str | None = message message = "\n".join(msg_parts) else: truncated_message = None return message, truncated_message def truncate_message(parts: Iterable[str]) -> tuple[bool, str]: """Take an iterator of message parts and return a string consisting of all the parts starting from the first that will fit into a bugzilla comment, seperated by new lines. :param parts: Iterator returning strings consisting of message parts :returns: a boolean indicating whether the message was truncated, and the truncated message. """ suffix = "(See attachment for full changes)" message = "" truncated = False padding = len(suffix) + 1 for part in parts: if len(message) + len(part) + 1 > max_comment_length - padding: truncated = True part = suffix if message: message += "\n" message += part if truncated: break return truncated, message