Skip to content

Commit 007aff9

Browse files
committed
Support creating new bugs for web-features
Initially create new bugs only for web-features which are: * Supported in both Chrome and Safari * Not supported in Firefox In practice there shouldn't be any of these, although there are a couple of false positives (features that are incorrectly marked as unsupported in Firefox) that make it a useful test case. The plan is to extend this to file bugs more cases in the future.
1 parent 0f70140 commit 007aff9

3 files changed

Lines changed: 410 additions & 93 deletions

File tree

bugbot/rules/web_platform_features.py

Lines changed: 236 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,21 @@
1313
Any,
1414
Generic,
1515
Iterable,
16+
Literal,
1617
Mapping,
1718
MutableMapping,
1819
Optional,
1920
Sequence,
2021
TypeVar,
22+
cast,
2123
)
2224
from urllib import parse
2325

2426
from google.cloud import bigquery
27+
from libmozdata.bugzilla import Bugzilla
28+
from requests.exceptions import HTTPError
2529

26-
from bugbot import gcp
30+
from bugbot import gcp, logger, spec_mapping, utils
2731
from bugbot.bzcleaner import Bug, BzCleaner
2832

2933
Json = None | str | int | float | Sequence["Json"] | Mapping[str, "Json"]
@@ -87,6 +91,43 @@ def __bool__(self) -> bool:
8791
return bool(self.add or self.remove)
8892

8993

94+
@dataclass
95+
class BugzillaNewBug:
96+
"""Representation of a new bug to be created"""
97+
98+
summary: str
99+
product: str
100+
component: str
101+
description: str
102+
type: Literal["defect"] | Literal["enhancement"] | Literal["task"]
103+
version: str = "unspecified"
104+
keywords: Optional[list[str]] = None
105+
whiteboard: Optional[str] = None
106+
see_also: Optional[list[str]] = None
107+
user_story: Optional[str] = None
108+
url: Optional[str] = None
109+
110+
def to_json(self) -> Mapping[str, Json]:
111+
rv: dict[str, Json] = {
112+
"summary": self.summary,
113+
"product": self.product,
114+
"component": self.component,
115+
"description": self.description,
116+
"version": self.version,
117+
"type": self.type,
118+
}
119+
for value, name in [
120+
(self.whiteboard, "status_whiteboard"),
121+
(self.keywords, "keywords"),
122+
(self.see_also, "see_also"),
123+
(self.user_story, "cf_user_story"),
124+
(self.url, "url"),
125+
]:
126+
if value is not None:
127+
rv[name] = value
128+
return rv
129+
130+
90131
@dataclass
91132
class BugzillaUpdate:
92133
"""Representation of bug changes for use with the Bugzilla ReST API"""
@@ -287,11 +328,36 @@ class FeatureData:
287328
supported_browsers: set[str]
288329
sp_issue: Optional[int]
289330
spec_url: set[str]
331+
name: Optional[str] = None
332+
description: Optional[str] = None
290333

291334
def is_supported(self) -> bool:
292335
return {"firefox", "firefox_android"}.issubset(self.supported_browsers)
293336

294337

338+
def feature_keywords(feature: FeatureData) -> set[str]:
339+
rv = set()
340+
if {"chrome", "chrome_android"}.issubset(feature.supported_browsers):
341+
rv.add("parity-chrome")
342+
if {"safari", "safari_ios"}.issubset(feature.supported_browsers):
343+
rv.add("parity-safari")
344+
return rv
345+
346+
347+
def feature_links(feature: FeatureData) -> set[str]:
348+
links = set(
349+
[
350+
f"https://web-platform-dx.github.io/web-features-explorer/features/{feature.feature}/"
351+
]
352+
)
353+
if feature.sp_issue is not None:
354+
links.add(
355+
f"https://github.com/mozilla/standards-positions/issues/{feature.sp_issue}"
356+
)
357+
links |= feature.spec_url
358+
return links
359+
360+
295361
@dataclass
296362
class FeatureBug:
297363
"""Bug that represents a web-feature"""
@@ -313,26 +379,16 @@ def expected_keywords(self) -> set[str]:
313379
rv.add("web-feature")
314380
if not self.is_supported():
315381
for feature in self.features.values():
316-
if {"chrome", "chrome_android"}.issubset(feature.supported_browsers):
317-
rv.add("parity-chrome")
318-
if {"safari", "safari_ios"}.issubset(feature.supported_browsers):
319-
rv.add("parity-safari")
382+
rv |= feature_keywords(feature)
320383
return rv
321384

322385
def missing_keywords(self) -> set[str]:
323386
return self.expected_keywords().difference(self.keywords)
324387

325388
def expected_links(self) -> set[str]:
326389
links = set()
327-
for feature_name, feature in self.features.items():
328-
links.add(
329-
f"https://web-platform-dx.github.io/web-features-explorer/features/{feature_name}/"
330-
)
331-
if feature.sp_issue is not None:
332-
links.add(
333-
f"https://github.com/mozilla/standards-positions/issues/{feature.sp_issue}"
334-
)
335-
links |= feature.spec_url
390+
for feature in self.features.values():
391+
links |= feature_links(feature)
336392
return links
337393

338394
def missing_links(self) -> set[str]:
@@ -366,6 +422,79 @@ def remove_links(self) -> set[str]:
366422
_DataType = TypeVar("_DataType")
367423

368424

425+
class CreateRule(ABC, Generic[_DataType]):
426+
"""Rule for creating new bugs based on BigQuery data"""
427+
428+
def __init__(self, client: bigquery.Client):
429+
self.client = client
430+
431+
@abstractmethod
432+
def get_data(self) -> _DataType:
433+
...
434+
435+
@abstractmethod
436+
def create(self, data: _DataType) -> Mapping[str, BugzillaNewBug]:
437+
...
438+
439+
def run(self) -> Mapping[str, BugzillaNewBug]:
440+
data: _DataType = self.get_data()
441+
return self.create(data)
442+
443+
444+
class FirefoxOnlyMissing(CreateRule):
445+
def get_data(self) -> list[FeatureData]:
446+
query = """
447+
SELECT
448+
features.feature,
449+
features.name,
450+
features.description,
451+
(SELECT ARRAY_AGG(browser) FROM UNNEST(features.support)) AS supported_browsers,
452+
features.spec as spec_url,
453+
sp_mozilla.issue as sp_issue
454+
FROM `web_features.features_latest` AS features
455+
LEFT JOIN `webcompat_knowledge_base.bugzilla_bugs` AS bugs ON
456+
features.feature IN UNNEST(`webcompat_knowledge_base.EXTRACT_ARRAY`(bugs.user_story, "$.web-feature"))
457+
LEFT JOIN `standards_positions.mozilla_standards_positions` AS sp_mozilla ON
458+
sp_mozilla.web_feature = features.feature
459+
WHERE
460+
"safari" in UNNEST(features.support.browser) AND
461+
"chrome" IN UNNEST(features.support.browser) AND
462+
"firefox" NOT IN UNNEST(features.support.browser) AND
463+
bugs.number IS NULL
464+
"""
465+
return [
466+
FeatureData(
467+
feature=row.feature,
468+
spec_url=set(row.spec_url),
469+
supported_browsers=set(row.supported_browsers),
470+
sp_issue=row.sp_issue,
471+
name=row.name,
472+
description=row.description,
473+
)
474+
for row in self.client.query(query)
475+
]
476+
477+
def create(self, data: list[FeatureData]) -> Mapping[str, BugzillaNewBug]:
478+
rv = {}
479+
spec_mapper = spec_mapping.SpecMapper.load()
480+
for feature in data:
481+
product, component = spec_mapper.map_urls(feature.spec_url)
482+
spec_url = feature.spec_url.pop()
483+
rv[feature.feature] = BugzillaNewBug(
484+
summary=f"[meta] Implement {feature.name}",
485+
product=product,
486+
component=component,
487+
description=f"Implement {feature.name}:\n{feature.description}",
488+
type="enhancement",
489+
keywords=["web-feature"] + list(feature_keywords(feature)),
490+
user_story=f"web-feature: {feature.feature}",
491+
url=spec_url,
492+
see_also=[item for item in feature_links(feature) if item != spec_url],
493+
)
494+
495+
return rv
496+
497+
369498
class UpdateRule(ABC, Generic[_DataType]):
370499
"""Rule for updating bugs based on BigQuery data"""
371500

@@ -598,6 +727,8 @@ def update(
598727
class WebPlatformFeatures(BzCleaner):
599728
def __init__(self) -> None:
600729
super().__init__()
730+
self.create_bugs: dict[str, BugzillaNewBug] = {}
731+
self.bugs_created: dict[int, BugzillaNewBug] = {}
601732
self.bug_updates: dict[int, FeatureBugUpdate] = defaultdict(FeatureBugUpdate)
602733

603734
def description(self) -> str:
@@ -610,24 +741,106 @@ def has_default_products(self) -> bool:
610741
return False
611742

612743
def columns(self) -> list[str]:
613-
return ["id", "summary", "changes", "whiteboard", "user_story"]
744+
return ["id", "summary", "change_type", "changes", "whiteboard", "user_story"]
745+
746+
def get_bugs(
747+
self,
748+
date: str = "today",
749+
bug_ids: list[int] = [],
750+
chunk_size: Optional[int] = None,
751+
) -> dict[str, Mapping[str, Any]]:
752+
bugs = super().get_bugs(date, bug_ids, chunk_size)
753+
754+
if self.create_bugs:
755+
bugs_for_features = self.get_feature_bugs(set(self.create_bugs.keys()))
756+
else:
757+
bugs_for_features = {}
758+
759+
for i, (feature, bug) in enumerate(self.create_bugs.items()):
760+
if feature in bugs_for_features:
761+
# A bug was already created for this feature
762+
continue
763+
764+
if self.dryrun or self.test_mode:
765+
response = {"id": i}
766+
logger.info(
767+
f"A bug '{bug.summary}` would be created with:\n{bug.to_json()}",
768+
)
769+
else:
770+
try:
771+
response = utils.create_bug(cast(dict, bug.to_json()))
772+
except HTTPError:
773+
logger.error(
774+
f"Failed to create bug '{bug.summary}'",
775+
)
776+
continue
777+
778+
bug_id = response["id"]
779+
assert isinstance(bug_id, int)
780+
self.bugs_created[bug_id] = bug
781+
bugs[str(bug_id)] = {
782+
"id": bug_id,
783+
"summary": bug.summary,
784+
"url": bug.url,
785+
"see_also": bug.component,
786+
"keywords": bug.keywords,
787+
"whiteboard": bug.whiteboard,
788+
"cf_user_story": bug.user_story,
789+
"status": "NEW",
790+
"resolution": "",
791+
"change_type": "create",
792+
"changes": bug,
793+
"user_story": bug.user_story,
794+
}
795+
796+
return bugs
797+
798+
def get_feature_bugs(self, features: set[str]) -> Mapping[str, int]:
799+
"""Get the list of existing bugs for specific features"""
800+
data: dict[str, int] = {}
801+
802+
def handler(bug: Mapping[str, Any], data: dict[str, int]) -> None:
803+
for _, key, value in parse_user_story(bug["cf_user_story"]):
804+
if key == "web-feature":
805+
if value in features:
806+
data[value] = bug["id"]
807+
808+
Bugzilla(
809+
{
810+
"keywords": "web-feature",
811+
"keywords_type": "allwords",
812+
"f1": "cf_user_story",
813+
"o1": "substring",
814+
"v1": "web-feature",
815+
"f2": "cf_user_story",
816+
"o2": "anywordssubstr",
817+
"v2": ",".join(features),
818+
},
819+
bugdata=data,
820+
bughandler=handler,
821+
).wait()
822+
823+
return data
614824

615825
def handle_bug(self, bug: Bug, data: dict[str, Any]) -> Optional[Bug]:
616826
bug_id_str = str(bug["id"])
617827
bug_id_int = int(bug["id"])
618828

619-
if bug_id_int not in self.bug_updates:
620-
return None
621-
bugzilla_update = self.bug_updates[bug_id_int].into_bugzilla_update(bug)
829+
if bug_id_int in self.bug_updates:
830+
bugzilla_update = self.bug_updates[bug_id_int].into_bugzilla_update(bug)
831+
if not bugzilla_update:
832+
return None
622833

623-
if bugzilla_update:
624834
self.autofix_changes[bug_id_str] = bugzilla_update.to_json()
625835
data[bug_id_str] = {
836+
"change_type": "update",
626837
"changes": bugzilla_update,
627838
"whiteboard": bug["whiteboard"],
628839
"user_story": bug["cf_user_story"],
629840
}
630841
return bug
842+
elif bug_id_int in self.bugs_created:
843+
return bug
631844

632845
return None
633846

@@ -648,6 +861,10 @@ def get_bz_params(self, date: str) -> dict[str, str | int | list[str] | list[int
648861
def get_bug_updates(self) -> None:
649862
project = "moz-fx-dev-dschubert-wckb"
650863
client = gcp.get_bigquery_client(project, ["cloud-platform", "drive"])
864+
865+
for create_rule in [FirefoxOnlyMissing(client)]:
866+
self.create_bugs.update(create_rule.run())
867+
651868
for update_rule in [
652869
FeatureRenames(client),
653870
InvalidFeatures(client),

0 commit comments

Comments
 (0)