Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 109 additions & 4 deletions bugbot/rules/web_platform_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -595,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__()
Expand Down Expand Up @@ -652,6 +756,7 @@ def get_bug_updates(self) -> None:
FeatureRenames(client),
InvalidFeatures(client),
UpdateMetadata(client),
UpdateInterop(client),
]:
update_rule.run(self.bug_updates)

Expand Down
151 changes: 151 additions & 0 deletions tests/test_web_platform_feature.py
Original file line number Diff line number Diff line change
@@ -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,
}