From af564d6392c1178ad4b3c494f53899d59bdf2497 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 9 Sep 2026 13:16:18 +0100 Subject: [PATCH 1/2] Fix formatting --- bugbot/rules/web_platform_features.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bugbot/rules/web_platform_features.py b/bugbot/rules/web_platform_features.py index 36de880c2..c8965da8f 100644 --- a/bugbot/rules/web_platform_features.py +++ b/bugbot/rules/web_platform_features.py @@ -373,14 +373,12 @@ def __init__(self, client: bigquery.Client): self.client = client @abstractmethod - def get_data(self) -> _DataType: - ... + def get_data(self) -> _DataType: ... @abstractmethod def update( self, updates: MutableMapping[int, FeatureBugUpdate], data: _DataType - ) -> None: - ... + ) -> None: ... def run(self, updates: MutableMapping[int, FeatureBugUpdate]) -> None: data: _DataType = self.get_data() From ba0818860801b2ccf4637782bc41a5b78d8e3134 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 9 Sep 2026 13:14:47 +0100 Subject: [PATCH 2/2] Add a rule to add metadata to bugs related to Interop proposals For these bugs add a link to the proposal issue and a user story entry of the form `interop-proposal:`. --- bugbot/rules/web_platform_features.py | 107 ++++++++++++++++++ tests/test_web_platform_feature.py | 151 ++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 tests/test_web_platform_feature.py diff --git a/bugbot/rules/web_platform_features.py b/bugbot/rules/web_platform_features.py index c8965da8f..53c68aaa7 100644 --- a/bugbot/rules/web_platform_features.py +++ b/bugbot/rules/web_platform_features.py @@ -593,6 +593,112 @@ def update( ) +@dataclass +class InteropBug: + user_story: dict[str, str | list[str]] + interop_issue: int + interop_year: int + + +class UpdateInterop(UpdateRule): + """Update bugs which have a corresponding Interop project proposal.""" + + def get_data(self) -> Mapping[int, list[InteropBug]]: + rv: dict[int, list[InteropBug]] = {} + + query = """ +WITH + +proposal_bugs AS ( + SELECT number, issue, year + FROM `moz-fx-dev-dschubert-wckb.interop.interop_bugs` AS interop_bugs + JOIN UNNEST(bugs) as number + JOIN `moz-fx-dev-dschubert-wckb.interop.interop_proposals` USING(issue) + WHERE interop_bugs.state = "open" +) + +SELECT + number, + user_story, + issue as interop_issue, + year as interop_year, +FROM proposal_bugs +JOIN `webcompat_knowledge_base.bugzilla_bugs` AS bugs USING(number) +WHERE bugs.resolution != "DUPLICATE" +""" + for row in self.client.query(query): + if row.number not in rv: + rv[row.number] = [] + rv[row.number].append( + InteropBug( + user_story=row.user_story, + interop_issue=row.interop_issue, + interop_year=row.interop_year, + ) + ) + + return rv + + def update( + self, + updates: MutableMapping[int, FeatureBugUpdate], + data: Mapping[int, list[InteropBug]], + ) -> None: + for bug_id, interop_bugs in data.items(): + years = set() + for interop_bug in interop_bugs: + interop_link = f"https://github.com/web-platform-tests/interop/issues/{interop_bug.interop_issue}" + updates[bug_id].see_also[interop_link] = True + + years.add(str(interop_bug.interop_year)) + user_story_change = self.user_story_change( + interop_bugs[0].user_story, years + ) + if user_story_change is not None: + updates[bug_id].user_story.append(user_story_change) + + def user_story_change( + self, user_story: Mapping[str, str | list[str]], years: set[str] + ) -> Optional[UserStoryChange]: + current_value = user_story.get("interop-proposal") + if current_value is not None: + if not isinstance(current_value, list): + current_value = [current_value] + + all_current = set() + target = None + for raw_value in current_value: + value = [item.strip() for item in raw_value.split(",")] + all_valid = True + for maybe_year in value: + is_year = re.match(r"\d{4}$", maybe_year) + if is_year: + all_current.add(maybe_year) + else: + all_valid = False + if all_valid and target is None: + target = (raw_value, value) + + if target is not None: + missing = years - all_current + if not missing: + return None + + return UserStoryChange( + "interop-proposal", + UserStoryChangeType.REPLACE, + target[0].strip(), + ",".join(target[1] + sorted(missing)), + ) + + return UserStoryChange( + "interop-proposal", + UserStoryChangeType.APPEND, + None, + ",".join(sorted(years)), + ) + + class WebPlatformFeatures(BzCleaner): def __init__(self) -> None: super().__init__() @@ -650,6 +756,7 @@ def get_bug_updates(self) -> None: FeatureRenames(client), InvalidFeatures(client), UpdateMetadata(client), + UpdateInterop(client), ]: update_rule.run(self.bug_updates) diff --git a/tests/test_web_platform_feature.py b/tests/test_web_platform_feature.py new file mode 100644 index 000000000..7d45066f8 --- /dev/null +++ b/tests/test_web_platform_feature.py @@ -0,0 +1,151 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +from collections import defaultdict + +import pytest + +from bugbot.rules.web_platform_features import ( + FeatureBugUpdate, + InteropBug, + UpdateInterop, +) + +INTEROP_URL = "https://github.com/web-platform-tests/interop/issues/{}" + + +def run_interop(proposals, user_story=None, updates=None): + """Run UpdateInterop.update() for a single bug. + + proposals is {interop issue: year}, user_story the parsed user story.""" + rule = UpdateInterop.__new__(UpdateInterop) + if updates is None: + updates = defaultdict(FeatureBugUpdate) + rule.update( + updates, + { + 1: [ + InteropBug(user_story if user_story is not None else {}, issue, year) + for issue, year in proposals.items() + ] + }, + ) + return updates[1] + + +@pytest.mark.parametrize( + "existing, user_story, proposals, expected", + [ + # No entry yet + ("web-feature:foo", {}, {11: 2026}, "web-feature:foo\ninterop-proposal:2026"), + # Already recorded, nothing to do + ("interop-proposal:2026", {"interop-proposal": "2026"}, {11: 2026}, None), + # Add a year to an existing entry + ( + "interop-proposal:2024", + {"interop-proposal": "2024"}, + {11: 2026}, + "interop-proposal:2024,2026", + ), + # Existing entry already lists several years + ( + "interop-proposal:2024,2025", + {"interop-proposal": "2024,2025"}, + {11: 2026}, + "interop-proposal:2024,2025,2026", + ), + # Whitespace around the stored value must still match + ( + "interop-proposal: 2025", + {"interop-proposal": " 2025 "}, + {11: 2026}, + "interop-proposal:2025,2026", + ), + # Several proposals, no entry yet: one merged entry, not one per proposal + ( + "web-feature:foo", + {}, + {11: 2025, 22: 2026}, + "web-feature:foo\ninterop-proposal:2025,2026", + ), + # Several proposals merged into an existing entry + ( + "interop-proposal:2024", + {"interop-proposal": "2024"}, + {11: 2025, 22: 2026}, + "interop-proposal:2024,2025,2026", + ), + # Several proposals, one of them already recorded + ( + "interop-proposal:2025", + {"interop-proposal": "2025"}, + {11: 2025, 22: 2026}, + "interop-proposal:2025,2026", + ), + ], +) +def test_interop_user_story(existing, user_story, proposals, expected): + update = run_interop(proposals, user_story) + assert update.update_user_story(existing) == expected + if expected is None: + assert update.user_story == [] + + +def test_interop_user_story_idempotent(): + """A second pass over the rule's own output must be a no-op.""" + first = run_interop({11: 2025, 22: 2026}).update_user_story("web-feature:foo") + assert first == "web-feature:foo\ninterop-proposal:2025,2026" + + second = run_interop({11: 2025, 22: 2026}, {"interop-proposal": "2025,2026"}) + assert second.user_story == [] + assert second.update_user_story(first) is None + + +def test_interop_user_story_non_year_value(): + """A non-year value is left alone rather than merged into.""" + update = run_interop({11: 2026}, {"interop-proposal": "accepted"}) + assert ( + update.update_user_story("interop-proposal:accepted") + == "interop-proposal:accepted\ninterop-proposal:2026" + ) + + +def test_interop_user_story_multiple_entries(): + """With several entries the year list is the one that gets updated.""" + update = run_interop({11: 2026}, {"interop-proposal": ["accepted", "2024"]}) + assert ( + update.update_user_story("interop-proposal:accepted\ninterop-proposal:2024") + == "interop-proposal:accepted\ninterop-proposal:2024,2026" + ) + + +def test_interop_see_also(): + update = run_interop({11: 2025, 22: 2026}) + assert update.see_also == { + INTEROP_URL.format(11): True, + INTEROP_URL.format(22): True, + } + # Links already on the bug are not added again, whether in see_also or url + assert not update.update_see_also( + "", [INTEROP_URL.format(11), INTEROP_URL.format(22)] + ) + assert not update.update_see_also(INTEROP_URL.format(11), [INTEROP_URL.format(22)]) + assert update.update_see_also("", [INTEROP_URL.format(11)]).to_json() == { + "add": [INTEROP_URL.format(22)] + } + + +def test_interop_preserves_see_also_from_other_rules(): + """UpdateInterop runs after UpdateMetadata on a shared FeatureBugUpdate.""" + updates = defaultdict(FeatureBugUpdate) + updates[1].see_also["https://example.com/keep/"] = True + updates[1].see_also["https://example.com/drop/"] = False + + update = run_interop({11: 2026}, updates=updates) + + assert update.see_also == { + "https://example.com/keep/": True, + "https://example.com/drop/": False, + INTEROP_URL.format(11): True, + }