From 06c8e1001e2b8bbeba7db9fec70d34c371f53266 Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 21 May 2026 12:05:06 +0100 Subject: [PATCH] Add web platform features rules Bugs with the `web-feature` keyword are managed by the bot so that their lifetimes matches the lifetime of the related web-feature (specified in the user-story field) and are closed once the web feature is marked as implemented in Firefox. This initial change implements the following functionality: * Automatically handling renames of existing web features * Removing `web-feature` labels in the user story which don't correspond to a real web-feature. * Updating links to external resources on web-features bugs. * Reopening web-features bugs that are closed when the corresponding feature isn't marked as supported. * Closing bugs once the corresponding web-feature is marked as supported. --- bugbot/rules/web_platform_features.py | 673 +++++++++++++++++++--- templates/web_platform_features.html | 94 ++- tests/rules/__init__.py | 0 tests/rules/test_web_platform_features.py | 165 ++++++ 4 files changed, 845 insertions(+), 87 deletions(-) create mode 100644 tests/rules/__init__.py create mode 100644 tests/rules/test_web_platform_features.py diff --git a/bugbot/rules/web_platform_features.py b/bugbot/rules/web_platform_features.py index 0d34c1e2c..8997e424b 100644 --- a/bugbot/rules/web_platform_features.py +++ b/bugbot/rules/web_platform_features.py @@ -3,43 +3,596 @@ # You can obtain one at http://mozilla.org/MPL/2.0/. # mypy: disallow-untyped-defs - -from dataclasses import dataclass -from typing import Any, Iterable, Mapping, Optional +import re +from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Iterator +from dataclasses import dataclass, field +from enum import Enum, IntEnum +from typing import ( + Any, + Generic, + Iterable, + Mapping, + MutableMapping, + Optional, + Sequence, + TypeVar, +) from urllib import parse +from google.cloud import bigquery + from bugbot import gcp from bugbot.bzcleaner import Bug, BzCleaner +Json = None | str | int | float | Sequence["Json"] | Mapping[str, "Json"] + + +def parse_user_story( + user_story: str, +) -> Iterator[tuple[str, Optional[str], Optional[str]]]: + """Parse the user story assuming it's lines of the form key: value. + + If there isn't a colon in the line we simply set value to the full line.""" + user_story_re = re.compile(r"^\s*([^\s]+)\s*:\s*(.*)") + for line in user_story.splitlines(): + key = None + value = None + m = user_story_re.match(line) + if m is not None: + maybe_key, maybe_value = m.groups() + if maybe_value: + key = maybe_key + value = maybe_value + yield line, key, value + + +class UserStoryChangeType(IntEnum): + APPEND = 1 + REPLACE = 2 + DELETE = 3 + + +@dataclass(frozen=True) +class UserStoryChange: + field: str + type: UserStoryChangeType + old_value: Optional[str] = None + new_value: Optional[str] = None + + +class Resolution(Enum): + NONE = "" + FIXED = "FIXED" + DUPLICATE = "DUPLICATE" + @dataclass -class FeatureUrls: - feature: str - spec_url: Optional[list[str]] - feature_url: str - sp_url: Optional[str] +class AddRemoveChange: + add: list[str] = field(default_factory=list) + remove: list[str] = field(default_factory=list) + + def to_json(self) -> Optional[Json]: + if self.add is None and self.remove is None: + return None + rv = {} + if self.add: + rv["add"] = self.add + if self.remove: + rv["remove"] = self.remove + return rv + + def __bool__(self) -> bool: + return bool(self.add or self.remove) + + +@dataclass +class BugChanges: + keywords: Optional[AddRemoveChange] = None + see_also: Optional[AddRemoveChange] = None + whiteboard: Optional[str] = None + user_story: Optional[str] = None + status: Optional[str] = None + resolution: Optional[str] = None + comment: Optional[str] = None + + def __bool__(self) -> bool: + if any(add_remove_field for add_remove_field in [self.keywords, self.see_also]): + return True + return any( + string_field is not None + for string_field in [ + self.whiteboard, + self.user_story, + self.status, + self.resolution, + self.comment, + ] + ) + + def to_json(self) -> Json: + rv: dict[str, Json] = {} + for add_remove_field, name in [ + (self.keywords, "keywords"), + (self.see_also, "see_also"), + ]: + if add_remove_field is not None: + value = add_remove_field.to_json() + if value: + rv[name] = value + + for value, name in [ + (self.whiteboard, "whiteboard"), + (self.status, "status"), + (self.resolution, "resolution"), + (self.user_story, "cf_user_story"), + ]: + if value is not None: + rv[name] = value + + if self.comment is not None: + rv["comment"] = {"body": self.comment} + + return rv + + +@dataclass +class BugUpdate: + keywords: dict[str, bool] = field(default_factory=dict) + see_also: dict[str, bool] = field(default_factory=dict) + user_story: list[UserStoryChange] = field(default_factory=list) + comment: list[str] = field(default_factory=list) + comment_when_unchanged: bool = False + resolve: Optional[Resolution] = None + def update_keywords(self, current_keywords: set[str]) -> AddRemoveChange: + return AddRemoveChange( + add=[ + keyword + for keyword, add_keyword in self.keywords.items() + if add_keyword and keyword not in current_keywords + ], + remove=[ + keyword + for keyword, add_keyword in self.keywords.items() + if not add_keyword and keyword in current_keywords + ], + ) -def url_keys(urls: Iterable[str]) -> Mapping[tuple[str, str], str]: - rv = {} + def update_see_also( + self, current_url: str, current_see_also: list[str] + ) -> AddRemoveChange: + add = [] + remove = [] + has_links = [current_url] + current_see_also + has_link_keys = url_keys(has_links) + expected_link_keys = url_keys(self.see_also.keys()) + + for key, urls in expected_link_keys.items(): + for url in urls: + add_url = self.see_also[url] + if add_url and key not in has_link_keys: + add.append(url) + elif not add_url and key in has_link_keys: + remove.append(url) + + return AddRemoveChange(add=add, remove=remove) + + def update_user_story(self, user_story: str) -> Optional[str]: + new_user_story = [] + user_story_updates = defaultdict(list) + for change in self.user_story: + user_story_updates[change.field].append(change) + + has_updates = False + applied_changes = set() + + for line, key, value in parse_user_story(user_story): + if key is None or value is None: + new_user_story.append(line) + continue + + output_line: Optional[tuple[str, str]] = (key, value) + if key in user_story_updates: + changes = user_story_updates[key] + current_value = value.strip() + for change in changes: + if change in applied_changes: + continue + if current_value == change.old_value: + applied_changes.add(change) + if change.type == UserStoryChangeType.DELETE: + output_line = None + has_updates = True + elif change.type == UserStoryChangeType.REPLACE: + assert change.new_value is not None + output_line = (key, change.new_value) + has_updates = True + elif ( + change.type == UserStoryChangeType.APPEND + and current_value == change.new_value + ): + # If we are going to append a value that's already there + # do nothing + applied_changes.add(change) + if output_line is not None: + new_user_story.append(f"{output_line[0]}:{output_line[1]}") + + for changes in user_story_updates.values(): + for change in changes: + if change not in applied_changes: + if change.type == UserStoryChangeType.DELETE: + # Tried to delete a key that doesn't exist, do nothing + pass + elif change.type == UserStoryChangeType.REPLACE: + # Tried to replace a key that doesn't exist, do nothing + pass + elif change.type == UserStoryChangeType.APPEND: + new_user_story.append(f"{change.field}:{change.new_value}") + has_updates = True + + if has_updates: + return "\n".join(new_user_story) + + return None + + def into_changes(self, bug: Bug) -> BugChanges: + changes = BugChanges() + + if self.keywords: + changes.keywords = self.update_keywords(set(bug["keywords"])) + if self.see_also: + changes.see_also = self.update_see_also(bug["url"], bug["see_also"]) + if self.user_story: + changes.user_story = self.update_user_story(bug["cf_user_story"]) + + if self.resolve: + if bug["resolution"] != self.resolve.value: + changes.status = ( + "REOPENED" if self.resolve == Resolution.NONE else "RESOLVED" + ) + changes.resolution = self.resolve.value + + if self.comment and (changes or self.comment_when_unchanged): + changes.comment = "\n\n".join(self.comment) + + return changes + + +def url_keys(urls: Iterable[str]) -> Mapping[tuple[str, str], list[str]]: + """Group URLs by a key consisting of their hostname and path""" + rv: dict[tuple[str, str], list[str]] = {} for url in urls: try: parsed = parse.urlparse(url) if parsed.hostname is None: continue - rv[(parsed.hostname, parsed.path)] = url + key = (parsed.hostname, parsed.path) + if key not in rv: + rv[key] = [] + rv[key].append(url) except ValueError: pass return rv +@dataclass +class FeatureData: + feature: str + supported_browsers: set[str] + sp_issue: Optional[int] + spec_url: set[str] + + def is_supported(self) -> bool: + return {"firefox", "firefox_android"}.issubset(self.supported_browsers) + + +@dataclass +class FeatureBug: + """Bug that represents a web-feature""" + + resolution: str + keywords: list[str] + url: Optional[str] + whiteboard: str + see_also: set[str] + user_story: Mapping[str, str | list[str]] + features: Mapping[str, FeatureData] + + def is_supported(self) -> bool: + return all(feature.is_supported() for feature in self.features.values()) + + def expected_keywords(self) -> set[str]: + rv = set() + if "[platform-feature]" in self.whiteboard: + rv.add("web-feature") + if not self.is_supported(): + for feature in self.features.values(): + if {"chrome", "chrome_android"}.issubset(feature.supported_browsers): + rv.add("parity-chrome") + if {"safari", "safari_ios"}.issubset(feature.supported_browsers): + rv.add("parity-safari") + return rv + + def missing_keywords(self) -> set[str]: + return self.expected_keywords().difference(self.keywords) + + def expected_links(self) -> set[str]: + links = set() + for feature_name, feature in self.features.items(): + links.add( + f"https://web-platform-dx.github.io/web-features-explorer/features/{feature_name}/" + ) + if feature.sp_issue is not None: + links.add( + f"https://github.com/mozilla/standards-positions/issues/{feature.sp_issue}" + ) + links |= feature.spec_url + return links + + def missing_links(self) -> set[str]: + rv = set() + has_links = list(self.see_also) + if self.url: + has_links.append(self.url) + has_link_keys = url_keys(has_links) + expected_link_keys = url_keys(self.expected_links()) + + for key, urls in expected_link_keys.items(): + if key not in has_link_keys: + rv |= set(urls) + return rv + + def remove_links(self) -> set[str]: + rv = set() + for link in self.see_also: + if link.startswith( + "https://web-platform-dx.github.io/web-features-explorer/features/" + ) and not any( + link.startswith( + f"https://web-platform-dx.github.io/web-features-explorer/features/{feature_name}/" + ) + for feature_name in self.features.keys() + ): + rv.add(link) + return rv + + +_DataType = TypeVar("_DataType") + + +class UpdateRule(ABC, Generic[_DataType]): + """Rule for updating bugs based on BigQuery data""" + + def __init__(self, client: bigquery.Client): + self.client = client + + @abstractmethod + def get_data(self) -> _DataType: + ... + + @abstractmethod + def update(self, updates: MutableMapping[int, BugUpdate], data: _DataType) -> None: + ... + + def run(self, updates: MutableMapping[int, BugUpdate]) -> None: + data: _DataType = self.get_data() + self.update(updates, data) + + +class FeatureRenames(UpdateRule): + """Update web-feature marker for features that have been renamed""" + + def get_data(self) -> Mapping[int, list[tuple[str, str]]]: + rv = defaultdict(list) + query = """ + SELECT DISTINCT number, feature, redirect_target + FROM `web_features.features_moved` + JOIN `webcompat_knowledge_base.bugzilla_bugs` AS bugs + ON feature IN UNNEST(`webcompat_knowledge_base.EXTRACT_ARRAY`(bugs.user_story, "$.web-feature"))""" + + for row in self.client.query(query): + rv[row.number].append((row["feature"], row["redirect_target"])) + + return rv + + def update( + self, + updates: MutableMapping[int, BugUpdate], + data: Mapping[int, list[tuple[str, str]]], + ) -> None: + for bug_id, renames in data.items(): + for old_name, new_name in renames: + updates[bug_id].user_story.append( + UserStoryChange( + "web-feature", UserStoryChangeType.REPLACE, old_name, new_name + ) + ) + + +class InvalidFeatures(UpdateRule): + def get_data(self) -> Mapping[int, list[tuple[str, list[str]]]]: + rv = defaultdict(list) + query = """ + WITH + missing_features AS ( + SELECT number, bug_feature as bug_feature + FROM `webcompat_knowledge_base.bugzilla_bugs` AS bugs + JOIN UNNEST(`webcompat_knowledge_base.EXTRACT_ARRAY`(bugs.user_story, "$.web-feature")) AS bug_feature + LEFT JOIN `web_features.features_latest` AS features ON features.feature = bug_feature + WHERE features.feature IS NULL + ), + + suggestions AS ( + SELECT number, bug_feature, feature, EDIT_DISTANCE(feature, bug_feature) as distance + FROM missing_features + CROSS JOIN `web_features.features_latest` + ) + + SELECT number, bug_feature, ARRAY_AGG(STRUCT(feature as feature, distance) ORDER BY distance LIMIT 5) AS suggestions + FROM suggestions + WHERE distance < 5 + GROUP BY number, bug_feature + """ + for row in self.client.query(query): + rv[row.number].append( + ( + row.bug_feature, + [ + suggestion["feature"] + for suggestion in sorted( + row.suggestions, key=lambda x: x["distance"] + ) + ], + ) + ) + + return rv + + def update( + self, + updates: MutableMapping[int, BugUpdate], + data: Mapping[int, list[tuple[str, list[str]]]], + ) -> None: + for bug_id, invalid_names in data.items(): + for invalid_name, suggestions in invalid_names: + updates[bug_id].user_story.append( + UserStoryChange( + "web-feature", UserStoryChangeType.DELETE, invalid_name + ) + ) + options_links = [ + f"[{suggestion}](https://web-platform-dx.github.io/web-features-explorer/features/{suggestion})" + for suggestion in suggestions + ] + # TODO: Consider adding a needinfo on someone (reporter? user that added this?) + comment = f"{invalid_name} is not a valid web-feature id." + if options_links: + comment += f" Consider one of the following possible ids: {', '.join(options_links)}." + updates[bug_id].comment.append(comment) + + +class UpdateMetadata(UpdateRule): + """Update existing web-feature bugs to ensure they have the correct metadata and status""" + + def get_data(self) -> Mapping[int, FeatureBug]: + rv: dict[int, FeatureBug] = {} + query = """ +WITH +feature_bugs AS ( + SELECT + number, + ARRAY_AGG(STRUCT( + feature, + web_features.spec as spec_url, + (SELECT ARRAY_AGG(browser) FROM UNNEST(web_features.support)) AS supported_browsers, + sp_mozilla.issue as sp_issue + ) + ) as features, + LOGICAL_OR( + bugs.resolution = "FIXED" AND + ("firefox" NOT in UNNEST(web_features.support.browser) OR + "firefox_android" NOT IN UNNEST(web_features.support.browser)) + ) as unsupported_closed_bug + FROM `webcompat_knowledge_base.bugzilla_bugs` AS bugs + JOIN `web_features.features_latest` AS web_features + ON web_features.feature IN UNNEST(`webcompat_knowledge_base.EXTRACT_ARRAY`(bugs.user_story, "$.web-feature")) + LEFT JOIN `standards_positions.mozilla_standards_positions` AS sp_mozilla + ON (`webcompat_knowledge_base.BUG_ID_FROM_BUGZILLA_URL`(sp_mozilla.bug) = bugs.number OR sp_mozilla.web_feature = feature) + GROUP BY number +) + +SELECT + number, + resolution, + url, + keywords, + whiteboard, + see_also, + user_story, + features +FROM feature_bugs +JOIN `webcompat_knowledge_base.bugzilla_bugs` AS bugs USING(number) +WHERE + ("web-feature" in UNNEST(keywords) OR whiteboard LIKE "%[platform-feature]%") AND + (resolution = "" OR unsupported_closed_bug) +""" + + for row in self.client.query(query): + rv[row.number] = FeatureBug( + resolution=row.resolution, + url=row.url, + keywords=row.keywords, + whiteboard=row.whiteboard, + see_also=set(row.see_also), + user_story=row.user_story, + features={ + feature["feature"]: FeatureData( + feature=feature["feature"], + spec_url=set(feature["spec_url"]), + supported_browsers=set(feature["supported_browsers"]), + sp_issue=feature["sp_issue"], + ) + for feature in row.features + }, + ) + + return rv + + def update( + self, updates: MutableMapping[int, BugUpdate], data: Mapping[int, FeatureBug] + ) -> None: + for bug_id, feature_bug in data.items(): + # Add any missing keywords + # TODO: are there keywords we should remove too if they're invalid + for keyword in feature_bug.missing_keywords(): + updates[bug_id].keywords[keyword] = True + for link in feature_bug.missing_links(): + updates[bug_id].see_also[link] = True + for link in feature_bug.remove_links(): + updates[bug_id].see_also[link] = False + + # Close bugs where the BCD status is fixed + if ( + feature_bug.resolution == "" + and "leave-open" not in feature_bug.keywords + and feature_bug.is_supported() + ): + updates[bug_id].resolve = Resolution.FIXED + + # Reopen bugs where the BCD status is not fixed + unsupported_features = [ + feature + for feature in feature_bug.features.values() + if not feature.is_supported() + ] + if feature_bug.resolution == "FIXED" and unsupported_features: + updates[bug_id].resolve = Resolution.NONE + feature_list = ", ".join( + f"{feature_name} ([definition file](https://github.com/web-platform-dx/web-features/blob/main/features/{feature_name}.yml.dist))" + for feature_name in unsupported_features + ) + text = ( + f"web-features {feature_list} are" + if len(unsupported_features) > 1 + else f"web-feature {feature_list} is" + ) + updates[bug_id].comment.append( + f"""Bug was resolved, but the {text} not yet marked as supported in Firefox. + +Feature bugs are usually automatically closed once the corresponding web-features are marked as supported; this typically happens after the feature reaches release. +""" + ) + + class WebPlatformFeatures(BzCleaner): def __init__(self) -> None: super().__init__() - self.feature_bugs: Mapping[int, FeatureUrls] = {} + self.bug_updates: dict[int, BugUpdate] = defaultdict(BugUpdate) def description(self) -> str: - return "Update See Also for web-features bugs" + return "Update web-features bugs" def filter_no_nag_keyword(self) -> bool: return False @@ -48,80 +601,50 @@ def has_default_products(self) -> bool: return False def columns(self) -> list[str]: - return ["id", "summary", "added"] + return ["id", "summary", "changes", "whiteboard", "user_story"] def handle_bug(self, bug: Bug, data: dict[str, Any]) -> Optional[Bug]: - features_key = bug["id"] - bug_id = str(bug["id"]) - - changes = {} - if bug_id not in data: - data[bug_id] = {} - data[bug_id]["added"] = [] - if features_key in self.feature_bugs: - existing_keys = url_keys(bug["see_also"] + [bug["url"]]) - - feature_urls = self.feature_bugs[features_key] - expected_urls = [feature_urls.feature_url] - if feature_urls.sp_url is not None: - expected_urls.append(feature_urls.sp_url) - if feature_urls.spec_url is not None: - expected_urls.extend(feature_urls.spec_url) - expected_keys = url_keys(expected_urls) - add_urls = [ - url for key, url in expected_keys.items() if key not in existing_keys - ] - if add_urls: - changes["see_also"] = {"add": add_urls} - data[bug_id]["added"] = add_urls + bug_id_str = str(bug["id"]) + bug_id_int = int(bug["id"]) + + if bug_id_int not in self.bug_updates: + return None + changes = self.bug_updates[bug_id_int].into_changes(bug) if changes: - self.autofix_changes[bug_id] = changes + self.autofix_changes[bug_id_str] = changes.to_json() + data[bug_id_str] = { + "changes": changes, + "whiteboard": bug["whiteboard"], + "user_story": bug["cf_user_story"], + } return bug return None def get_bz_params(self, date: str) -> dict[str, str | int | list[str] | list[int]]: - fields = ["id", "url", "see_also"] - self.feature_bugs = self.get_feature_bugs() - return {"include_fields": fields, "id": list(self.feature_bugs.keys())} + fields = [ + "id", + "url", + "see_also", + "keywords", + "whiteboard", + "cf_user_story", + "status", + "resolution", + ] + self.get_bug_updates() + return {"include_fields": fields, "id": list(self.bug_updates.keys())} - def get_feature_bugs(self) -> Mapping[int, FeatureUrls]: + def get_bug_updates(self) -> None: project = "moz-fx-dev-dschubert-wckb" - client = gcp.get_bigquery_client(project, ["cloud-platform", "drive"]) - query = f""" -WITH feature_bugs as ( - SELECT - bugs.number, - feature, - bugs.see_also, - web_features.spec as spec_url, - concat("https://web-platform-dx.github.io/web-features-explorer/features/", feature, "/") as feature_url, - concat("https://github.com/mozilla/standards-positions/issues/", sp_mozilla.issue) as sp_url - FROM `{project}.webcompat_knowledge_base.bugzilla_bugs` AS bugs - JOIN `{project}.web_features.features_latest` AS web_features - ON web_features.feature IN UNNEST(`{project}.webcompat_knowledge_base.EXTRACT_ARRAY`(bugs.user_story, "$.web-feature")) - LEFT JOIN `{project}.standards_positions.mozilla_standards_positions` AS sp_mozilla - ON `{project}.webcompat_knowledge_base.BUG_ID_FROM_BUGZILLA_URL`(sp_mozilla.bug) = bugs.number -) - -SELECT number, feature, spec_url, feature_url, sp_url FROM feature_bugs -WHERE - NOT EXISTS(SELECT 1 FROM feature_bugs.spec_url WHERE spec_url NOT IN UNNEST(see_also)) - OR feature_url NOT IN UNNEST(see_also) - OR sp_url NOT IN UNNEST(see_also) -""" - - return { - row["number"]: FeatureUrls( - feature=row["feature"], - spec_url=row["spec_url"], - feature_url=row["feature_url"], - sp_url=row["sp_url"], - ) - for row in client.query(query).result() - } + for update_rule in [ + FeatureRenames(client), + InvalidFeatures(client), + UpdateMetadata(client), + ]: + update_rule.run(self.bug_updates) if __name__ == "__main__": diff --git a/templates/web_platform_features.html b/templates/web_platform_features.html index 138ee9499..0b3d46264 100644 --- a/templates/web_platform_features.html +++ b/templates/web_platform_features.html @@ -1,32 +1,102 @@

- The following {{ plural('bug is', data, pword='bugs are') }} are marked as for specific web-features, but were missing relevant see-also links: + The following {{ plural('bug is', data, pword='bugs are') }} are marked as for specific web-features, and have been updated to match the feature status:

- + - {% for i, (bugid, summary, added) in enumerate(data) -%} + {% for i, (bugid, summary, changes, whiteboard, user_story) in enumerate(data) -%} + - - {% endfor -%} - + {% endif -%} + {% endif -%} + {% if changes.whiteboard != None %} +
  • +

    + Updated whiteboard from {{ whiteboard }} to {{ changes.whiteboard }} +

    +
  • + {% endif -%} + {% if changes.user_story != None %} +
  • +

    Updated user story from:

    +
    {{ user_story }}
    +

    to:

    +
    {{ changes.user_story }}
    +
  • + {% endif -%} + {% if changes.status != None %} +
  • +

    Set status to {{ changes.status }}

    +
  • + {% endif -%} + {% if changes.resolution != None %} +
  • +

    Set resolution to {{ changes.resolution }}

    +
  • + {% endif -%} + + + +{% endfor -%} +
    BugLinks added SummaryChanges
    {{ bugid }} {{ summary | e }}
      - {% for link in added -%} -
    • - {{ link | e }} + {% if changes.keywords != None %} + {% if changes.keywords.add %} +
    • +

      + Added keywords + {% for item in changes.keywords.add %} + {{ item }} + {% if not loop.last %},{% endif %} + {% endfor %} +

      +
    • + {% endif -%} + {% if changes.keywords.remove %} +
    • +

      + Removed keywords + {% for item in changes.keywords.remove %} + {{ item }} + {% if not loop.last %},{% endif %} + {% endfor %} +

      +
    • + {% endif -%} + {% endif -%} + {% if changes.see_also != None %} + {% if changes.see_also.add %} +
    • +

      + Added see_also + {% for item in changes.see_also.add %} + {{ item }} + {% if not loop.last %},{% endif %} + {% endfor %} +

      +
    • + {% endif -%} + {% if changes.see_also.remove %} +
    • +

      + Removed see_also + {% for item in changes.see_also.remove %} + {{ item }} + {% if not loop.last %},{% endif %} + {% endfor %} +

      +

    • - {% endfor -%} -
    -
    {{ summary | e }}
    diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/rules/test_web_platform_features.py b/tests/rules/test_web_platform_features.py new file mode 100644 index 000000000..dfa7f2313 --- /dev/null +++ b/tests/rules/test_web_platform_features.py @@ -0,0 +1,165 @@ +from bugbot.rules.web_platform_features import ( + AddRemoveChange, + BugChanges, + BugUpdate, + Resolution, + UserStoryChange, + UserStoryChangeType, + WebPlatformFeatures, + parse_user_story, + url_keys, +) + + +def test_parse_user_story(): + user_story = """ +foo:bar +abcde: + foo : baz +some long line: with a colon +key-:value +""" + assert list(parse_user_story(user_story)) == [ + ("", None, None), + ("foo:bar", "foo", "bar"), + ("abcde:", None, None), + (" foo : baz", "foo", "baz"), + ("some long line: with a colon", None, None), + ("key-:value", "key-", "value"), + ] + + +def test_url_keys(): + urls = ["https://example.org/foo/?test#bar"] + assert url_keys(urls) == {("example.org", "/foo/"): urls} + + urls = ["https://example.org/foo/?test#bar", "https://example.org/foo/"] + assert url_keys(urls) == {("example.org", "/foo/"): urls} + + urls = ["https://example.org/foo/", "https://example.org/bar/"] + assert url_keys(urls) == { + ("example.org", "/foo/"): urls[:1], + ("example.org", "/bar/"): urls[1:], + } + + +def test_bugchanges_to_json(): + assert BugChanges(keywords=AddRemoveChange(add=["test"])).to_json() == { + "keywords": {"add": ["test"]} + } + assert BugChanges(keywords=AddRemoveChange(remove=["test"])).to_json() == { + "keywords": {"remove": ["test"]} + } + assert BugChanges(see_also=AddRemoveChange(remove=["test"])).to_json() == { + "see_also": {"remove": ["test"]} + } + assert BugChanges(whiteboard="test").to_json() == {"whiteboard": "test"} + assert BugChanges(user_story="test").to_json() == {"cf_user_story": "test"} + assert BugChanges(status="test").to_json() == {"status": "test"} + assert BugChanges(resolution="test").to_json() == {"resolution": "test"} + assert BugChanges(comment="test").to_json() == {"comment": {"body": "test"}} + + +def test_bugupdate_into_changes(): + assert BugUpdate( + keywords={ + "test_add": True, + "test_add_present": True, + "test_remove": False, + "test_remove_missing": False, + } + ).into_changes( + {"keywords": ["existing", "test_remove", "test_add_present"]} + ) == BugChanges(keywords=AddRemoveChange(add=["test_add"], remove=["test_remove"])) + assert BugUpdate( + see_also={ + "https://example.org/add/": True, + "https://example.org/add_present/?query#hash": True, + "https://example.org/remove/": False, + "https://example.org/remove_missing/": False, + } + ).into_changes( + { + "url": "https://example.org/url/", + "see_also": [ + "https://example.org/add_present/", + "https://example.org/remove/", + ], + } + ) == BugChanges( + see_also=AddRemoveChange( + add=["https://example.org/add/"], remove=["https://example.org/remove/"] + ) + ) + assert BugUpdate( + user_story=[ + UserStoryChange( + "test-1", UserStoryChangeType.REPLACE, "test-1_old", "test-1_new" + ), + UserStoryChange("test-2", UserStoryChangeType.APPEND, None, "test-2_old"), + UserStoryChange("test-2", UserStoryChangeType.APPEND, None, "test-2_new"), + UserStoryChange("test-3", UserStoryChangeType.DELETE, "test-3_old", None), + UserStoryChange( + "test-4", + UserStoryChangeType.REPLACE, + "test-4_old-missing", + "test-4_new", + ), + ] + ).into_changes( + { + "cf_user_story": """ +test-1:test-1_old +test-2:test-2_old +test-3:test-3_old +test-3:test-3_old-unremoved +test-4:test-4_old +""" + } + ) == BugChanges( + user_story=""" +test-1:test-1_new +test-2:test-2_old +test-3:test-3_old-unremoved +test-4:test-4_old +test-2:test-2_new""" + ) + + assert BugUpdate(comment=["test", "test1"]).into_changes({}) == BugChanges() + assert BugUpdate( + comment=["test", "test1"], comment_when_unchanged=True + ).into_changes({}) == BugChanges(comment="test\n\ntest1") + + assert BugUpdate(resolve=Resolution.FIXED).into_changes( + {"resolution": "", "status": "NEW"} + ) == BugChanges(resolution="FIXED", status="RESOLVED") + assert BugUpdate(resolve=Resolution.NONE).into_changes( + {"resolution": "RESOLVED", "status": "FIXED"} + ) == BugChanges(resolution="", status="REOPENED") + + +def test_handlebug(): + cleaner = WebPlatformFeatures() + cleaner.bug_updates = {1234: BugUpdate(keywords={"add-keyword": True})} + data = {} + input_bug = { + "id": "1234", + "url": "https://example.org", + "see_also": [], + "keywords": ["web-feature"], + "whiteboard": "", + "cf_user_story": "web-feature: test", + "status": "NEW", + "resolution": "", + } + output_bug = cleaner.handle_bug( + input_bug.copy(), + data, + ) + assert data["1234"] == { + "changes": BugChanges(keywords=AddRemoveChange(add=["add-keyword"])), + "whiteboard": "", + "user_story": "web-feature: test", + } + assert output_bug == input_bug + assert cleaner.autofix_changes == {"1234": {"keywords": {"add": ["add-keyword"]}}}