1313 Any ,
1414 Generic ,
1515 Iterable ,
16+ Literal ,
1617 Mapping ,
1718 MutableMapping ,
1819 Optional ,
1920 Sequence ,
2021 TypeVar ,
22+ cast ,
2123)
2224from urllib import parse
2325
2426from 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
2731from bugbot .bzcleaner import Bug , BzCleaner
2832
2933Json = 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
91132class 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
296362class 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+
369498class UpdateRule (ABC , Generic [_DataType ]):
370499 """Rule for updating bugs based on BigQuery data"""
371500
@@ -598,6 +727,8 @@ def update(
598727class 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