diff --git a/README.rst b/README.rst index 6e8ccded5..df1f4b0e8 100644 --- a/README.rst +++ b/README.rst @@ -59,11 +59,14 @@ Before running: "iam_client_secret": "xxxxxxxxxxxxxx", "iam_client_id": "xxxxxxxxxxxxxx", "socorro_token": "xxxxxxxxxxxxxx", - "hackbot_api_key": "xxxxxxxxxxxxxx" + "hackbot_api_key": "xxxxxxxxxxxxxx", + "slack_bot_token": "xoxb-xxxxxxxxxxxxxx" } The ``hackbot_api_key`` is only needed by rules that start a `hackbot `_ agent run (currently ``frontend_triage``). Those rules talk to ``https://hackbot-api.moz.tools`` by default; set ``HACKBOT_API_URL`` to point at a different deployment. +The ``slack_bot_token`` is only needed by rules that post a message to Slack (currently ``reo_regression_slack`` and ``reo_regression_slack_daily``). It is a Slack app's Bot User OAuth Token and needs the ``chat:write``, ``chat:write.customize`` and ``chat:write.public`` scopes. + Do a dryrun:: uv run -m bugbot.rules.stalled diff --git a/bugbot/rules/reo_regression_slack.py b/bugbot/rules/reo_regression_slack.py new file mode 100644 index 000000000..56ea3ced0 --- /dev/null +++ b/bugbot/rules/reo_regression_slack.py @@ -0,0 +1,574 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +"""Post the REO release regression cycle summary to Slack. + +For Release, Beta and Nightly it reports two bug lists that together partition +every open regression affecting the version: "new regressions", affected in N +while N-1 is unaffected or unknown, and "carry over regressions", the same query +negated. Each count is broken down by severity, New Regressions also by owning +team, and Beta and Nightly get a working day countdown to the end of the cycle. + +The regressions that need chasing rather than counting are the other rule, +`reo_regression_slack_daily`. +""" + +import argparse +import datetime +import functools +import re +from collections.abc import Collection +from typing import Any + +import requests +from libmozdata import utils as lmdutils +from libmozdata.fx_trains import FirefoxTrains + +from bugbot import logger, slack, utils +from bugbot.bzcleaner import Bug, BzCleaner, BzParams, EmailData +from bugbot.components import ComponentName, fetch_component_teams + +# Also matches the pre-S1 severity names, so these counts can run slightly ahead +# of the REO tab's. +from bugbot.constants import HIGH_SEVERITY + +# TEMPORARY: #tmp-dm-test, a scratch channel for shaking the port out. It has to +# be pointed at the real REO channel before this message is meant to be read. +CHANNEL = "C0BLP0WUBED" + +BZ_BUGLIST_URL = "https://bugzilla.mozilla.org/buglist.cgi" + +RELEASE_PAGE_URL = "https://whattrainisitnow.com/release/?version={}" + +WELLNESS_API_URL = "https://whattrainisitnow.com/api/wellness/days/" + +HTTP_TIMEOUT_SECONDS = 15 + +# Every classification except Graveyard, as bugdash's REO queries have it. +CLASSIFICATIONS = [ + "Client Software", + "Components", + "Developer Infrastructure", + "Other", + "Server Software", +] + +# Bugzilla reports a bug with no triage decision as "--". N/A is a decision, and +# comes back from the API capitalised even though a query matches it as "n/a". +MISSING_SEVERITIES = ("--",) + +# Dropped from every query. The Developer Infrastructure classification stays in +# scope; only the product of the same name goes. +EXCLUDED_PRODUCTS = ("Testing", "Developer Infrastructure") + +# Above every slot the query uses, including the 11 `with_severities` takes. +EXCLUDED_PRODUCTS_SLOT = 12 + +UNKNOWN_TEAM = "Unknown team" + +# A Slack section block holds at most 3000 characters. +SECTION_LIMIT = 3000 + +# Above this length a snapshot URL is shortened; see `bug_link`. +MAX_SNAPSHOT_URL = 2000 + +# Slack has no nested lists in message text, so indent sub-bullets by hand. +SUB_BULLET = " ◦ " + +# `groups` is how a bug is known to be restricted. No `summary`: the message +# names no bug, so restricted ones are counted and linked but never named. +BUG_FIELDS = "id,severity,product,component,groups" + +# Stands in for a milestone key, as the last beta is numbered differently from +# one version to the next (beta_10 for 154, beta_5 under the 2 week cadence). +LAST_BETA = "last_beta" + +# The milestone ending each channel's cycle, and the cycle's name. Release has no +# equivalent deadline, so it gets no countdown. +CYCLE_ENDS = { + "beta": ("Beta", LAST_BETA), + "nightly": ("Nightly", "merge_day"), +} + +# Custom emoji in the Mozilla workspace. A name that doesn't exist there renders +# as the literal :name: rather than failing. +CHANNEL_EMOJI = { + "release": ":firefox-browser:", + "beta": ":beta-browser:", + "nightly": ":nightly-browser:", +} + +HEADING = "REO release regression status:" + +# So a silent channel reads as good news rather than as the script having failed. +NOTHING_TO_REPORT = "• No open release regressions" + + +def utc_today() -> datetime.date: + """Today in UTC: milestone dates are UTC and the cron host may not be.""" + return lmdutils.get_date_ymd("today").date() + + +def without_excluded_products(slot: int = EXCLUDED_PRODUCTS_SLOT) -> dict: + """Chart conditions dropping EXCLUDED_PRODUCTS, a numbered slot per product. + + One notequals per product rather than a nowords, which Bugzilla would split on + whitespace and match "Developer Infrastructure" as two words. + """ + conditions: dict = {} + for offset, product in enumerate(EXCLUDED_PRODUCTS): + number = slot + offset + conditions |= { + f"f{number}": "product", + f"o{number}": "notequals", + f"v{number}": product, + } + + return conditions + + +def regressions_query(version: int, carry_over: bool | None = None) -> dict: + """Build the open regressions query for a version. + + Bugs with all of the following: + - regression keyword + - open (unresolved) + - status-firefox{version} is affected + Bugs with any of the following are ignored: + - tracking-firefox{version} is - + - stalled or intermittent-failure keywords + - within one of EXCLUDED_PRODUCTS + + carry_over splits that set in two: False keeps the bugs where + status-firefox{version - 1} is unaffected, ? or ---, so they regressed during + this cycle, and True negates it. None asks for the whole set. + + The gaps in the chart numbering come from bugdash and are harmless, as + Bugzilla ignores unused numbers. + """ + query = { + "classification": CLASSIFICATIONS, + "keywords": "regression", + "keywords_type": "allwords", + "resolution": "---", + "f1": utils.get_flag(version, "status", "release"), + "o1": "equals", + "v1": "affected", + "f8": utils.get_flag(version, "tracking", "release"), + "o8": "notequals", + "v8": "-", + "f10": "keywords", + "o10": "nowordssubstr", + "v10": "stalled,intermittent-failure", + **without_excluded_products(), + } + + if carry_over is None: + return query + + previous = utils.get_flag(version - 1, "status", "release") + query |= { + "f2": "OP", + "j2": "OR", + "f3": previous, + "o3": "equals", + "v3": "unaffected", + "f4": previous, + "o4": "equals", + "v4": "?", + "f5": previous, + "o5": "equals", + "v5": "---", + "f6": "CP", + } + + if carry_over: + # n2 attaches to the OP at f2, so it negates the whole f3-f5 group. + query["n2"] = "1" + + return query + + +def with_severities(query: dict, severities: Collection[str]) -> dict: + """Narrow a query to some severities, for a link that stays live. + + Sorted so the same severities always produce the same URL, as `HIGH_SEVERITY` + is a set. + """ + return { + **query, + "f11": "bug_severity", + "o11": "anyexact", + "v11": ", ".join(sorted(severities)), + } + + +def snapshot_url(bugs: list[dict]) -> str: + """A Bugzilla URL listing exactly these bugs, so it still matches the count. + + Built by hand rather than through `utils.get_bz_search_url` so the separators + stay as commas: percent-encoded they would triple the length + `MAX_SNAPSHOT_URL` measures. + """ + ids = ",".join(str(bug["id"]) for bug in bugs) + + return f"{BZ_BUGLIST_URL}?bug_id={ids}&order=bug_list" + + +def shortened_url(url: str) -> str | None: + """A short Bugzilla URL for a long one, or None if it couldn't be shortened. + + `utils.shorten_long_bz_url` answers an error with the URL split across lines + (bugbot#1402), which a Slack link would end at the first newline, so that + counts as a failure too. + """ + try: + short = utils.shorten_long_bz_url(url) + except Exception: + logger.exception("Could not shorten a Bugzilla URL") + return None + + if "\n" in short or len(short) > MAX_SNAPSHOT_URL: + return None + + return short + + +def bug_link( + bugs: list[dict], label_template: str, fallback_query: dict | None = None +) -> str: + """Format a non-empty bug list as a Slack link labelled with its count. + + An over-long snapshot URL is shortened, then falls back to fallback_query, + which is live and so can drift from the count, then to no link at all. Team + lines pass no fallback, as reproducing a team as a query means listing all its + components. + """ + label = label_template.format(len(bugs)) + snapshot = snapshot_url(bugs) + + if len(snapshot) <= MAX_SNAPSHOT_URL: + return f"<{snapshot}|{label}>" + + url = shortened_url(snapshot) + if url is None and fallback_query is not None: + url = utils.get_bz_search_url(fallback_query) + + if url is None: + return label + + return f"<{url}|{label}>" + + +@functools.cache +def component_teams() -> dict[ComponentName, str]: + """Map every (product, component) to the team that owns it, in one request.""" + return fetch_component_teams() + + +def team_of(bug: dict) -> str: + """The team owning a bug's component.""" + return component_teams().get(ComponentName.from_bug(bug)) or UNKNOWN_TEAM + + +def team_breakdown(bugs: list[dict]) -> str: + """Count the bugs owned by each team, busiest team first.""" + by_team: dict[str, list[dict]] = {} + for bug in bugs: + by_team.setdefault(team_of(bug), []).append(bug) + + ranked = sorted(by_team.items(), key=lambda item: (-len(item[1]), item[0])) + + return ", ".join(bug_link(team_bugs, f"{{}} {team}") for team, team_bugs in ranked) + + +def restricted_note(bugs: list[dict]) -> str: + """Say how many of a bug list are restricted, or nothing when none are. + + Any group counts, not only a security one: this explains why the linked list + looks shorter than the count to a reader without access. + """ + count = sum(1 for bug in bugs if bug.get("groups")) + if not count: + return "" + + return f" ({count} restricted)" + + +def to_blocks(sections: list[str]) -> list[dict]: + """Wrap the sections of a message as Block Kit sections. + + Slack silently splits a message past about 4000 characters, where each section + block gets its own allowance. An overflowing section raises rather than + posting something malformed. + """ + for section in sections: + if len(section) > SECTION_LIMIT: + raise RuntimeError( + f"Slack section block is {len(section)} characters, over the " + f"{SECTION_LIMIT} limit:\n{section[:200]}..." + ) + + return [ + {"type": "section", "text": {"type": "mrkdwn", "text": section}} + for section in sections + ] + + +def block_text(block: dict) -> str: + """The text of any block, for printing a message instead of posting it.""" + if "elements" in block: + return " ".join(element["text"] for element in block["elements"]) + + return block["text"]["text"] + + +def versions_to_report() -> dict[str, int]: + """The current version of each channel, logging what was read. + + Not through `BzCleaner.init_versions`: `utils.get_checked_versions` returns + nothing on merge day, which is a day this message has wording for. + """ + versions = utils.get_versions_from_trains() + logger.info( + "Reporting Firefox %s release / %s beta / %s nightly", + versions["release"], + versions["beta"], + versions["nightly"], + ) + + return versions + + +@functools.cache +def wellness_days() -> frozenset[datetime.date]: + """Fetch the days off that don't count as working days. + + libmozdata's `FirefoxTrains` doesn't cover this endpoint. + """ + response = requests.get( + WELLNESS_API_URL, + headers={"User-Agent": "bugbot"}, + timeout=HTTP_TIMEOUT_SECONDS, + ) + response.raise_for_status() + + return frozenset(datetime.date.fromisoformat(day) for day in response.json()) + + +def work_days_until(end: datetime.date) -> int: + """Count working days between today and end, end excluded. + + Mirrors ReleaseInsights\\Duration::workDays(), so this agrees with the + countdowns on the release pages. + """ + today = utc_today() + days = (end - today).days + if days <= 0: + return 0 + + # Counting from tomorrow is what leaves the current day out. + return sum( + 1 + for offset in range(1, days) + if (day := today + datetime.timedelta(days=offset)).weekday() < 5 # Mon-Fri + and day not in wellness_days() + ) + + +def release_schedule(version: int) -> dict: + """A version's milestone dates from the trains API, cached by libmozdata.""" + return FirefoxTrains.get_instance().get_release_schedule(str(version)) + + +def milestone_date(schedule: dict, milestone: str) -> datetime.date: + """The date of a milestone, resolving LAST_BETA to the highest numbered beta. + + Sorting on the number matters: as strings, beta_9 would come after beta_10. + """ + if milestone == LAST_BETA: + betas = [key for key in schedule if re.fullmatch(r"beta_\d+", key)] + milestone = max(betas, key=lambda key: int(key.removeprefix("beta_"))) + + return lmdutils.get_date_ymd(schedule[milestone]).date() + + +def cycle_countdown(version: int, channel: str) -> str: + """A countdown to the end of this version's time on the channel. + + The deadline and the days after it read badly as a countdown ("in 0 working + days"), so they get their own wording. + """ + if channel not in CYCLE_ENDS: + return "" + + cycle, milestone = CYCLE_ENDS[channel] + label = f"End of {cycle}" + end = milestone_date(release_schedule(version), milestone) + today = utc_today() + + if end < today: + return f"{cycle} cycle finished" + + if end == today: + return f"{label} today" + + if end == today + datetime.timedelta(days=1): + return f"{label} {end:%Y-%m-%d} — tomorrow" + + days = work_days_until(end) + return f'{label} {end:%Y-%m-%d} in {days} {utils.plural("working day", days)}' + + +class ReoRegressionSlack(BzCleaner): + """A `BzCleaner` that reports to Slack instead of by email. + + The days it runs on are `must_run` in `configs/rules.json`. + """ + + # Overridden by a `--channel` run, so this is the channel the cron posts to. + channel = CHANNEL + + def description(self) -> str: + return "REO release regression cycle summary posted to Slack" + + def all_include_fields(self) -> bool: + # `BzCleaner` would otherwise add `summary` to every query. + return True + + def has_default_products(self) -> bool: + # Scoped by classification instead, as bugdash's REO queries are. + return False + + def filter_no_nag_keyword(self) -> bool: + # A [no-nag] bug is still one the cycle is carrying, and dropping those + # would put the counts out of step with the REO tab. + return False + + def add_custom_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--channel", + action="store", + default="", + help=( + f"Slack channel ID to post to, overriding {CHANNEL}. Useful to " + "shake the message out somewhere else without editing the code." + ), + ) + + def parse_custom_arguments(self, args: argparse.Namespace) -> None: + self.channel = args.channel or CHANNEL + + def get_bz_params(self, date: str) -> BzParams: + """The query the running `get_bugs()` call is for. See `fetch_bugs`.""" + return self.params + + def bughandler(self, bug: Bug, data: dict[str, Any]) -> None: + """Keep every field, where `BzCleaner` would keep the email columns.""" + data[str(bug["id"])] = bug + + def fetch_bugs(self, query: dict, fields: str = BUG_FIELDS) -> list[dict]: + """Run one of this rule's queries through `BzCleaner`'s search path. + + Two queries per channel, each set here and read back by `get_bz_params`, + the way `warn_regressed_by` steps through its two. libmozdata only pages a + query carrying none of count_only, limit, order or offset, so no query + here may add one. + """ + self.params = {**query, "include_fields": fields} + + return list(self.get_bugs().values()) + + def regression_group( + self, version: int, carry_over: bool, label: str, by_team: bool = False + ) -> str: + """Build the bullet and severity sub-bullets for one bug list. + + Split locally rather than queried per subset, so the sub-bullets are + guaranteed to be part of the count above them. Empty lists are left out of + the message entirely. + """ + query = regressions_query(version, carry_over) + bugs = self.fetch_bugs(query) + if not bugs: + return "" + + link = bug_link(bugs, f"{{}} {label} Regressions", query) + lines = [f"• {link}{restricted_note(bugs)}"] + + if by_team: + lines.append(SUB_BULLET + team_breakdown(bugs)) + + severity_counts = [] + for severities, template in ( + (HIGH_SEVERITY, "{} S2+"), + (MISSING_SEVERITIES, "{} missing severity"), + ): + subset = [bug for bug in bugs if bug["severity"] in severities] + if subset: + severity_counts.append( + bug_link(subset, template, with_severities(query, severities)) + ) + + if severity_counts: + lines.append(SUB_BULLET + ", ".join(severity_counts)) + + return "\n".join(lines) + + def post_message(self, blocks: list[dict]) -> None: + """Post the message to Slack, or print it on a dry or test run.""" + if self.dryrun or self.test_mode: + print("DRY RUN: message not posted.\n") + for block in blocks: + print(block_text(block)) + return + + # HEADING is the notification fallback text, which is what a client that + # cannot render blocks shows instead of them. + slack.post_to_slack(self.channel, HEADING, blocks=blocks) + logger.info("Rule %s posted to %s", self.name(), self.channel) + + def get_email_data(self, date: str) -> EmailData: + """Post the message, and return no data so `send_email` sends nothing.""" + self.post_message(self.blocks(versions_to_report())) + + return [] + + def blocks(self, versions: dict[str, int]) -> list[dict]: + """Build the cycle summary as Block Kit sections, one per bug list.""" + sections = [HEADING] + + for channel in ("release", "beta", "nightly"): + version = versions[channel] + page = RELEASE_PAGE_URL.format(version) + emoji = CHANNEL_EMOJI[channel] + header = f"{emoji} *<{page}|Fx{version} {channel.title()}>*" + + countdown = cycle_countdown(version, channel) + if countdown: + header += f"\n{countdown}" + + groups = [ + group + for group in ( + self.regression_group(version, False, "New", by_team=True), + self.regression_group(version, True, "Carry Over"), + ) + if group + ] + + if not groups: + sections.append(f"{header}\n{NOTHING_TO_REPORT}") + continue + + # The header rides along with the first surviving group, so that a + # channel with only carry over bugs isn't left with a stray heading. + sections.append(f"{header}\n{groups[0]}") + sections.extend(groups[1:]) + + return to_blocks(sections) + + +if __name__ == "__main__": + ReoRegressionSlack().run() diff --git a/bugbot/rules/reo_regression_slack_daily.py b/bugbot/rules/reo_regression_slack_daily.py new file mode 100644 index 000000000..fd1e8465f --- /dev/null +++ b/bugbot/rules/reo_regression_slack_daily.py @@ -0,0 +1,584 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +"""Post the REO release regressions that need action to Slack. + +The same bug set as the cycle summary, `reo_regression_slack`, with the three +channels merged into one deduplicated list and only the bugs stuck long enough +to need a nudge: high severity with nobody on them, no severity decision, or an +unanswered needinfo. It ends with bugdash's Burndown list per version, Beta and +Release only, cut down to the fixes nobody has asked to uplift. Each line is +broken down by owning team. + +Posted every weekday, unlike the summary: these are things somebody has to do, +so a day skipped is a day nobody was asked. +""" + +import argparse +import datetime +import functools +from typing import Any + +from libmozdata import utils as lmdutils + +from bugbot import logger, slack, utils +from bugbot.bzcleaner import Bug, BzCleaner, BzParams, EmailData +from bugbot.components import ComponentName, fetch_component_teams + +# Also matches the pre-S1 severity names, so these counts can run slightly ahead +# of the REO tab's. +from bugbot.constants import HIGH_SEVERITY + +# TEMPORARY: #tmp-dm-test, a scratch channel for shaking the port out. It has to +# be pointed at the real REO channel before this message is meant to be read. +CHANNEL = "C0BLP0WUBED" + +BZ_BUGLIST_URL = "https://bugzilla.mozilla.org/buglist.cgi" + +# Every classification except Graveyard, as bugdash's REO queries have it. +CLASSIFICATIONS = [ + "Client Software", + "Components", + "Developer Infrastructure", + "Other", + "Server Software", +] + +# Bugzilla reports a bug with no triage decision as "--". N/A is a decision, and +# comes back from the API capitalised even though a query matches it as "n/a". +MISSING_SEVERITIES = ("--",) + +# Dropped from every query. The Developer Infrastructure classification stays in +# scope; only the product of the same name goes. +EXCLUDED_PRODUCTS = ("Testing", "Developer Infrastructure") + +# Above every slot either query uses. +EXCLUDED_PRODUCTS_SLOT = 12 + +UNKNOWN_TEAM = "Unknown team" + +# A Slack section block holds at most 3000 characters. +SECTION_LIMIT = 3000 + +# Above this length a snapshot URL is shortened; see `bug_link`. +MAX_SNAPSHOT_URL = 2000 + +# Slack renders this back as >. The bare character ends a link's label and opens +# a blockquote at the start of a line. +GREATER_THAN = ">" + +# Slack has no nested lists in message text, so indent sub-bullets by hand. +SUB_BULLET = " ◦ " + +# `groups` is how a bug is known to be restricted. No `summary`: the message +# names no bug, so restricted ones are counted and linked but never named. +BUG_FIELDS = "id,severity,product,component,groups" + +# What each query adds on top: whatever its buckets age a bug from. +FIELDS = f"{BUG_FIELDS},assigned_to,creation_time,last_change_time,flags" +BURNDOWN_FIELDS = f"{BUG_FIELDS},cf_last_resolved" + +# Left out of the "S2+ unassigned" bucket alone. Empty today, as the one +# exemption we have belongs to a component rather than to a whole product. +UNASSIGNED_EXEMPT_PRODUCTS: tuple[str, ...] = () + +# Web Compatibility::Site Reports bugs S2 definition does not follow the +# regression severity definition. The exemption is the component's, not the +# product's, and applies to this one bucket only. +UNASSIGNED_EXEMPT_COMPONENTS = ("Site Reports",) + +# Bugs are aged from a fixed point in the past rather than over a window, so one +# that went stale on Friday is still in Monday's message. +STUCK_HOURS = 24 + +# The channels a fix only reaches by being uplifted, in the order the burndown +# lines appear. Nightly is where fixes land, so it needs no line. +UPLIFT_CHANNELS = ("beta", "release") + +# Also the message's fallback text, which is what notification previews show. +HEADING = "Action needed: REO release regressions" + +CADENCE = "Daily update" + +INTRO = ( + "These release regressions are waiting on activity and fall into the urgent " + "category. " + f"They have been pending for longer than {STUCK_HOURS} hours. " + "Please take a look where one of your teams is listed." +) + +# So a quiet day reads as good news rather than as the script having failed. +NOTHING_STUCK = "• Nothing needs attention" + + +def without_excluded_products(slot: int = EXCLUDED_PRODUCTS_SLOT) -> dict: + """Chart conditions dropping EXCLUDED_PRODUCTS, a numbered slot per product. + + One notequals per product rather than a nowords, which Bugzilla would split on + whitespace and match "Developer Infrastructure" as two words. + """ + conditions: dict = {} + for offset, product in enumerate(EXCLUDED_PRODUCTS): + number = slot + offset + conditions |= { + f"f{number}": "product", + f"o{number}": "notequals", + f"v{number}": product, + } + + return conditions + + +def regressions_query(version: int) -> dict: + """Build the open regressions query for a version. + + Bugs with all of the following: + - regression keyword + - open (unresolved) + - status-firefox{version} is affected + Bugs with any of the following are ignored: + - tracking-firefox{version} is - + - stalled or intermittent-failure keywords + - within one of EXCLUDED_PRODUCTS + + The cycle summary splits this set into the bugs that regressed during the + cycle and the ones that were already there; this message wants the whole set. + + The gaps in the chart numbering come from bugdash and from that split, and + are harmless: Bugzilla ignores unused numbers. + """ + return { + "classification": CLASSIFICATIONS, + "keywords": "regression", + "keywords_type": "allwords", + "resolution": "---", + "f1": utils.get_flag(version, "status", "release"), + "o1": "equals", + "v1": "affected", + "f8": utils.get_flag(version, "tracking", "release"), + "o8": "notequals", + "v8": "-", + "f10": "keywords", + "o10": "nowordssubstr", + "v10": "stalled,intermittent-failure", + **without_excluded_products(), + } + + +def burndown_query(version: int, uplift_flag: str) -> dict: + """Build the burndown query for a version, less the bugs already asking to uplift. + + Bugs with all of the following: + - resolved as fixed + - status-firefox{version} is affected or fix-optional + - any of: + - crash, regression, leak, topcrash, assertion or dataloss keywords + - in a security group + - tracking-firefox{version} is +, ? or blocking + Bugs with any of the following are ignored: + - within one of EXCLUDED_PRODUCTS + - an uplift request against the channel, in any state + + All but the last of those is bugdash's Burndown list, kept in step with + app/buglists/burndown.mjs there, its gaps in the chart numbering included. + + The uplift request is a flag on an attachment, so it is left to Bugzilla + rather than filtered here: flagtypes.name matches attachment flags too, and + matching the bare name catches the request in any state. n11 negates that. + """ + return { + "classification": CLASSIFICATIONS, + "resolution": "FIXED", + "f1": utils.get_flag(version, "status", "release"), + "o1": "anywords", + "v1": "affected optional", + "j2": "OR", + "f2": "OP", + "f3": "keywords", + "o3": "anywords", + "v3": "crash regression leak topcrash assertion dataloss", + "f4": "bug_group", + "o4": "substring", + "v4": "sec", + "f6": utils.get_flag(version, "tracking", "release"), + "o6": "anywordssubstr", + "v6": "+ ? blocking", + "f7": "CP", + "f11": "flagtypes.name", + "o11": "substring", + "v11": uplift_flag, + "n11": "1", + **without_excluded_products(), + } + + +def snapshot_url(bugs: list[dict]) -> str: + """A Bugzilla URL listing exactly these bugs, so it still matches the count. + + Built by hand rather than through `utils.get_bz_search_url` so the separators + stay as commas: percent-encoded they would triple the length + `MAX_SNAPSHOT_URL` measures. + """ + ids = ",".join(str(bug["id"]) for bug in bugs) + + return f"{BZ_BUGLIST_URL}?bug_id={ids}&order=bug_list" + + +def shortened_url(url: str) -> str | None: + """A short Bugzilla URL for a long one, or None if it couldn't be shortened. + + `utils.shorten_long_bz_url` answers an error with the URL split across lines + (bugbot#1402), which a Slack link would end at the first newline, so that + counts as a failure too. + """ + try: + short = utils.shorten_long_bz_url(url) + except Exception: + logger.exception("Could not shorten a Bugzilla URL") + return None + + if "\n" in short or len(short) > MAX_SNAPSHOT_URL: + return None + + return short + + +def bug_link(bugs: list[dict], label_template: str) -> str: + """Format a non-empty bug list as a Slack link labelled with its count. + + An over-long snapshot URL is shortened, and failing that the count is left + unlinked. No line here has a live query to fall back on, as the ageing is done + in this rule rather than by Bugzilla. + """ + label = label_template.format(len(bugs)) + snapshot = snapshot_url(bugs) + + if len(snapshot) <= MAX_SNAPSHOT_URL: + return f"<{snapshot}|{label}>" + + url = shortened_url(snapshot) + if url is None: + return label + + return f"<{url}|{label}>" + + +@functools.cache +def component_teams() -> dict[ComponentName, str]: + """Map every (product, component) to the team that owns it, in one request.""" + return fetch_component_teams() + + +def team_of(bug: dict) -> str: + """The team owning a bug's component.""" + return component_teams().get(ComponentName.from_bug(bug)) or UNKNOWN_TEAM + + +def team_breakdown(bugs: list[dict]) -> str: + """Count the bugs owned by each team, busiest team first.""" + by_team: dict[str, list[dict]] = {} + for bug in bugs: + by_team.setdefault(team_of(bug), []).append(bug) + + ranked = sorted(by_team.items(), key=lambda item: (-len(item[1]), item[0])) + + return ", ".join(bug_link(team_bugs, f"{{}} {team}") for team, team_bugs in ranked) + + +def restricted_note(bugs: list[dict]) -> str: + """Say how many of a bug list are restricted, or nothing when none are. + + Any group counts, not only a security one: this explains why the linked list + looks shorter than the count to a reader without access. That is a wider test + than the `bug_group ~ "sec"` branch in `burndown_query`, which asks whether a + fix is worth chasing rather than whether the bug is readable. + """ + count = sum(1 for bug in bugs if bug.get("groups")) + if not count: + return "" + + return f" ({count} restricted)" + + +def to_blocks(sections: list[str]) -> list[dict]: + """Wrap the sections of a message as Block Kit sections. + + Slack silently splits a message past about 4000 characters, where each section + block gets its own allowance. An overflowing section raises rather than + posting something malformed. + """ + for section in sections: + if len(section) > SECTION_LIMIT: + raise RuntimeError( + f"Slack section block is {len(section)} characters, over the " + f"{SECTION_LIMIT} limit:\n{section[:200]}..." + ) + + return [ + {"type": "section", "text": {"type": "mrkdwn", "text": section}} + for section in sections + ] + + +def block_text(block: dict) -> str: + """The text of any block, for printing a message instead of posting it.""" + if "elements" in block: + return " ".join(element["text"] for element in block["elements"]) + + return block["text"]["text"] + + +def versions_to_report() -> dict[str, int]: + """The current version of each channel, logging what was read. + + Not through `BzCleaner.init_versions`: `utils.get_checked_versions` returns + nothing on merge day, which is a day this message has wording for. + """ + versions = utils.get_versions_from_trains() + logger.info( + "Reporting Firefox %s release / %s beta / %s nightly", + versions["release"], + versions["beta"], + versions["nightly"], + ) + + return versions + + +def stuck_since() -> datetime.datetime: + """The moment a bug has to predate to count as stuck. See STUCK_HOURS.""" + now = datetime.datetime.now(datetime.timezone.utc) + + return now - datetime.timedelta(hours=STUCK_HOURS) + + +def unassigned_exempt(bug: dict) -> bool: + """Whether a bug is exempt from the S2+ unassigned bucket.""" + return ( + bug["product"] in UNASSIGNED_EXEMPT_PRODUCTS + or bug["component"] in UNASSIGNED_EXEMPT_COMPONENTS + ) + + +def needs_assignee(bug: dict, cutoff: datetime.datetime) -> bool: + """A high severity bug nobody has taken on, aged from when it was filed. + + `utils.is_no_assignee` is wider than the REO queries take it, which count a + bug parked on a component default address as assigned, so this can flag a bug + bugdash would not. + """ + return ( + bug["severity"] in HIGH_SEVERITY + and not unassigned_exempt(bug) + and utils.is_no_assignee(bug["assigned_to"]) + and lmdutils.get_date_ymd(bug["creation_time"]) < cutoff + ) + + +def needs_severity(bug: dict, cutoff: datetime.datetime) -> bool: + """A bug still waiting on a severity decision, aged from its last activity. + + Any change counts as activity, not just a triage one, so a bug someone is + asking questions on is left out until it goes quiet again. + """ + return ( + bug["severity"] in MISSING_SEVERITIES + and lmdutils.get_date_ymd(bug["last_change_time"]) < cutoff + ) + + +def needs_answer(bug: dict, cutoff: datetime.datetime) -> bool: + """A bug with a needinfo nobody has answered, aged from when it was asked. + + Several open requests still only count the bug once. Aged from the flag's + creation_date, which is when the request now standing was made, so one asked + again after an answer is aged from the second ask. + """ + return any( + lmdutils.get_date_ymd(flag["creation_date"]) < cutoff + for flag in utils.get_needinfo(bug) + ) + + +# What makes a bug belong in a bucket, the label its count goes in, and what its +# age is counted from. A bug can be in more than one. Each names its own anchor +# because each is aged from a different timestamp. +STUCK_BUCKETS = ( + (needs_assignee, "{} S2+ unassigned", "filed"), + (needs_severity, "{} missing severity", "last change"), + (needs_answer, "{} needinfo pending", "requested"), +) + + +def stuck_group(bugs: list[dict], label: str, anchor: str) -> str: + """Build the bullet and team sub-bullet for one bucket. + + Only the count and what it counts are linked; the restricted note and the age + that follow are plain text. Empty buckets return an empty string and are left + out of the message. + """ + if not bugs: + return "" + + age = f", {GREATER_THAN} {STUCK_HOURS} hours since {anchor}" + + return ( + f"• {bug_link(bugs, label)}{restricted_note(bugs)}{age}\n" + f"{SUB_BULLET}{team_breakdown(bugs)}" + ) + + +class ReoRegressionSlackDaily(BzCleaner): + """A `BzCleaner` that reports to Slack instead of by email. + + No `must_run` in `configs/rules.json`: this runs every day the cron invokes it. + """ + + # Overridden by a `--channel` run, so this is the channel the cron posts to. + channel = CHANNEL + + def description(self) -> str: + return "REO release regressions needing action posted to Slack" + + def all_include_fields(self) -> bool: + # `BzCleaner` would otherwise add `summary` to every query. + return True + + def has_default_products(self) -> bool: + # Scoped by classification instead, as bugdash's queries are. + return False + + def filter_no_nag_keyword(self) -> bool: + # [no-nag] is a request not to mail a bug's assignee. This message names + # teams rather than people and is read by the release managers chasing + # the work, so dropping those bugs would hide work still to be done. + return False + + def add_custom_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--channel", + action="store", + default="", + help=( + f"Slack channel ID to post to, overriding {CHANNEL}. Useful to " + "shake the message out somewhere else without editing the code." + ), + ) + + def parse_custom_arguments(self, args: argparse.Namespace) -> None: + self.channel = args.channel or CHANNEL + + def get_bz_params(self, date: str) -> BzParams: + """The query the running `get_bugs()` call is for. See `fetch_bugs`.""" + return self.params + + def bughandler(self, bug: Bug, data: dict[str, Any]) -> None: + """Keep every field, where `BzCleaner` would keep the email columns.""" + data[str(bug["id"])] = bug + + def fetch_bugs(self, query: dict, fields: str = FIELDS) -> list[dict]: + """Run one of this rule's queries through `BzCleaner`'s search path. + + One query per version plus one per burndown line, each set here and read + back by `get_bz_params`, the way `warn_regressed_by` steps through its + two. libmozdata only pages a query carrying none of count_only, limit, + order or offset, so no query here may add one. + """ + self.params = {**query, "include_fields": fields} + + return list(self.get_bugs().values()) + + def open_regressions(self, versions: dict[str, int]) -> list[dict]: + """Every open release regression across the channels, each bug listed once. + + The three queries overlap heavily. Where they disagree the last one wins, + but the fields the buckets look at are all channel independent. + """ + bugs: dict[int, dict] = {} + for version in sorted(set(versions.values())): + for bug in self.fetch_bugs(regressions_query(version)): + bugs[bug["id"]] = bug + + return list(bugs.values()) + + def burndown_group( + self, channel: str, version: int, cutoff: datetime.datetime + ) -> str: + """Build the burndown bullet for one channel, aged from when each bug was fixed. + + Per version rather than merged across the channels: a fix reaches Beta and + Release by separate uplifts, so the same bug can be outstanding on one and + done on the other. + """ + query = burndown_query(version, utils.get_flag(None, "approval", channel)) + bugs = [ + bug + for bug in self.fetch_bugs(query, BURNDOWN_FIELDS) + if lmdutils.get_date_ymd(bug["cf_last_resolved"]) < cutoff + ] + label = f"{{}} Fx{version} {channel.title()} fixed with no uplift request" + + return stuck_group(bugs, label, "resolved") + + def post_message(self, blocks: list[dict]) -> None: + """Post the message to Slack, or print it on a dry or test run.""" + if self.dryrun or self.test_mode: + print("DRY RUN: message not posted.\n") + for block in blocks: + print(block_text(block)) + return + + # HEADING is the notification fallback text, which is what a client that + # cannot render blocks shows instead of them. + slack.post_to_slack(self.channel, HEADING, blocks=blocks) + logger.info("Rule %s posted to %s", self.name(), self.channel) + + def get_email_data(self, date: str) -> EmailData: + """Post the message, and return no data so `send_email` sends nothing.""" + self.post_message(self.blocks(versions_to_report())) + + return [] + + def blocks(self, versions: dict[str, int]) -> list[dict]: + """Build the action required message, one section per bucket. + + Header blocks take plain text only, which is why the cadence is a separate + context block. + """ + titles: list[dict] = [ + {"type": "header", "text": {"type": "plain_text", "text": HEADING}}, + {"type": "context", "elements": [{"type": "mrkdwn", "text": CADENCE}]}, + ] + sections = [INTRO] + + cutoff = stuck_since() + bugs = self.open_regressions(versions) + + groups = [ + group + for matches, label, anchor in STUCK_BUCKETS + if ( + group := stuck_group( + [bug for bug in bugs if matches(bug, cutoff)], label, anchor + ) + ) + ] + for channel in UPLIFT_CHANNELS: + version = versions.get(channel) + if version is None: + # Skipped rather than raised, so adding a channel above can never + # be the thing that costs the whole message. + logger.warning("No version for %s; skipping its burndown line", channel) + continue + + if group := self.burndown_group(channel, version, cutoff): + groups.append(group) + + sections.extend(groups or [NOTHING_STUCK]) + + return titles + to_blocks(sections) + + +if __name__ == "__main__": + ReoRegressionSlackDaily().run() diff --git a/bugbot/slack.py b/bugbot/slack.py new file mode 100644 index 000000000..6e0bd41ff --- /dev/null +++ b/bugbot/slack.py @@ -0,0 +1,121 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +"""Post messages to Slack. + +One bot for the whole of bugbot: the token and the name it appears under live +here, and a caller supplies only the message and where to send it. + +`SLACK_API_URL`, `SLACK_ACCESS_TOKEN` and the error wording follow taskcluster's +notify service (services/notify). `SLACK_API_URL` points at a test server, which +is the only way to exercise this without a real token. +""" + +import json +import os + +import requests + +from bugbot import utils + +TIMEOUT_SECONDS = 15 + +DEFAULT_API_URL = "https://slack.com/api/" +API_URL_VAR = "SLACK_API_URL" +TOKEN_VAR = "SLACK_ACCESS_TOKEN" + +# Read with `.get` rather than validated at load time the way `bz_api_key` is: a +# deployment that posts to no channel needs no token. +TOKEN_KEY = "slack_bot_token" + +# The name every message is posted under, instead of whatever the Slack app +# happens to be called. Needs `chat:write.customize` on the token, and Slack +# rejects the message outright when that scope is missing. +USERNAME = "Firefox Release Management Bot" + + +def get_token() -> str: + """The bot token to post with, `SLACK_ACCESS_TOKEN` winning over the config. + + A missing config file counts as a missing key, so a checkout with no + credentials still imports. Raises rather than returning empty: these are cron + jobs whose whole purpose is the message. + """ + token = os.environ.get(TOKEN_VAR, "").strip() + if token: + return token + + try: + token = utils.get_login_info().get(TOKEN_KEY, "") + except OSError: + token = "" + + if not token: + raise RuntimeError( + f"Posting to Slack needs a bot token with the chat:write scope " + f"(chat:write.public to post without being invited), from {TOKEN_VAR} " + f"or {TOKEN_KEY} in configs/config.json" + ) + + return token + + +def post_to_slack( + channel: str, + text: str, + blocks: list[dict] | None = None, + thread_ts: str | None = None, +) -> str: + """Post a message to a Slack channel, and return its timestamp. + + `channel` is a channel ID, the last section of a channel's 'copy link' URL. + `text` is the notification and the fallback for clients that can't render + blocks. `thread_ts` takes the timestamp this returns for an earlier message. + + Not retried, unlike reads: a POST that times out may well have arrived, so + retrying risks posting the message twice. + """ + payload: dict = { + "channel": channel, + "text": text, + "username": USERNAME, + # These messages are built around their links, and an unfurl below one + # repeats what the message already says. + "unfurl_links": False, + "unfurl_media": False, + } + if blocks is not None: + payload["blocks"] = blocks + if thread_ts is not None: + payload["thread_ts"] = thread_ts + + api_url = (os.environ.get(API_URL_VAR) or DEFAULT_API_URL).rstrip("/") + + # Encoded here rather than passed as `json=` so the charset can be spelled + # out: Slack answers a bare application/json with a missing_charset warning. + response = requests.post( + f"{api_url}/chat.postMessage", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"Bearer {get_token()}", + }, + timeout=TIMEOUT_SECONDS, + ) + if not response.ok: + raise RuntimeError( + f"Slack returned HTTP {response.status_code}: {response.text.strip()}" + ) + + # chat.postMessage reports application errors as HTTP 200 with ok=false. + result = response.json() + if not result.get("ok"): + reason = result.get("error", result) + # On missing_scope Slack names the scope it wanted and the ones the token + # carries. Without those two the error is very hard to act on. + if result.get("needed"): + reason += f" (needed {result['needed']}, token has {result['provided']})" + raise RuntimeError(f"error posting slack message: {reason}") + + return result["ts"] diff --git a/configs/rules.json b/configs/rules.json index 630e561dd..1878b9e57 100644 --- a/configs/rules.json +++ b/configs/rules.json @@ -490,5 +490,8 @@ }, "topcrash_notify": { "additional_receivers": ["rm"] + }, + "reo_regression_slack": { + "must_run": ["Mon", "Thu"] } } diff --git a/scripts/cron_run_weekdays.sh b/scripts/cron_run_weekdays.sh index 2d21fc6e9..5b6677755 100755 --- a/scripts/cron_run_weekdays.sh +++ b/scripts/cron_run_weekdays.sh @@ -196,4 +196,11 @@ python -m bugbot.rules.severity_high_performance_impact --production # Request potential missing info when a bug is moved to Core::Performance python -m bugbot.rules.moved_to_performance --production +# Post the REO release regression cycle summary to Slack +# Gated to Mon and Thu by its must_run in configs/rules.json +python -m bugbot.rules.reo_regression_slack --production + +# Post the REO release regressions needing action to Slack +python -m bugbot.rules.reo_regression_slack_daily --production + source ./scripts/cron_common_end.sh