diff --git a/backend/data/notifications/new_issues/default.yaml b/backend/data/notifications/new_issues/default.yaml new file mode 100644 index 000000000..66c83cbe0 --- /dev/null +++ b/backend/data/notifications/new_issues/default.yaml @@ -0,0 +1,4 @@ +# Replaces --to on the new_issues cron. `issues` lists the kinds included +# in the community mail. +to: kernelci-results@groups.io +issues: [build, boot] diff --git a/backend/data/notifications/regression/amlogic.yaml b/backend/data/notifications/regression/amlogic.yaml new file mode 100644 index 000000000..98a431726 --- /dev/null +++ b/backend/data/notifications/regression/amlogic.yaml @@ -0,0 +1,3 @@ +amlogic: + - recipients: + - linux-amlogic@lists.infradead.org diff --git a/backend/data/notifications/regression/android.yaml b/backend/data/notifications/regression/android.yaml new file mode 100644 index 000000000..2a98324c3 --- /dev/null +++ b/backend/data/notifications/regression/android.yaml @@ -0,0 +1,5 @@ +android: + - recipients: + - tkjos@google.com + - joneslee@google.com + - ynaffit@google.com diff --git a/backend/data/notifications/regression/ardb.yaml b/backend/data/notifications/regression/ardb.yaml new file mode 100644 index 000000000..f5dba6788 --- /dev/null +++ b/backend/data/notifications/regression/ardb.yaml @@ -0,0 +1,3 @@ +ardb: + - recipients: + - ardb@kernel.org diff --git a/backend/data/notifications/regression/arm64.yaml b/backend/data/notifications/regression/arm64.yaml new file mode 100644 index 000000000..2ea60e48b --- /dev/null +++ b/backend/data/notifications/regression/arm64.yaml @@ -0,0 +1,4 @@ +arm64: + - recipients: + - will@kernel.org + - linux-arm-kernel@lists.infradead.org diff --git a/backend/data/notifications/regression/arnd.yaml b/backend/data/notifications/regression/arnd.yaml new file mode 100644 index 000000000..0600e5c88 --- /dev/null +++ b/backend/data/notifications/regression/arnd.yaml @@ -0,0 +1,3 @@ +arnd: + - recipients: + - arnd@arndb.de diff --git a/backend/data/notifications/regression/broonie-misc.yaml b/backend/data/notifications/regression/broonie-misc.yaml new file mode 100644 index 000000000..bd1a1b329 --- /dev/null +++ b/backend/data/notifications/regression/broonie-misc.yaml @@ -0,0 +1,3 @@ +broonie-misc: + - recipients: + - broonie@kernel.org diff --git a/backend/data/notifications/regression/broonie-regmap.yaml b/backend/data/notifications/regression/broonie-regmap.yaml new file mode 100644 index 000000000..a38a55c10 --- /dev/null +++ b/backend/data/notifications/regression/broonie-regmap.yaml @@ -0,0 +1,3 @@ +broonie-regmap: + - recipients: + - broonie@kernel.org diff --git a/backend/data/notifications/regression/broonie-regulator.yaml b/backend/data/notifications/regression/broonie-regulator.yaml new file mode 100644 index 000000000..3a750e60e --- /dev/null +++ b/backend/data/notifications/regression/broonie-regulator.yaml @@ -0,0 +1,3 @@ +broonie-regulator: + - recipients: + - broonie@kernel.org diff --git a/backend/data/notifications/regression/broonie-sound.yaml b/backend/data/notifications/regression/broonie-sound.yaml new file mode 100644 index 000000000..ab8746408 --- /dev/null +++ b/backend/data/notifications/regression/broonie-sound.yaml @@ -0,0 +1,3 @@ +broonie-sound: + - recipients: + - broonie@kernel.org diff --git a/backend/data/notifications/regression/broonie-spi.yaml b/backend/data/notifications/regression/broonie-spi.yaml new file mode 100644 index 000000000..53afb88d7 --- /dev/null +++ b/backend/data/notifications/regression/broonie-spi.yaml @@ -0,0 +1,3 @@ +broonie-spi: + - recipients: + - broonie@kernel.org diff --git a/backend/data/notifications/regression/chrome-platform.yaml b/backend/data/notifications/regression/chrome-platform.yaml new file mode 100644 index 000000000..1de785578 --- /dev/null +++ b/backend/data/notifications/regression/chrome-platform.yaml @@ -0,0 +1,4 @@ +chrome-platform: + - recipients: + - chrome-platform@lists.linux.dev + - tzungbi@kernel.org diff --git a/backend/data/notifications/regression/chromiumos.yaml b/backend/data/notifications/regression/chromiumos.yaml new file mode 100644 index 000000000..8c7b99fa7 --- /dev/null +++ b/backend/data/notifications/regression/chromiumos.yaml @@ -0,0 +1,6 @@ +chromiumos: + - recipients: + - laura.nao@collabora.com + - vignesh.raman@collabora.com + - arnaud.ferraris@collabora.com + - mhiramat@google.com diff --git a/backend/data/notifications/regression/cip.yaml b/backend/data/notifications/regression/cip.yaml new file mode 100644 index 000000000..e3d143493 --- /dev/null +++ b/backend/data/notifications/regression/cip.yaml @@ -0,0 +1,3 @@ +cip: + - recipients: + - cip-dev@lists.cip-project.org diff --git a/backend/data/notifications/regression/clk.yaml b/backend/data/notifications/regression/clk.yaml new file mode 100644 index 000000000..d780b6203 --- /dev/null +++ b/backend/data/notifications/regression/clk.yaml @@ -0,0 +1,4 @@ +clk: + - recipients: + - linux-clk@vger.kernel.org + - sboyd@kernel.org diff --git a/backend/data/notifications/regression/default.yaml b/backend/data/notifications/regression/default.yaml new file mode 100644 index 000000000..5aa5589de --- /dev/null +++ b/backend/data/notifications/regression/default.yaml @@ -0,0 +1,3 @@ +# Replaces KERNELCI_RESULTS on the regression path only. The [STATUS] cron +# keeps --add-mailing-lists and the constants in common.py. +to: kernelci-results@groups.io diff --git a/backend/data/notifications/regression/efi.yaml b/backend/data/notifications/regression/efi.yaml new file mode 100644 index 000000000..be4258440 --- /dev/null +++ b/backend/data/notifications/regression/efi.yaml @@ -0,0 +1,3 @@ +efi: + - recipients: + - ardb@kernel.org diff --git a/backend/data/notifications/regression/hyperv.yaml b/backend/data/notifications/regression/hyperv.yaml new file mode 100644 index 000000000..b26b890e1 --- /dev/null +++ b/backend/data/notifications/regression/hyperv.yaml @@ -0,0 +1,3 @@ +hyperv: + - recipients: + - kernelcialerts@microsoft.com diff --git a/backend/data/notifications/regression/khilman.yaml b/backend/data/notifications/regression/khilman.yaml new file mode 100644 index 000000000..c16e388ad --- /dev/null +++ b/backend/data/notifications/regression/khilman.yaml @@ -0,0 +1,3 @@ +khilman: + - recipients: + - khilman@kernel.org diff --git a/backend/data/notifications/regression/krzysztof.yaml b/backend/data/notifications/regression/krzysztof.yaml new file mode 100644 index 000000000..72b915522 --- /dev/null +++ b/backend/data/notifications/regression/krzysztof.yaml @@ -0,0 +1,3 @@ +krzysztof: + - recipients: + - krzk@kernel.org diff --git a/backend/data/notifications/regression/kselftest.yaml b/backend/data/notifications/regression/kselftest.yaml new file mode 100644 index 000000000..77ef30063 --- /dev/null +++ b/backend/data/notifications/regression/kselftest.yaml @@ -0,0 +1,3 @@ +kselftest: + - recipients: + - shuah@kernel.org diff --git a/backend/data/notifications/regression/lee-backlight.yaml b/backend/data/notifications/regression/lee-backlight.yaml new file mode 100644 index 000000000..ad3ca083e --- /dev/null +++ b/backend/data/notifications/regression/lee-backlight.yaml @@ -0,0 +1,3 @@ +lee-backlight: + - recipients: + - lee@kernel.org diff --git a/backend/data/notifications/regression/lee-mfd.yaml b/backend/data/notifications/regression/lee-mfd.yaml new file mode 100644 index 000000000..bc16c7740 --- /dev/null +++ b/backend/data/notifications/regression/lee-mfd.yaml @@ -0,0 +1,3 @@ +lee-mfd: + - recipients: + - lee@kernel.org diff --git a/backend/data/notifications/regression/linux-pci.yaml b/backend/data/notifications/regression/linux-pci.yaml new file mode 100644 index 000000000..224fc1736 --- /dev/null +++ b/backend/data/notifications/regression/linux-pci.yaml @@ -0,0 +1,3 @@ +linux-pci: + - recipients: + - linux-pci@vger.kernel.org diff --git a/backend/data/notifications/regression/mainline.yaml b/backend/data/notifications/regression/mainline.yaml new file mode 100644 index 000000000..9c8c847b8 --- /dev/null +++ b/backend/data/notifications/regression/mainline.yaml @@ -0,0 +1,3 @@ +mainline: + - recipients: + - broonie@kernel.org diff --git a/backend/data/notifications/regression/media-committers.yaml b/backend/data/notifications/regression/media-committers.yaml new file mode 100644 index 000000000..9f0c444ee --- /dev/null +++ b/backend/data/notifications/regression/media-committers.yaml @@ -0,0 +1,3 @@ +media-committers: + - recipients: + - linux-media@vger.kernel.org diff --git a/backend/data/notifications/regression/media.yaml b/backend/data/notifications/regression/media.yaml new file mode 100644 index 000000000..8083c67fb --- /dev/null +++ b/backend/data/notifications/regression/media.yaml @@ -0,0 +1,3 @@ +media: + - recipients: + - media@linuxtv.org diff --git a/backend/data/notifications/regression/mediatek.yaml b/backend/data/notifications/regression/mediatek.yaml new file mode 100644 index 000000000..2ed60f764 --- /dev/null +++ b/backend/data/notifications/regression/mediatek.yaml @@ -0,0 +1,3 @@ +mediatek: + - recipients: + - angelogioacchino.delregno@collabora.com diff --git a/backend/data/notifications/regression/next.yaml b/backend/data/notifications/regression/next.yaml new file mode 100644 index 000000000..22da5aaaa --- /dev/null +++ b/backend/data/notifications/regression/next.yaml @@ -0,0 +1,3 @@ +next: + - recipients: + - linux-next@vger.kernel.org diff --git a/backend/data/notifications/regression/omap.yaml b/backend/data/notifications/regression/omap.yaml new file mode 100644 index 000000000..b87149991 --- /dev/null +++ b/backend/data/notifications/regression/omap.yaml @@ -0,0 +1,3 @@ +omap: + - recipients: + - khilman@baylibre.com diff --git a/backend/data/notifications/regression/padovan.yaml b/backend/data/notifications/regression/padovan.yaml new file mode 100644 index 000000000..8040da8cf --- /dev/null +++ b/backend/data/notifications/regression/padovan.yaml @@ -0,0 +1,3 @@ +padovan: + - recipients: + - padovan@kernel.org diff --git a/backend/data/notifications/regression/pm.yaml b/backend/data/notifications/regression/pm.yaml new file mode 100644 index 000000000..daee7420d --- /dev/null +++ b/backend/data/notifications/regression/pm.yaml @@ -0,0 +1,3 @@ +pm: + - recipients: + - rafael@kernel.org diff --git a/backend/data/notifications/regression/qcom.yaml b/backend/data/notifications/regression/qcom.yaml new file mode 100644 index 000000000..ac1ac6bb2 --- /dev/null +++ b/backend/data/notifications/regression/qcom.yaml @@ -0,0 +1,4 @@ +qcom: + - recipients: + - linux-arm-msm@vger.kernel.org + - Bjorn Andersson diff --git a/backend/data/notifications/regression/renesas.yaml b/backend/data/notifications/regression/renesas.yaml new file mode 100644 index 000000000..1b4cfe737 --- /dev/null +++ b/backend/data/notifications/regression/renesas.yaml @@ -0,0 +1,3 @@ +renesas: + - recipients: + - linux-renesas-soc@vger.kernel.org diff --git a/backend/data/notifications/regression/riscv.yaml b/backend/data/notifications/regression/riscv.yaml new file mode 100644 index 000000000..abd5cca2f --- /dev/null +++ b/backend/data/notifications/regression/riscv.yaml @@ -0,0 +1,3 @@ +riscv: + - recipients: + - linux-riscv@lists.infradead.org diff --git a/backend/data/notifications/regression/robh.yaml b/backend/data/notifications/regression/robh.yaml new file mode 100644 index 000000000..78d1a62c3 --- /dev/null +++ b/backend/data/notifications/regression/robh.yaml @@ -0,0 +1,4 @@ +robh: + - recipients: + - robh@kernel.org + - devicetree@vger.kernel.org diff --git a/backend/data/notifications/regression/rppt.yaml b/backend/data/notifications/regression/rppt.yaml new file mode 100644 index 000000000..f1bf22147 --- /dev/null +++ b/backend/data/notifications/regression/rppt.yaml @@ -0,0 +1,4 @@ +rppt: + - recipients: + - rppt@kernel.org + - linux-mm@kvack.org diff --git a/backend/data/notifications/regression/sashal-next.yaml b/backend/data/notifications/regression/sashal-next.yaml new file mode 100644 index 000000000..de6404b9c --- /dev/null +++ b/backend/data/notifications/regression/sashal-next.yaml @@ -0,0 +1,3 @@ +sashal-next: + - recipients: + - sashal@kernel.org diff --git a/backend/data/notifications/regression/soc.yaml b/backend/data/notifications/regression/soc.yaml new file mode 100644 index 000000000..e56a6e89d --- /dev/null +++ b/backend/data/notifications/regression/soc.yaml @@ -0,0 +1,3 @@ +soc: + - recipients: + - soc@lists.linux.dev diff --git a/backend/data/notifications/regression/stable-rc.yaml b/backend/data/notifications/regression/stable-rc.yaml new file mode 100644 index 000000000..682817cc0 --- /dev/null +++ b/backend/data/notifications/regression/stable-rc.yaml @@ -0,0 +1,3 @@ +stable-rc: + - recipients: + - stable@vger.kernel.org diff --git a/backend/data/notifications/regression/stable-rt.yaml b/backend/data/notifications/regression/stable-rt.yaml new file mode 100644 index 000000000..e2c938e42 --- /dev/null +++ b/backend/data/notifications/regression/stable-rt.yaml @@ -0,0 +1,3 @@ +stable-rt: + - recipients: + - linux-rt-devel@lists.linux.dev diff --git a/backend/data/notifications/regression/stable.yaml b/backend/data/notifications/regression/stable.yaml new file mode 100644 index 000000000..141e97a73 --- /dev/null +++ b/backend/data/notifications/regression/stable.yaml @@ -0,0 +1,3 @@ +stable: + - recipients: + - stable@vger.kernel.org diff --git a/backend/data/notifications/regression/tegra.yaml b/backend/data/notifications/regression/tegra.yaml new file mode 100644 index 000000000..1c996c70c --- /dev/null +++ b/backend/data/notifications/regression/tegra.yaml @@ -0,0 +1,3 @@ +tegra: + - recipients: + - linux-tegra@vger.kernel.org diff --git a/backend/data/notifications/regression/thermal.yaml b/backend/data/notifications/regression/thermal.yaml new file mode 100644 index 000000000..1afac3b55 --- /dev/null +++ b/backend/data/notifications/regression/thermal.yaml @@ -0,0 +1,3 @@ +thermal: + - recipients: + - linux-pm@vger.kernel.org diff --git a/backend/data/notifications/regression/tip.yaml b/backend/data/notifications/regression/tip.yaml new file mode 100644 index 000000000..ee0bd413e --- /dev/null +++ b/backend/data/notifications/regression/tip.yaml @@ -0,0 +1,3 @@ +tip: + - recipients: + - tip@kernel.org diff --git a/backend/data/notifications/regression/ulfh.yaml b/backend/data/notifications/regression/ulfh.yaml new file mode 100644 index 000000000..2d234142e --- /dev/null +++ b/backend/data/notifications/regression/ulfh.yaml @@ -0,0 +1,3 @@ +ulfh: + - recipients: + - linux-mmc@vger.kernel.org diff --git a/backend/data/notifications/regression/vireshk.yaml b/backend/data/notifications/regression/vireshk.yaml new file mode 100644 index 000000000..fc6646621 --- /dev/null +++ b/backend/data/notifications/regression/vireshk.yaml @@ -0,0 +1,3 @@ +vireshk: + - recipients: + - vireshk@kernel.org diff --git a/backend/kernelCI/settings.py b/backend/kernelCI/settings.py index ed19ef72e..c85960a6f 100644 --- a/backend/kernelCI/settings.py +++ b/backend/kernelCI/settings.py @@ -178,7 +178,6 @@ def get_json_env_var(name, default): "notifications", "--monitoring-id=notifications_new_issues", "--action=new_issues", - "--to=kernelci-results@groups.io", "--send", "--yes", ], diff --git a/backend/kernelCI_app/management/commands/helpers/common.py b/backend/kernelCI_app/management/commands/helpers/common.py index 4cea995fc..930460f1a 100644 --- a/backend/kernelCI_app/management/commands/helpers/common.py +++ b/backend/kernelCI_app/management/commands/helpers/common.py @@ -1,13 +1,17 @@ import os +from collections.abc import Iterable +from email.utils import parseaddr from typing import Optional from django.conf import settings from jinja2 import Environment, FileSystemLoader, Template from kernelCI_app.helpers.email import smtp_send_email -from kernelCI_app.helpers.logger import log_message -from kernelCI_app.management.commands.helpers.summary import SIGNUP_FOLDER -from kernelCI_app.utils import read_yaml_file +from kernelCI_app.typeModels.notificationRecipients import ( + RecipientEntry, + load_recipient_default, + load_recipient_files, +) KERNELCI_RESULTS = "kernelci-results@groups.io" KERNELCI_REPLYTO = "kernelci@lists.linux.dev" @@ -15,6 +19,11 @@ REGZBOT_TREES = {"mainline", "next", "stable"} +REGRESSION_FOLDER = "notifications/regression/" +BUILD_INCIDENT_KEYS = {"architecture", "config_name", "compiler"} +REGRESSION_KEYS = {"issues", "branch", *BUILD_INCIDENT_KEYS} +REGRESSION_ISSUES = {"build"} + def is_regzbot_tree(tree_name) -> bool: """Trees whose regression reports are tracked by regzbot. @@ -38,50 +47,69 @@ def setup_jinja_template(template_name: str) -> Template: return jinja_env.get_template(template_name) -def _get_default_tree_recipients( +def _regression_directory() -> str: + return os.path.join(settings.BACKEND_DATA_DIR, REGRESSION_FOLDER) + + +def load_regression_files( + directory: Optional[str] = None, +) -> dict[str, list[RecipientEntry]]: + return load_recipient_files( + directory or _regression_directory(), + allowed_keys=REGRESSION_KEYS, + allowed_issues=REGRESSION_ISSUES, + ) + + +def _union_recipients(entries: Iterable[RecipientEntry]) -> list[str]: + """Recipients of every entry in order, dropping repeated addresses + even when they are written with different display names.""" + seen: dict[str, str] = {} + for entry in entries: + for recipient in entry.recipients: + seen.setdefault(parseaddr(recipient)[1].lower() or recipient, recipient) + return list(seen.values()) + + +def _entry_matches(entry: RecipientEntry, issue: dict, build_incidents) -> bool: + issue_kind = "build" if issue.get("build_id") else "test" + if entry.issues and issue_kind not in entry.issues: + return False + if entry.branch and issue.get("git_repository_branch") not in entry.branch: + return False + return all( + any(incident.get(key) in values for incident in build_incidents) + for key, values in entry.scope.items() + if key in BUILD_INCIDENT_KEYS + ) + + +def resolve_regression_recipients( + issue: dict, + build_incidents: list[dict], *, - base_dir: Optional[str] = None, - signup_folder: Optional[str] = None, - search_url: Optional[str] = None, + entries_by_url: Optional[dict[str, list[RecipientEntry]]] = None, ) -> list[str]: - """ - Searches in every subscription file to find the matching git_url from a tree, - and returns the default recipients of that tree - - Params: - base_dir: the base path for the signup folder; - signup_folder: the relative path of the folder where all the subscription files are; - search_url: the url of the tree that is being searched; + """Cc for a [REGRESSION] report: every regression/ entry on the issue's + tree url whose scope matches the issue and its build incidents.""" + if entries_by_url is None: + entries_by_url = load_regression_files() + entries = entries_by_url.get(issue.get("git_repository_url"), []) + return _union_recipients( + entry for entry in entries if _entry_matches(entry, issue, build_incidents) + ) - Returns: - A list of str with recipient emails. - The list will be empty if there was an error or the tree was not found. - """ - if not search_url: - return [] - - if not base_dir: - base_dir = settings.BACKEND_DATA_DIR - if not signup_folder: - signup_folder = SIGNUP_FOLDER - - full_path = os.path.join(base_dir, signup_folder) - for filename in os.listdir(full_path): - if filename.endswith(".yaml") or filename.endswith(".yml"): - file_path = os.path.join(signup_folder, filename) - file_data = read_yaml_file(base_dir=base_dir, file=file_path) - - # Although there is a for loop here, there should only be a single tree per file - for tree_values in file_data.values(): - tree_url = tree_values.get("url") - default_recipients = tree_values.get("default_recipients", []) - if search_url == tree_url: - return default_recipients - else: - log_message( - f"Skipping file {filename} on loading summary files. Not a yaml file." - ) +def unscoped_regression_recipients( + git_url: Optional[str], + *, + entries_by_url: Optional[dict[str, list[RecipientEntry]]] = None, +) -> list[str]: + """Cc for a test report: only the regression/ entries without scope keys.""" + if entries_by_url is None: + entries_by_url = load_regression_files() + entries = entries_by_url.get(git_url, []) + return _union_recipients(entry for entry in entries if not entry.scope) def _ask_confirmation(): @@ -100,8 +128,6 @@ def send_email_report( service, report, email_args, - git_url: Optional[str] = None, - signup_folder: Optional[str] = None, recipients: Optional[list[str]] = None, message_id: Optional[str] = None, ): @@ -111,13 +137,7 @@ def send_email_report( service: the email service used to send the email itself report: the dict with information about the report, usually set up with jinja email_args: the SimpleNamespace with options for the email sending - git_url: the git_repository_url of a tree that is used to retrieve the recipients for the email.\n - Requires signup_folder to be set in order to take effect. - signup_folder: the folder with the tree submissions files that is used - to retrieve the recipients for the email.\n - Is only used if git_url is set and default recipients are not ignored. - recipients: a direct list of email recipients to use, - this parameter overrides the use of git_url + signup_folder. + recipients: the Cc list, skipped when email_args.ignore_recipients is set message_id: the Message-ID to send the email with, generated by the smtp helper when not given. Pass one when the report body references its own Message-ID (e.g. a lore.kernel.org link). @@ -137,19 +157,17 @@ def send_email_report( cc = "" reply_to = None if email_args.add_mailing_lists: - to = KERNELCI_RESULTS + to = ( + load_recipient_default(_regression_directory()).to + if email_args.regression_report + else KERNELCI_RESULTS + ) reply_to = KERNELCI_REPLYTO else: to = email_args.to - if not email_args.ignore_recipients: - if not recipients: - recipients = _get_default_tree_recipients( - signup_folder=signup_folder, - search_url=git_url, - ) - formatted_recipients = ", ".join(recipients) if recipients else "" - cc = ", ".join([formatted_recipients, cc]) if cc else formatted_recipients + if not email_args.ignore_recipients and recipients: + cc = ", ".join(recipients) if email_args.cc: cc = ", ".join([email_args.cc, cc]) if cc else email_args.cc diff --git a/backend/kernelCI_app/management/commands/notifications.py b/backend/kernelCI_app/management/commands/notifications.py index 19ed1eef6..b8853f6cc 100644 --- a/backend/kernelCI_app/management/commands/notifications.py +++ b/backend/kernelCI_app/management/commands/notifications.py @@ -1,12 +1,14 @@ import json +import os import sys from collections import defaultdict from datetime import datetime, time, timedelta, timezone -from email.utils import make_msgid +from email.utils import make_msgid, parseaddr from types import SimpleNamespace from typing import Optional from urllib.parse import quote_plus +from django.conf import settings from django.core.management.base import BaseCommand from kernelCI_app.helpers.email import smtp_setup_connection @@ -16,8 +18,10 @@ from kernelCI_app.helpers.trees import sanitize_tree from kernelCI_app.management.commands.helpers.common import ( is_regzbot_tree, + resolve_regression_recipients, send_email_report, setup_jinja_template, + unscoped_regression_recipients, ) from kernelCI_app.management.commands.helpers.healthcheck import ( MONITORING_ID_PARAM_HELP_TEXT, @@ -53,6 +57,11 @@ LabMetricsData, MetricsReportData, ) +from kernelCI_app.typeModels.notificationRecipients import ( + NEW_ISSUES_FOLDER, + RecipientEntry, + load_new_issue_files, +) from kernelCI_app.utils import group_status, is_boot from kernelCI_cache.queries.issues import ( get_all_issue_keys, @@ -100,12 +109,7 @@ def exclude_already_found_and_store(issues: list[dict]) -> list[dict]: return new_issues -def look_for_new_issues(*, service, signup_folder, email_args): - if get_running_instance() == "staging": - print("This command only runs on production or dev environments.") - return - - issues = kcidb_new_issues() +def _tag_build_and_boot(issues: list[dict]) -> tuple[list[dict], list[dict]]: build_issues = [] boot_issues = [] for issue in issues: @@ -115,38 +119,182 @@ def look_for_new_issues(*, service, signup_folder, email_args): elif issue["test_id"]: issue["type"] = "boot" boot_issues.append(issue) + return build_issues, boot_issues + + +def _tag_other_tests(issues: list[dict], known_ids: set[str]) -> list[dict]: + """Test rows are incidents with a test id and no build id. + + Ids already returned by the build/boot query are skipped, so a build + incident is never mailed as a test. + """ + test_issues = [] + for issue in issues: + if issue["id"] in known_ids or issue["build_id"] or not issue["test_id"]: + continue + issue["type"] = "test" + known_ids.add(issue["id"]) + test_issues.append(issue) + return test_issues + + +def _new_issue_matches(entry: RecipientEntry, issue: dict) -> bool: + if entry.tree_url is not None and issue.get("git_repository_url") != entry.tree_url: + return False + # Build rows carry a NULL platform and never match a platform scope. + if issue["type"] == "build" and (entry.hardware_platform or entry.platform): + return False + if ( + entry.hardware_platform is not None + and issue.get("platform") != entry.hardware_platform + ): + return False + if entry.issues and issue["type"] not in entry.issues: + return False + if entry.branch and issue.get("git_repository_branch") not in entry.branch: + return False + if entry.origin and issue.get("origin") not in entry.origin: + return False + if entry.platform and issue.get("platform") not in entry.platform: + return False + return True + + +def _copies_by_address(entries: list[RecipientEntry], issues: list[dict]) -> list[dict]: + """One bucket per address: the union of rows from every matching entry.""" + deliveries: dict[str, dict] = {} + for entry in entries: + matched = [issue for issue in issues if _new_issue_matches(entry, issue)] + if not matched: + continue + for recipient in entry.recipients: + key = parseaddr(recipient)[1].lower() or recipient + bucket = deliveries.get(key) + if bucket is None: + bucket = {"to": recipient, "issues": [], "seen": set(), "files": []} + deliveries[key] = bucket + for issue in matched: + if issue["id"] in bucket["seen"]: + continue + bucket["seen"].add(issue["id"]) + bucket["issues"].append(issue) + if entry.source_file not in bucket["files"]: + bucket["files"].append(entry.source_file) + return list(deliveries.values()) + + +def _summary_report( + template, when, *, build_issues, boot_issues, test_issues, copy_from="" +): + return { + "content": template.render( + build_issues=build_issues, + boot_issues=boot_issues, + test_issues=test_issues, + copy_from=copy_from, + ), + "title": f"new issues summary - {when.strftime('%Y-%m-%d %H:%M %Z')}", + } + + +def _show_new_issue_delivery(email_args, to: str, issues: list[dict]) -> None: + if email_args.send: + return + print(f"To: {to}") + print("issues: " + ", ".join(issue["id"] for issue in issues)) + + +def _of_type(issues: list[dict], issue_type: str) -> list[dict]: + return [issue for issue in issues if issue["type"] == issue_type] + + +def look_for_new_issues(*, service, email_args, directory: Optional[str] = None): + if get_running_instance() == "staging": + print("This command only runs on production or dev environments.") + return + + if directory is None: + directory = os.path.join(settings.BACKEND_DATA_DIR, NEW_ISSUES_FOLDER) + default, entries = load_new_issue_files(directory) + if email_args.ignore_recipients: + entries = [] + if not email_args.to: + email_args.to = default.to + + wants_test = "test" in default.issues or any( + not entry.issues or "test" in entry.issues for entry in entries + ) + build_issues, boot_issues = _tag_build_and_boot(kcidb_new_issues()) + known_ids = {issue["id"] for issue in build_issues + boot_issues} + test_issues = ( + _tag_other_tests(kcidb_new_issues(include_other_tests=True), known_ids) + if wants_test + else [] + ) new_build_issues = exclude_already_found_and_store(build_issues) new_boot_issues = exclude_already_found_and_store(boot_issues) - if not new_build_issues and not new_boot_issues: + new_test_issues = exclude_already_found_and_store(test_issues) + if not new_build_issues and not new_boot_issues and not new_test_issues: print("No new issues") return - template = setup_jinja_template("issues.txt.j2") - report = {} - now = datetime.now(timezone.utc) - report["content"] = template.render( - build_issues=new_build_issues, boot_issues=new_boot_issues + kinds = set(default.issues) + community_build = new_build_issues if "build" in kinds else [] + community_boot = new_boot_issues if "boot" in kinds else [] + community_test = new_test_issues if "test" in kinds else [] + community_rows = community_build + community_boot + community_test + copies = _copies_by_address( + entries, new_build_issues + new_boot_issues + new_test_issues ) - report["title"] = f"new issues summary - {now.strftime('%Y-%m-%d %H:%M %Z')}" - send_email_report( - service=service, - report=report, - email_args=email_args, - signup_folder=signup_folder, - ) + template = setup_jinja_template("issues.txt.j2") + now = datetime.now(timezone.utc) + if community_rows: + _show_new_issue_delivery(email_args, email_args.to, community_rows) + send_email_report( + service=service, + report=_summary_report( + template, + now, + build_issues=community_build, + boot_issues=community_boot, + test_issues=community_test, + ), + email_args=email_args, + ) + for copy in copies: + copy_args = SimpleNamespace(**vars(email_args)) + copy_args.to = copy["to"] + copy_args.cc = None + copy_args.add_mailing_lists = False + copy_args.ignore_recipients = True + _show_new_issue_delivery(copy_args, copy["to"], copy["issues"]) + send_email_report( + service=service, + report=_summary_report( + template, + now, + build_issues=_of_type(copy["issues"], "build"), + boot_issues=_of_type(copy["issues"], "boot"), + test_issues=_of_type(copy["issues"], "test"), + copy_from=", ".join(f"new_issues/{name}" for name in copy["files"]), + ), + email_args=copy_args, + ) + if not community_rows and not copies: + print("No new issues") - email_args.add_mailing_lists = True - email_args.update = True - for issue in new_build_issues: - if issue["origin"] == "maestro": - generate_issue_report( - service=service, - issue_id=issue["id"], - email_args=email_args, - signup_folder=signup_folder, - ) + if new_build_issues or new_boot_issues: + email_args.add_mailing_lists = True + email_args.update = True + for issue in new_build_issues: + if issue["origin"] == "maestro": + generate_issue_report( + service=service, + issue_id=issue["id"], + email_args=email_args, + ) def ask_ignore_issue(): @@ -208,7 +356,6 @@ def generate_issue_report( issue_id, email_args, ask_ignore=False, - signup_folder, ): result = kcidb_issue_details(issue_id) if not result: @@ -249,6 +396,7 @@ def generate_issue_report( if last_build: incident["last_pass_commit"] = last_build[0]["git_commit_hash"] report = generate_build_issue_report(issue, incidents, message_id) + recipients = resolve_regression_recipients(issue, incidents) elif issue["test_id"]: incidents = kcidb_test_incidents(issue_id) issue_type = "boot" if is_boot(path=incidents[0]["path"]) else "test" @@ -258,6 +406,7 @@ def generate_issue_report( incident["last_pass_commit"] = last_test[0]["git_commit_hash"] incident["last_pass_id"] = last_test[0]["id"] report = generate_boot_issue_report(issue, incidents, message_id) + recipients = resolve_regression_recipients(issue, []) else: print(f"unable to generate issue report for {issue_id}", file=sys.stderr) sys.exit(-1) @@ -268,8 +417,7 @@ def generate_issue_report( service=service, report=report, email_args=email_args, - signup_folder=signup_folder, - git_url=issue["git_repository_url"], + recipients=recipients, message_id=message_id, ) @@ -549,7 +697,7 @@ def run_checkout_summary( ) -def create_and_send_issue_reports(*, service, email_args, signup_folder): +def create_and_send_issue_reports(*, service, email_args): unsent_issues = get_unsent_issues() for issue in unsent_issues: if issue.issue_type != "build": @@ -560,11 +708,10 @@ def create_and_send_issue_reports(*, service, email_args, signup_folder): issue_id=issue.issue_id, email_args=email_args, ask_ignore=True, - signup_folder=signup_folder, ) -def generate_test_report(*, service, test_id, email_args, signup_folder): +def generate_test_report(*, service, test_id, email_args): """Generate a test regression report for a specific test ID.""" test_data = get_test_details_data(test_id=test_id) @@ -660,8 +807,7 @@ def generate_test_report(*, service, test_id, email_args, signup_folder): service=service, report=report, email_args=email_args, - git_url=test["git_repository_url"], - signup_folder=signup_folder, + recipients=unscoped_regression_recipients(test["git_repository_url"]), message_id=message_id, ) @@ -785,7 +931,6 @@ def generate_hardware_summary_report( service=service, report=report, email_args=email_args, - signup_folder=signup_folder, recipients=recipients, ) @@ -1070,7 +1215,6 @@ def _run_action(self, options): look_for_new_issues( service=service, email_args=email_args, - signup_folder=signup_folder, ) case "issue_report": @@ -1079,7 +1223,6 @@ def _run_action(self, options): create_and_send_issue_reports( service=service, email_args=email_args, - signup_folder=signup_folder, ) else: issue_id = options.get("id") @@ -1094,7 +1237,6 @@ def _run_action(self, options): service=service, issue_id=issue_id, email_args=email_args, - signup_folder=signup_folder, ) self.stdout.write( self.style.SUCCESS( @@ -1131,7 +1273,6 @@ def _run_action(self, options): service=service, test_id=test_id, email_args=email_args, - signup_folder=signup_folder, ) self.stdout.write( self.style.SUCCESS(f"Test report generated for test {test_id}") diff --git a/backend/kernelCI_app/management/commands/templates/issue_boot.txt.j2 b/backend/kernelCI_app/management/commands/templates/issue_boot.txt.j2 index 3036276ed..022763413 100644 --- a/backend/kernelCI_app/management/commands/templates/issue_boot.txt.j2 +++ b/backend/kernelCI_app/management/commands/templates/issue_boot.txt.j2 @@ -1,6 +1,8 @@ {% extends "base.txt" %} {% block header %}{% endblock %} {% block content -%} +Cc from: regression/{{ issue["tree_name"] }}.yaml + New boot regression found on {{ issue["tree_name"] }}/{{ issue["git_repository_branch"] }}: --- diff --git a/backend/kernelCI_app/management/commands/templates/issue_build.txt.j2 b/backend/kernelCI_app/management/commands/templates/issue_build.txt.j2 index e1f9b4025..0deebd9cc 100644 --- a/backend/kernelCI_app/management/commands/templates/issue_build.txt.j2 +++ b/backend/kernelCI_app/management/commands/templates/issue_build.txt.j2 @@ -1,6 +1,8 @@ {% extends "base.txt" %} {% block header %}{% endblock %} {% block content -%} +Cc from: regression/{{ issue["tree_name"] }}.yaml + New build issue found on {{ issue["tree_name"] }}/{{ issue["git_repository_branch"] }}: --- diff --git a/backend/kernelCI_app/management/commands/templates/issues.txt.j2 b/backend/kernelCI_app/management/commands/templates/issues.txt.j2 index c406c8fa6..f680a8f04 100644 --- a/backend/kernelCI_app/management/commands/templates/issues.txt.j2 +++ b/backend/kernelCI_app/management/commands/templates/issues.txt.j2 @@ -2,6 +2,10 @@ {% extends "base.txt" %} {% block header %}{% endblock %} {% block content %} +{% if copy_from -%} +Copy from: {{ copy_from }} + +{% endif -%} {% if build_issues %} Build regressions: ------------- @@ -32,4 +36,19 @@ Boot regressions: --- {% endfor %} {% endif %} +{% if test_issues %} +Test regressions: +------------- +{% for issue in test_issues %} +#kernelci issue {{ issue["id"] }} +- Comment: {{ issue["comment"] }} +- Timestamp: {{ issue["_timestamp"].strftime('%Y-%m-%d %H:%M %Z') }} +- Origin: {{ issue["origin"] }} +- Tree: {{ issue["tree_name"] }}/{{ issue["git_repository_branch"]}} +- Commit HEAD: {{ issue["git_commit_hash"] }} +- Incidents Count: {{issue["incident_count"]}} +- Dashboard: https://d.kernelci.org/issue/{{ issue["id"] }} +--- +{% endfor %} +{% endif %} {%- endblock %} \ No newline at end of file diff --git a/backend/kernelCI_app/queries/notifications.py b/backend/kernelCI_app/queries/notifications.py index 89ead4208..8b0aa9349 100644 --- a/backend/kernelCI_app/queries/notifications.py +++ b/backend/kernelCI_app/queries/notifications.py @@ -46,10 +46,40 @@ def kcidb_execute_query(query, params=None): sys.exit() -def kcidb_new_issues(): - """Fetch issues from the last few days, including related checkouts.""" +def kcidb_new_issues(*, include_other_tests: bool = False): + """Fetch issues from the last hour and the first incident's checkout. + + Build rows have a NULL platform. `include_other_tests` drops that branch + and keeps tests whose path is not boot or boot.nfs. + """ params = {"interval": "1 hour"} + if include_other_tests: + path_predicate = "AND NOT (t.path = 'boot' OR t.path = 'boot.nfs')" + build_union = "" + else: + path_predicate = "AND (t.path = 'boot' OR t.path = 'boot.nfs')" + build_union = """ + SELECT + inc._timestamp, + inc.issue_id, + inc.issue_version, + inc.test_id, + inc.build_id, + c.git_repository_url, + c.tree_name, + c.git_repository_branch, + c.git_commit_hash, + c.git_commit_name, + NULL::text AS platform, + ROW_NUMBER() OVER (PARTITION BY inc.issue_id ORDER BY inc._timestamp ASC) as incident_rn + FROM incidents inc + JOIN builds b ON inc.build_id = b.id + JOIN checkouts c ON b.checkout_id = c.id + WHERE inc._timestamp >= NOW() - INTERVAL %(interval)s + + UNION +""" query = """ WITH ranked_issues AS ( @@ -101,6 +131,7 @@ def kcidb_new_issues(): ), first_incidents AS ( +__BUILD_UNION__ SELECT inc._timestamp, inc.issue_id, @@ -112,32 +143,14 @@ def kcidb_new_issues(): c.git_repository_branch, c.git_commit_hash, c.git_commit_name, - ROW_NUMBER() OVER (PARTITION BY inc.issue_id ORDER BY inc._timestamp ASC) as incident_rn - FROM incidents inc - JOIN builds b ON inc.build_id = b.id - JOIN checkouts c ON b.checkout_id = c.id - WHERE inc._timestamp >= NOW() - INTERVAL %(interval)s - - UNION - - SELECT - inc._timestamp, - inc.issue_id, - inc.issue_version, - inc.test_id, - inc.build_id, - c.git_repository_url, - c.tree_name, - c.git_repository_branch, - c.git_commit_hash, - c.git_commit_name, + t.environment_misc->>'platform' AS platform, ROW_NUMBER() OVER (PARTITION BY inc.issue_id ORDER BY inc._timestamp ASC) as incident_rn FROM incidents inc JOIN tests t ON inc.test_id = t.id JOIN builds b ON t.build_id = b.id JOIN checkouts c ON b.checkout_id = c.id WHERE inc._timestamp >= NOW() - INTERVAL %(interval)s - AND (t.path = 'boot' OR t.path = 'boot.nfs') + __PATH_PREDICATE__ ) SELECT @@ -154,6 +167,7 @@ def kcidb_new_issues(): fi.git_repository_branch, fi.git_commit_hash, fi.git_commit_name, + fi.platform, COUNT(inc.id) AS incident_count FROM new_issues n LEFT JOIN first_incidents fi ON n.id = fi.issue_id AND fi.incident_rn = 1 @@ -171,11 +185,17 @@ def kcidb_new_issues(): fi.tree_name, fi.git_repository_branch, fi.git_commit_hash, - fi.git_commit_name + fi.git_commit_name, + fi.platform ORDER BY fi._timestamp DESC; """ - return kcidb_execute_query(query, params) + return kcidb_execute_query( + query.replace("__BUILD_UNION__", build_union).replace( + "__PATH_PREDICATE__", path_predicate + ), + params, + ) # TODO: include issue version as an optional parameter diff --git a/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_boot_report_example.txt b/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_boot_report_example.txt index 9b3ee6016..2055fecc3 100644 --- a/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_boot_report_example.txt +++ b/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_boot_report_example.txt @@ -1,3 +1,5 @@ +Cc from: regression/mainline.yaml + New boot regression found on mainline/master: --- diff --git a/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_build_report_example.txt b/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_build_report_example.txt index 2c3a67dae..44eb04bea 100644 --- a/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_build_report_example.txt +++ b/backend/kernelCI_app/tests/unitTests/commands/fixtures/issue_build_report_example.txt @@ -1,3 +1,5 @@ +Cc from: regression/mainline.yaml + New build issue found on mainline/master: --- diff --git a/backend/kernelCI_app/tests/unitTests/commands/new_issues_recipients_test.py b/backend/kernelCI_app/tests/unitTests/commands/new_issues_recipients_test.py new file mode 100644 index 000000000..fa5f12215 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/commands/new_issues_recipients_test.py @@ -0,0 +1,359 @@ +import os +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from django.conf import settings + +from kernelCI_app.management.commands.notifications import look_for_new_issues +from kernelCI_app.typeModels.notificationRecipients import ( + NEW_ISSUES_FOLDER, + RecipientFileError, + load_new_issue_files, +) + +MODULE = "kernelCI_app.management.commands.notifications" +SOUND_URL = "https://git.kernel.org/pub/scm/linux/kernel/git/broonie/sound.git" +WHEN = datetime(2026, 10, 22, 12, 0, tzinfo=timezone.utc) +DEFAULT_YAML = "to: kernelci-results@groups.io\nissues: [build, boot]\n" +SOUND_SUBSCRIPTION = f"broonie-sound:\n url: {SOUND_URL}\n" + + +def write_new_issues(tmp_path, files, subscriptions=None): + base = tmp_path / "notifications" + (base / "subscriptions").mkdir(parents=True) + (base / "new_issues").mkdir() + for name, content in (subscriptions or {}).items(): + (base / "subscriptions" / name).write_text(content) + for name, content in files.items(): + (base / "new_issues" / name).write_text(content) + return str(base / "new_issues") + + +def _issue(**overrides): + issue = { + "id": "issue-1", + "version": 1, + "build_id": "build-1", + "test_id": None, + "origin": "maestro", + "git_repository_url": SOUND_URL, + "git_repository_branch": "for-next", + "platform": None, + "comment": "broke", + "_timestamp": WHEN, + "tree_name": "broonie-sound", + "git_commit_hash": "abc", + "incident_count": 1, + } + issue.update(overrides) + return issue + + +def _email_args(**overrides): + args = SimpleNamespace( + send=False, + yes=True, + to=None, + cc=None, + in_reply_to=None, + ignore_recipients=False, + add_mailing_lists=False, + regression_report=False, + ) + for key, value in overrides.items(): + setattr(args, key, value) + return args + + +def _run(directory, issues, email_args, other=None): + events = [] + + def query(*, include_other_tests=False): + events.append(("query", include_other_tests)) + if include_other_tests: + return other or [] + return issues + + def store(rows): + events.append(("store", [row["id"] for row in rows])) + return rows + + def send(**kwargs): + events.append("send") + return None + + with ( + patch(f"{MODULE}.get_running_instance", return_value=None), + patch(f"{MODULE}.kcidb_new_issues", side_effect=query), + patch(f"{MODULE}.exclude_already_found_and_store", side_effect=store), + patch(f"{MODULE}.send_email_report", side_effect=send) as sent, + patch(f"{MODULE}.generate_issue_report") as regression, + ): + look_for_new_issues( + service=object(), email_args=email_args, directory=directory + ) + return events, sent, regression + + +def test_only_default_yaml_sends_one_community_mail(tmp_path, capsys): + directory = write_new_issues(tmp_path, {"default.yaml": DEFAULT_YAML}) + build = _issue(id="build-maestro") + other_origin = _issue(id="build-microsoft", origin="microsoft") + boot = _issue(id="boot-1", build_id=None, test_id="t1") + events, sent, regression = _run( + directory, [build, other_origin, boot], _email_args() + ) + + assert events[0] == ("query", False) + assert "query" not in [event[0] for event in events[1:] if event != "send"] + assert sent.call_count == 1 + report = sent.call_args.kwargs["report"]["content"] + assert "build-maestro" in report + assert "boot-1" in report + assert "Copy from" not in report + assert "Test regressions" not in report + assert sent.call_args.kwargs["email_args"].to == "kernelci-results@groups.io" + assert [call.kwargs["issue_id"] for call in regression.call_args_list] == [ + "build-maestro" + ] + printed = capsys.readouterr().out + assert "To: kernelci-results@groups.io" in printed + assert "issues: build-maestro, build-microsoft, boot-1" in printed + + +def test_to_overrides_default_yaml(tmp_path): + directory = write_new_issues(tmp_path, {"default.yaml": DEFAULT_YAML}) + _, sent, _ = _run( + directory, [_issue(id="build-maestro")], _email_args(to="me@example.com") + ) + + assert sent.call_args.kwargs["email_args"].to == "me@example.com" + + +def test_tree_boot_copy_skips_other_rows_and_unmatched_address(tmp_path, capsys): + directory = write_new_issues( + tmp_path, + { + "default.yaml": DEFAULT_YAML, + "broonie-sound.yaml": ( + "broonie-sound:\n" + " - issues: [boot]\n" + " recipients: [maintainer@example.com]\n" + " - issues: [boot]\n" + " branch: other-branch\n" + " recipients: [nobody@example.com]\n" + ), + }, + {"broonie-sound.yaml": SOUND_SUBSCRIPTION}, + ) + boot_on_tree = _issue( + id="boot-sound", build_id=None, test_id="t1", platform="board" + ) + boot_elsewhere = _issue( + id="boot-other", + build_id=None, + test_id="t2", + git_repository_url="https://example.com/other.git", + ) + build_on_tree = _issue(id="build-sound") + _, sent, _ = _run( + directory, [build_on_tree, boot_on_tree, boot_elsewhere], _email_args() + ) + + assert sent.call_count == 2 + copy = sent.call_args_list[1].kwargs + assert copy["email_args"].to == "maintainer@example.com" + assert copy["email_args"].cc is None + assert copy["email_args"].add_mailing_lists is False + assert copy["email_args"].ignore_recipients is True + assert "boot-sound" in copy["report"]["content"] + assert "boot-other" not in copy["report"]["content"] + assert "build-sound" not in copy["report"]["content"] + assert "Copy from: new_issues/broonie-sound.yaml" in copy["report"]["content"] + assert "nobody@example.com" not in capsys.readouterr().out + + +def test_platform_matches_boot_and_not_a_build(tmp_path): + directory = write_new_issues( + tmp_path, + { + "default.yaml": DEFAULT_YAML, + "qcs6490_hardware.yaml": ( + "qcs6490-rb3gen2:\n - recipients: [board-owner@example.com]\n" + ), + }, + {"qcs6490_hardware.yaml": "qcs6490-rb3gen2:\n labs: {}\n"}, + ) + boot = _issue( + id="boot-board", + build_id=None, + test_id="t1", + platform="qcs6490-rb3gen2", + ) + other_origin = _issue( + id="boot-microsoft", + build_id=None, + test_id="t2", + origin="microsoft", + platform="qcs6490-rb3gen2", + ) + other_platform = _issue( + id="boot-other", + build_id=None, + test_id="t3", + platform="other-board", + ) + build = _issue(id="build-board", platform="qcs6490-rb3gen2") + _, sent, _ = _run( + directory, [boot, other_origin, other_platform, build], _email_args(send=True) + ) + + copies = [ + call.kwargs + for call in sent.call_args_list + if call.kwargs["email_args"].to == "board-owner@example.com" + ] + assert len(copies) == 1 + content = copies[0]["report"]["content"] + assert "boot-board" in content + assert "boot-microsoft" not in content + assert "boot-other" not in content + assert "build-board" not in content + + +def test_two_files_sharing_an_address_send_one_mail(tmp_path): + directory = write_new_issues( + tmp_path, + { + "default.yaml": DEFAULT_YAML, + "boot.yaml": ( + "broonie-sound:\n" + " - issues: [boot]\n" + " recipients: ['Maintainer ']\n" + ), + "test.yaml": ( + "broonie-sound:\n" + " - issues: [test]\n" + " recipients: [maintainer@example.com]\n" + ), + }, + {"broonie-sound.yaml": SOUND_SUBSCRIPTION}, + ) + boot = _issue(id="boot-sound", build_id=None, test_id="t1") + test_issue = _issue(id="test-sound", build_id=None, test_id="t2") + _, sent, _ = _run(directory, [boot], _email_args(send=True), other=[test_issue]) + + copies = [ + call.kwargs + for call in sent.call_args_list + if "maintainer@example.com" in call.kwargs["email_args"].to + ] + assert len(copies) == 1 + assert copies[0]["email_args"].to == "Maintainer " + content = copies[0]["report"]["content"] + assert "boot-sound" in content + assert "test-sound" in content + assert "Test regressions" in content + assert "Copy from: new_issues/boot.yaml, new_issues/test.yaml" in content + + +def test_test_row_never_comes_from_a_build_incident(tmp_path): + directory = write_new_issues( + tmp_path, + { + "default.yaml": DEFAULT_YAML, + "broonie-sound.yaml": ( + "broonie-sound:\n" + " - issues: [test]\n" + " recipients: [maintainer@example.com]\n" + ), + }, + {"broonie-sound.yaml": SOUND_SUBSCRIPTION}, + ) + from_build = _issue(id="from-build", build_id="b1", test_id="t-build") + real_test = _issue(id="real-test", build_id=None, test_id="t-real") + events, sent, regression = _run( + directory, [], _email_args(send=True), other=[from_build, real_test] + ) + + assert ("query", True) in events + stored = [ + event[1] for event in events if isinstance(event, tuple) and event[0] == "store" + ] + assert stored == [[], [], ["real-test"]] + assert events.index(("store", ["real-test"])) < events.index("send") + content = sent.call_args.kwargs["report"]["content"] + assert "real-test" in content + assert "from-build" not in content + regression.assert_not_called() + + +def test_ignore_recipients_skips_copies(tmp_path): + directory = write_new_issues( + tmp_path, + { + "default.yaml": DEFAULT_YAML, + "broonie-sound.yaml": ( + "broonie-sound:\n" + " - issues: [test]\n" + " recipients: [maintainer@example.com]\n" + ), + }, + {"broonie-sound.yaml": SOUND_SUBSCRIPTION}, + ) + events, sent, _ = _run( + directory, + [_issue(id="boot-sound", build_id=None, test_id="t1")], + _email_args(send=True, ignore_recipients=True), + other=[_issue(id="test-sound", build_id=None, test_id="t2")], + ) + + assert events[0] == ("query", False) + assert ("query", True) not in events + assert sent.call_count == 1 + assert sent.call_args.kwargs["email_args"].to == "kernelci-results@groups.io" + + +def test_no_new_issues_sends_nothing(tmp_path, capsys): + directory = write_new_issues(tmp_path, {"default.yaml": DEFAULT_YAML}) + _, sent, regression = _run(directory, [], _email_args()) + + sent.assert_not_called() + regression.assert_not_called() + assert "No new issues" in capsys.readouterr().out + + +def test_loader_rejects_architecture(tmp_path): + directory = write_new_issues( + tmp_path, + { + "default.yaml": DEFAULT_YAML, + "broonie-sound.yaml": ( + "broonie-sound:\n" + " - issues: [boot]\n" + " architecture: [arm64]\n" + " recipients: [a@example.com]\n" + ), + }, + {"broonie-sound.yaml": SOUND_SUBSCRIPTION}, + ) + + with pytest.raises(RecipientFileError) as error: + load_new_issue_files(directory) + + message = str(error.value) + assert "broonie-sound.yaml" in message + assert "entry 0" in message + assert "key 'architecture'" in message + + +def test_committed_new_issue_files_are_valid(): + directory = os.path.join(settings.BACKEND_DATA_DIR, NEW_ISSUES_FOLDER) + + default, entries = load_new_issue_files(directory) + + assert entries == [] + assert default.to == "kernelci-results@groups.io" + assert default.issues == ["build", "boot"] diff --git a/backend/kernelCI_app/tests/unitTests/commands/regression_recipients_test.py b/backend/kernelCI_app/tests/unitTests/commands/regression_recipients_test.py new file mode 100644 index 000000000..b119481b0 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/commands/regression_recipients_test.py @@ -0,0 +1,191 @@ +import os + +import pytest +from django.conf import settings + +from kernelCI_app.management.commands.helpers.common import ( + REGRESSION_FOLDER, + load_regression_files, + resolve_regression_recipients, + unscoped_regression_recipients, +) +from kernelCI_app.typeModels.notificationRecipients import ( + RecipientFileError, + load_recipient_default, +) + +STABLE_URL = "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git" + + +def write_tree(tmp_path, *, regression: str, subscriptions: dict[str, str] = None): + """Creates subscriptions/ and regression/ under tmp_path, returns regression/""" + if subscriptions is None: + subscriptions = {"stable.yaml": f"stable:\n url: {STABLE_URL}\n"} + (tmp_path / "subscriptions").mkdir() + for name, content in subscriptions.items(): + (tmp_path / "subscriptions" / name).write_text(content) + (tmp_path / "regression").mkdir() + (tmp_path / "regression" / "stable.yaml").write_text(regression) + return str(tmp_path / "regression") + + +def test_loader_valid(tmp_path): + directory = write_tree( + tmp_path, + regression=( + "stable:\n" + " - recipients: list@example.org\n" + " - branch: linux-6.12.y\n" + " architecture: [arm64]\n" + " issues: [build]\n" + " recipients: [Maintainer ]\n" + ), + ) + + entries = load_regression_files(directory) + + assert list(entries) == [STABLE_URL] + unscoped, scoped = entries[STABLE_URL] + assert unscoped.recipients == ["list@example.org"] + assert unscoped.scope == {} + assert scoped.recipients == ["Maintainer "] + assert scoped.scope == { + "issues": ["build"], + "branch": ["linux-6.12.y"], + "architecture": ["arm64"], + } + + +@pytest.mark.parametrize( + ("entry", "key"), + [ + ("{recipients: [a@example.org], unknown: x}", "unknown"), + ("{recipients: [a@example.org], platform: rk3399}", "platform"), + ("{recipients: [a@example.org], origin: maestro}", "origin"), + ("{recipients: [a@example.org], issues: [boot]}", "issues"), + ("{recipients: []}", "recipients"), + ], +) +def test_loader_rejects_entry(tmp_path, entry, key): + directory = write_tree( + tmp_path, regression=f"stable:\n - recipients: [ok@example.org]\n - {entry}\n" + ) + + with pytest.raises(RecipientFileError) as error: + load_regression_files(directory) + + message = str(error.value) + assert "stable.yaml" in message + assert "entry 1" in message + assert f"key '{key}'" in message + + +def test_loader_rejects_unknown_tree(tmp_path): + directory = write_tree( + tmp_path, regression="mainline:\n - recipients: [a@example.org]\n" + ) + + with pytest.raises(RecipientFileError, match="unknown tree 'mainline'"): + load_regression_files(directory) + + +def test_loader_rejects_duplicate_subscription_url(tmp_path): + directory = write_tree( + tmp_path, + regression="stable:\n - recipients: [a@example.org]\n", + subscriptions={ + "stable.yaml": f"stable:\n url: {STABLE_URL}\n", + "stable-copy.yaml": f"stable-copy:\n url: {STABLE_URL}\n", + }, + ) + + with pytest.raises(RecipientFileError, match="also used by"): + load_regression_files(directory) + + +def test_loader_rejects_hardware_file(tmp_path): + directory = write_tree( + tmp_path, regression="stable:\n - recipients: [a@example.org]\n" + ) + (tmp_path / "regression" / "board_hardware.yaml").write_text("board: {}\n") + + with pytest.raises(RecipientFileError, match="hardware files are not allowed"): + load_regression_files(directory) + + +def test_committed_regression_files_are_valid(): + directory = os.path.join(settings.BACKEND_DATA_DIR, REGRESSION_FOLDER) + + assert load_regression_files(directory) + assert load_recipient_default(directory).to + + +@pytest.fixture +def entries_by_url(tmp_path): + directory = write_tree( + tmp_path, + regression=( + "stable:\n" + " - recipients: [list@example.org]\n" + " - branch: linux-6.12.y\n" + " recipients: [branch@example.org]\n" + " - architecture: arm64\n" + " recipients: [arm64@example.org, List ]\n" + " - branch: linux-6.6.y\n" + " compiler: clang-17\n" + " recipients: [never@example.org]\n" + ), + ) + return load_regression_files(directory) + + +def build_issue(branch: str) -> dict: + return { + "build_id": "maestro:1", + "git_repository_url": STABLE_URL, + "git_repository_branch": branch, + } + + +def test_resolve_branch_match(entries_by_url): + recipients = resolve_regression_recipients( + build_issue("linux-6.12.y"), + [{"architecture": "x86_64"}], + entries_by_url=entries_by_url, + ) + + assert recipients == ["list@example.org", "branch@example.org"] + + +def test_resolve_architecture_match_and_dedup(entries_by_url): + recipients = resolve_regression_recipients( + build_issue("linux-6.1.y"), + [{"architecture": "x86_64"}, {"architecture": "arm64"}], + entries_by_url=entries_by_url, + ) + + assert recipients == ["list@example.org", "arm64@example.org"] + + +def test_resolve_scoped_entries_need_every_key(entries_by_url): + recipients = resolve_regression_recipients( + build_issue("linux-6.6.y"), + [{"compiler": "gcc-12"}], + entries_by_url=entries_by_url, + ) + + assert recipients == ["list@example.org"] + + +def test_resolve_no_match_for_other_tree(entries_by_url): + issue = build_issue("linux-6.12.y") | {"git_repository_url": "https://other"} + + assert resolve_regression_recipients(issue, [], entries_by_url=entries_by_url) == [] + + +def test_test_report_uses_only_unscoped_entries(entries_by_url): + recipients = unscoped_regression_recipients( + STABLE_URL, entries_by_url=entries_by_url + ) + + assert recipients == ["list@example.org"] diff --git a/backend/kernelCI_app/tests/unitTests/queries/notifications_test.py b/backend/kernelCI_app/tests/unitTests/queries/notifications_test.py index cedbba650..5550f32ab 100644 --- a/backend/kernelCI_app/tests/unitTests/queries/notifications_test.py +++ b/backend/kernelCI_app/tests/unitTests/queries/notifications_test.py @@ -60,8 +60,26 @@ def test_kcidb_new_issues_success(self, mock_execute_query): result = kcidb_new_issues() assert result == expected_result + query = mock_execute_query.call_args[0][0] + assert "NULL::text AS platform" in query + assert "t.environment_misc->>'platform' AS platform" in query + assert "AND (t.path = 'boot' OR t.path = 'boot.nfs')" in query + assert "JOIN builds b ON inc.build_id = b.id" in query mock_execute_query.assert_called_once() + @patch("kernelCI_app.queries.notifications.kcidb_execute_query") + def test_kcidb_new_issues_other_tests_drop_the_build_branch( + self, mock_execute_query + ): + mock_execute_query.return_value = [] + + kcidb_new_issues(include_other_tests=True) + + query = mock_execute_query.call_args[0][0] + assert "JOIN builds b ON inc.build_id = b.id" not in query + assert "AND NOT (t.path = 'boot' OR t.path = 'boot.nfs')" in query + assert "t.environment_misc->>'platform' AS platform" in query + class TestKcidbIssueDetails: @patch("kernelCI_app.queries.notifications.kcidb_execute_query") diff --git a/backend/kernelCI_app/typeModels/notificationRecipients.py b/backend/kernelCI_app/typeModels/notificationRecipients.py new file mode 100644 index 000000000..aaa989a7c --- /dev/null +++ b/backend/kernelCI_app/typeModels/notificationRecipients.py @@ -0,0 +1,294 @@ +import os +from typing import Annotated, Optional + +import yaml +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PrivateAttr, + ValidationError, +) + +from kernelCI_app.constants.general import DEFAULT_ORIGIN + +DEFAULT_FILE = "default.yaml" +HARDWARE_SUFFIX = "_hardware.yaml" +NEW_ISSUES_FOLDER = "notifications/new_issues" +NEW_ISSUE_KEYS = {"issues", "branch", "origin", "platform"} +NEW_ISSUE_KINDS = {"build", "boot", "test"} + + +def _as_list(value): + return [value] if isinstance(value, str) else value + + +type StrList = Annotated[list[str], BeforeValidator(_as_list), Field(min_length=1)] + + +class RecipientEntry(BaseModel): + model_config = ConfigDict(extra="forbid") + + recipients: StrList + issues: Optional[StrList] = None + branch: Optional[StrList] = None + architecture: Optional[StrList] = None + config_name: Optional[StrList] = None + compiler: Optional[StrList] = None + platform: Optional[StrList] = None + origin: Optional[StrList] = None + _tree_url: Optional[str] = PrivateAttr(default=None) + _hardware_platform: Optional[str] = PrivateAttr(default=None) + _source_file: str = PrivateAttr(default="") + + @property + def scope(self) -> dict[str, list[str]]: + return self.model_dump(exclude={"recipients"}, exclude_none=True) + + @property + def tree_url(self) -> Optional[str]: + return self._tree_url + + @property + def hardware_platform(self) -> Optional[str]: + return self._hardware_platform + + @property + def source_file(self) -> str: + return self._source_file + + +class RecipientDefault(BaseModel): + model_config = ConfigDict(extra="forbid") + + to: str + + +class NewIssuesDefault(RecipientDefault): + issues: StrList + + +class RecipientFileError(ValueError): + def __init__(self, file: str, message: str, *, entry=None, key=None): + where = [file] + if entry is not None: + where.append(f"entry {entry}") + if key is not None: + where.append(f"key '{key}'") + super().__init__(f"{': '.join(where)}: {message}") + + +def _read_yaml(path: str): + try: + with open(path) as f: + return yaml.safe_load(f) + except (OSError, yaml.YAMLError) as exc: + raise RecipientFileError(path, str(exc)) from exc + + +def _yaml_files(directory: str) -> list[str]: + return sorted(f for f in os.listdir(directory) if f.endswith((".yaml", ".yml"))) + + +def _subscriptions_directory(directory: str) -> str: + return os.path.join(os.path.dirname(os.path.normpath(directory)), "subscriptions") + + +def _tree_urls(subscriptions_directory: str) -> dict[str, str]: + """Maps each tree key in the subscription files to its url""" + tree_urls: dict[str, str] = {} + files_by_url: dict[str, str] = {} + for filename in _yaml_files(subscriptions_directory): + if filename.endswith(HARDWARE_SUFFIX): + continue + path = os.path.join(subscriptions_directory, filename) + for tree_key, values in (_read_yaml(path) or {}).items(): + url = values.get("url") + if url in files_by_url: + raise RecipientFileError( + path, + f"url {url} is also used by {files_by_url[url]}", + key="url", + ) + files_by_url[url] = filename + tree_urls[tree_key] = url + return tree_urls + + +def _hardware_origins(subscriptions_directory: str) -> dict[str, str]: + """Maps each platform key in *_hardware.yaml to its origin (maestro if omitted).""" + origins: dict[str, str] = {} + for filename in _yaml_files(subscriptions_directory): + if not filename.endswith(HARDWARE_SUFFIX): + continue + path = os.path.join(subscriptions_directory, filename) + for platform, values in (_read_yaml(path) or {}).items(): + origin = DEFAULT_ORIGIN + if isinstance(values, dict) and values.get("origin"): + origin = values["origin"] + origins.setdefault(platform, origin) + return origins + + +def _parse_entry(path, index, raw, *, allowed_keys, allowed_issues) -> RecipientEntry: + if not isinstance(raw, dict): + raise RecipientFileError(path, "entry must be a mapping", entry=index) + for key in raw: + if key != "recipients" and key not in allowed_keys: + raise RecipientFileError(path, "key not allowed", entry=index, key=key) + try: + entry = RecipientEntry.model_validate(raw) + except ValidationError as exc: + error = exc.errors()[0] + key = error["loc"][0] if error["loc"] else None + raise RecipientFileError(path, error["msg"], entry=index, key=key) from exc + for issue in entry.issues or []: + if issue not in allowed_issues: + raise RecipientFileError( + path, f"issue '{issue}' not allowed", entry=index, key="issues" + ) + return entry + + +def _append_key_entries( + entries: list[RecipientEntry], + *, + path: str, + filename: str, + key: str, + raw_entries, + is_hardware: bool, + tree_urls: dict[str, str], + hardware_origins: dict[str, str], + allowed_keys: set[str], + allowed_issues: set[str], +) -> None: + if is_hardware and key not in hardware_origins: + raise RecipientFileError(path, f"unknown platform '{key}'", key=key) + if not is_hardware and key not in tree_urls: + raise RecipientFileError(path, f"unknown tree '{key}'", key=key) + if not isinstance(raw_entries, list): + raise RecipientFileError(path, "tree must hold a list of entries", key=key) + for index, raw in enumerate(raw_entries): + entry = _parse_entry( + path, + index, + raw, + allowed_keys=allowed_keys, + allowed_issues=allowed_issues, + ) + entry._source_file = filename + if is_hardware: + entry._hardware_platform = key + if entry.origin is None: + entry.origin = [hardware_origins[key]] + else: + entry._tree_url = tree_urls[key] + entries.append(entry) + + +def _collect_entries( + directory: str, + *, + allowed_keys: set[str], + allowed_issues: set[str], + allow_hardware: bool, + subscriptions_directory: str, +) -> list[RecipientEntry]: + tree_urls = _tree_urls(subscriptions_directory) + hardware_origins = ( + _hardware_origins(subscriptions_directory) if allow_hardware else {} + ) + entries: list[RecipientEntry] = [] + for filename in _yaml_files(directory): + if filename == DEFAULT_FILE: + continue + path = os.path.join(directory, filename) + is_hardware = filename.endswith(HARDWARE_SUFFIX) + if is_hardware and not allow_hardware: + raise RecipientFileError(path, "hardware files are not allowed") + data = _read_yaml(path) + if not isinstance(data, dict): + raise RecipientFileError(path, "file must map tree keys to entries") + for key, raw_entries in data.items(): + _append_key_entries( + entries, + path=path, + filename=filename, + key=key, + raw_entries=raw_entries, + is_hardware=is_hardware, + tree_urls=tree_urls, + hardware_origins=hardware_origins, + allowed_keys=allowed_keys, + allowed_issues=allowed_issues, + ) + return entries + + +def load_recipient_files( + directory: str, + *, + allowed_keys: set[str], + allowed_issues: set[str], + subscriptions_directory: Optional[str] = None, +) -> dict[str, list[RecipientEntry]]: + """Loads and validates every recipient file in `directory`. + + Each top-level key must be a tree key from the subscription files, which + default to the `subscriptions` folder next to `directory`. + + Returns the entries keyed by the tree's git url. + Raises RecipientFileError naming the file, entry index, and key. + """ + if subscriptions_directory is None: + subscriptions_directory = _subscriptions_directory(directory) + entries_by_url: dict[str, list[RecipientEntry]] = {} + for entry in _collect_entries( + directory, + allowed_keys=allowed_keys, + allowed_issues=allowed_issues, + allow_hardware=False, + subscriptions_directory=subscriptions_directory, + ): + entries_by_url.setdefault(entry.tree_url, []).append(entry) + return entries_by_url + + +def load_recipient_default(directory: str) -> RecipientDefault: + path = os.path.join(directory, DEFAULT_FILE) + try: + return RecipientDefault.model_validate(_read_yaml(path)) + except ValidationError as exc: + error = exc.errors()[0] + key = error["loc"][0] if error["loc"] else None + raise RecipientFileError(path, error["msg"], key=key) from exc + + +def load_new_issue_files( + directory: str, +) -> tuple[NewIssuesDefault, list[RecipientEntry]]: + """Loads `default.yaml` and every other recipient file in `directory`. + + Tree keys match a subscription url. Hardware keys match a platform and, + when the entry omits origin, use that platform's origin. + """ + path = os.path.join(directory, DEFAULT_FILE) + try: + default = NewIssuesDefault.model_validate(_read_yaml(path)) + except ValidationError as exc: + error = exc.errors()[0] + key = error["loc"][0] if error["loc"] else None + raise RecipientFileError(path, error["msg"], key=key) from exc + for issue in default.issues: + if issue not in NEW_ISSUE_KINDS: + raise RecipientFileError(path, f"issue '{issue}' not allowed", key="issues") + entries = _collect_entries( + directory, + allowed_keys=NEW_ISSUE_KEYS, + allowed_issues=NEW_ISSUE_KINDS, + allow_hardware=True, + subscriptions_directory=_subscriptions_directory(directory), + ) + return default, entries diff --git a/docs/notifications.md b/docs/notifications.md index 9dd741196..2cd22f5d9 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -39,9 +39,9 @@ These options are available for all actions and are always optional: | Option | Description | Type | Default | |--------|-------------|------|---------| | `--add-mailing-lists` | Include community mailing lists in recipients | Flag | False | -| `--ignore-recipients` | Bypass the recipients in the subscription file | Flag | False | +| `--ignore-recipients` | Bypass the recipients in the subscription, `regression/`, and `new_issues/` files | Flag | False | | `--send` | Send email after generating report | Flag | False | -| `--to` | Specify direct recipient email | String | None | +| `--to` | Specify direct recipient email. Overrides `new_issues/default.yaml` on the community summary | String | None | | `--cc` | Specify CC recipient emails as "email1, email2" list | String | None | | `--yes` | Send email without confirmation | Flag | False | | `--in-reply-to` | Message-ID to reply to (sets In-Reply-To header) | String | None | @@ -102,10 +102,91 @@ poetry run python manage.py notifications \ ## Adding yourself to recipients -Edit the corresponding tree file in the [subscriptions folder](../backend/data/notifications/subscriptions) and send a PR to the dashboard. +Edit the file for your tree and send a PR to the dashboard. Use the git tree +name reported by the Web Dashboard. + +- `[STATUS]` checkout summaries: `default_recipients` in the + [subscriptions folder](../backend/data/notifications/subscriptions). +- `[REGRESSION]` issue reports: the + [regression folder](../backend/data/notifications/regression). The report + body names the file it took the Cc from. +- New-issues summary copies: the + [new issues folder](../backend/data/notifications/new_issues). The community + mail still goes to the `to` address in `default.yaml`. A copy names the + files its rows came from. + +### Regression recipients + +Each `regression/.yaml` has a top-level key that must be a tree key +from `subscriptions/`. The tree's `url` decides which issues the file applies +to. Each entry Cc's its `recipients` when every scope key it sets matches: + +```yaml +stable: + - recipients: [list@example.org] # no scope keys: every build issue on this tree's URL + - branch: [linux-6.12.y, linux-6.6.y] + architecture: [arm64] + recipients: [maintainer@example.com] +``` -Use the git tree name reported by the -Web Dashboard. +| Key | Values | Matched against | +|---|---|---| +| `recipients` | addresses, at least one, sent as written | | +| `issues` | `build` only | the issue kind | +| `branch` | string or list | the branch printed in the report | +| `architecture`, `config_name`, `compiler` | string or list | any build where the issue occurred | + +The Cc is the union of the matching entries, with repeated addresses dropped. +Any other key, including `platform` and `origin`, fails `pytest -m unit` with +the file, entry index, and key. + +`test_report` only uses entries without scope keys. + +With `--add-mailing-lists`, regression reports are sent to the `to` address in +[`regression/default.yaml`](../backend/data/notifications/regression/default.yaml). +`[STATUS]` summaries keep the address in `common.py`. + +### New-issues recipients + +[`new_issues/default.yaml`](../backend/data/notifications/new_issues/default.yaml) +replaces `--to` on the hourly summary. `--to` still overrides it. `issues` +lists the kinds in that community mail. + +Other files are optional. A tree file's key must be a tree key in +`subscriptions/`, and the entry matches that tree's `url`. A +`*_hardware.yaml` key must be a platform in `subscriptions/`, and the entry +matches that platform. Each address with at least one matching issue gets one +mail for the run, combining every matching entry and file. No match sends +nothing. The community mail is still sent, including to an address that also +gets a copy. + +```yaml +broonie-sound: + - issues: [boot, test] + recipients: [maintainer@example.com] +``` + +```yaml +qcs6490-rb3gen2: + - issues: [boot] + recipients: [board-owner@example.com] # origin comes from that platform's *_hardware.yaml +``` + +| Key | Values | Matched against | +|---|---|---| +| `recipients` | addresses, at least one, sent as written | | +| `issues` | `build`, `boot`, `test` | the row kind | +| `branch`, `origin` | string or list | the first incident's row | +| `platform` | string or list | that row's platform. Build rows have none and never match | + +An omitted `origin` on a hardware entry uses the platform's `origin` in +`subscriptions/*_hardware.yaml`, or `maestro` when that file omits it. +`architecture`, `config_name`, and `compiler` fail `pytest -m unit` with the +file, entry index, and key. + +A copy goes to that address alone. Its body starts with `Copy from: +new_issues/` for each file that contributed a row. The community mail +has no such line. ## Signup tree for checkout summary