Repository: mozilla/bugbug

Issue:
Bug classification can choke on a single bug when giving a list
When calling `model.classify` with a list of bug data, a single bug might trigger a error like the following one:

```python
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/rq/worker.py", line 799, in perform_job
    rv = job.perform()
  File "/usr/local/lib/python3.7/site-packages/rq/job.py", line 600, in perform
    self._result = self._execute()
  File "/usr/local/lib/python3.7/site-packages/rq/job.py", line 606, in _execute
    return self.func(*self.args, **self.kwargs)
  File "./bugbug_http_service/models.py", line 101, in classify_bug
    probs = model.classify(list(bugs.values()), True)
  File "/usr/local/lib/python3.7/site-packages/bugbug/model.py", line 191, in classify
    X = self.extraction_pipeline.transform(items)
  File "/usr/local/lib/python3.7/site-packages/sklearn/pipeline.py", line 540, in _transform
    Xt = transform.transform(Xt)
  File "/usr/local/lib/python3.7/site-packages/bugbug/bug_features.py", line 431, in transform
    bug = bug_snapshot.rollback(bug, self.rollback_when)
  File "/usr/local/lib/python3.7/site-packages/bugbug/bug_snapshot.py", line 600, in rollback
    ), f'Current value for field {field} of {bug["id"]}:\n{bug[field]}\nis different from previous value:\n{new_value}'
AssertionError: Current value for field product of 812809:
```
As we are classifying a batch, it is problematic as no other bugs in the batch will be classified.

https://github.com/mozilla/bugbug/pull/321/files#diff-7908c821177e4af7e08af4a9ca055d60R99


Discussion:
When rollbacking the bug, we shouldn't fail when a field is inconsistent, but rather remove the field altogether and consider it as missing data.

Resolving patch:
diff --git a/bugbug/bug_snapshot.py b/bugbug/bug_snapshot.py
--- a/bugbug/bug_snapshot.py
+++ b/bugbug/bug_snapshot.py
@@ -63,6 +63,13 @@ def op_sys(op_sys):
     return op_sys
 
 
+def platform(platform):
+    if platform == "Macintosh":
+        return "PowerPC"
+
+    return platform
+
+
 def product(product):
     mapping = {
         "Web Compatibility Tools": "Web Compatibility",
@@ -110,6 +117,7 @@ def null_str(val):
     "keywords": keyword_mapping,
     "groups": group_mapping,
     "op_sys": op_sys,
+    "platform": platform,
     "product": product,
     "target_milestone": target_milestone,
     "cf_due_date": null_str,
@@ -298,10 +306,83 @@ def is_expected_inconsistent_change_field(field, bug_id, new_value):
         or (
             field == "type" and bug_id == 1257155
         )  # TODO: Remove once https://bugzilla.mozilla.org/show_bug.cgi?id=1550129 is fixed.
+        or is_email(
+            new_value
+        )  # TODO: Users can change their email, try with all emails from a mapping file.
+    )
+
+
+def is_expected_inconsistent_change_list_field(field, bug_id, value):
+    return (
+        (
+            field == "keywords"
+            and value == "checkin-needed"
+            and bug_id in [1274602, 1341727]
+        )
+        or (
+            field == "keywords"
+            and value
+            in [
+                "patch",
+                "nsbeta1",
+                "mozilla1.1",
+                "mozilla1.0",
+                "4xp",
+                "sec-review-complete",
+            ]
+        )  # These keywords don't exist anymore.
+        or is_email(
+            value
+        )  # TODO: Users can change their email, try with all emails from a mapping file.
+    )
+
+
+def is_expected_inconsistent_change_flag(flag, obj_id):
+    # TODO: always assert here, once https://bugzilla.mozilla.org/show_bug.cgi?id=1514415 is fixed.
+    return (
+        obj_id in [1_052_536, 1_201_115, 1_213_517, 794_863]
+        or (
+            flag == "in-testsuite+"
+            and obj_id
+            in [1_318_438, 1_312_852, 1_332_255, 1_344_690, 1_362_387, 1_380_306]
+        )
+        or (flag == "in-testsuite-" and obj_id in [1321444, 1342431, 1370129])
+        or (
+            flag == "checkin+"
+            and obj_id
+            in [8880381, 8879995, 8872652, 8871000, 8870452, 8870505, 8864140, 8868787]
+        )
+        or (flag == "checkin-" and obj_id == 8924974)
+        or (flag == "webcompat?" and obj_id in [1_360_579, 1_364_598])
+        or (
+            flag == "qe-verify-"
+            and obj_id
+            in [
+                1322685,
+                1336510,
+                1363358,
+                1370506,
+                1374024,
+                1377911,
+                1393848,
+                1396334,
+                1398874,
+                1419371,
+            ]
+        )
+        or (flag == "approval-comm-beta+" and obj_id == 8972248)
+        or (flag in ["platform-rel?", "blocking0.3-"])  # These flags have been removed.
     )
 
 
-def rollback(bug, when, verbose=True, all_inconsistencies=False):
+def rollback(bug, when=None, do_assert=False):
+    def assert_or_log(msg):
+        msg = f'{msg}, in bug {bug["id"]}'
+        if do_assert:
+            assert False, msg
+        else:
+            print(msg)
+
     last_product = bug["product"]
 
     change_to_return = None
@@ -326,7 +407,6 @@ def rollback(bug, when, verbose=True, all_inconsistencies=False):
     for history in reversed(bug["history"]):
         # TODO: Handle changes to product and component.
         # TODO: This code might be removed when https://bugzilla.mozilla.org/show_bug.cgi?id=1513952 is fixed.
-        pass
 
         if ret:
             break
@@ -397,20 +477,15 @@ def rollback(bug, when, verbose=True, all_inconsistencies=False):
                         if attachment["id"] == change["attachment_id"]:
                             obj = attachment
                             break
-                    assert obj is not None
+
+                    if obj is None:
+                        assert_or_log(f'Attachment {change["attachment_id"]} not found')
+                        continue
                 else:
                     obj = bug
 
                 if change["added"]:
                     for to_remove in change["added"].split(", "):
-                        if to_remove.startswith("approval-comm-beta"):
-                            # Skip this for now.
-                            continue
-
-                        # These flags have been removed.
-                        if to_remove in ["platform-rel?", "blocking0.3-"]:
-                            continue
-
                         if any(
                             to_remove.startswith(s)
                             for s in [
@@ -434,72 +509,20 @@ def rollback(bug, when, verbose=True, all_inconsistencies=False):
                                 and f["status"] == status
                                 and (requestee is None or f["requestee"] == requestee)
                             ):
-                                assert (
-                                    found_flag is None
-                                ), f'{f["name"]}{f["status"]}{f["requestee"]} found twice!'
+                                if found_flag is not None:
+                                    assert_or_log(
+                                        f'{f["name"]}{f["status"]}{f["requestee"]} found twice!'
+                                    )
                                 found_flag = f
 
-                        # TODO: always assert here, once https://bugzilla.mozilla.org/show_bug.cgi?id=1514415 is fixed.
-                        if (
-                            obj["id"] not in [1_052_536, 1_201_115, 1_213_517, 794_863]
-                            and not (
-                                to_remove == "in-testsuite+"
-                                and obj["id"]
-                                in [
-                                    1_318_438,
-                                    1_312_852,
-                                    1_332_255,
-                                    1_344_690,
-                                    1_362_387,
-                                    1_380_306,
-                                ]
-                            )
-                            and not (
-                                to_remove == "in-testsuite-"
-                                and bug["id"] in [1_321_444, 1_342_431, 1_370_129]
-                            )
-                            and not (
-                                to_remove == "approval-comm-esr52?"
-                                and bug["id"] == 1_352_850
-                            )
-                            and not (
-                                to_remove == "checkin+"
-                                and bug["id"]
-                                in [
-                                    1_308_868,
-                                    1_357_808,
-                                    1_361_361,
-                                    1_365_763,
-                                    1_328_454,
-                                ]
-                            )
-                            and not (to_remove == "checkin-" and bug["id"] == 1_412_952)
-                            and not (
-                                to_remove == "webcompat?"
-                                and obj["id"] in [1_360_579, 1_364_598]
-                            )
-                            and not (
-                                to_remove == "qe-verify-"
-                                and bug["id"]
-                                in [
-                                    1_322_685,
-                                    1_336_510,
-                                    1_363_358,
-                                    1_370_506,
-                                    1_374_024,
-                                    1_377_911,
-                                    1_393_848,
-                                    1_396_334,
-                                    1_398_874,
-                                    1_419_371,
-                                ]
-                            )
-                        ):
-                            assert (
-                                found_flag is not None
-                            ), f'flag {to_remove} not found in {bug["id"]}'
                         if found_flag is not None:
                             obj["flags"].remove(found_flag)
+                        elif not is_expected_inconsistent_change_flag(
+                            to_remove, obj["id"]
+                        ):
+                            assert_or_log(
+                                f"flag {to_remove} not found, in obj {obj['id']}"
+                            )
 
                 if change["removed"]:
                     # Inconsistent review flag.
@@ -518,14 +541,10 @@ def rollback(bug, when, verbose=True, all_inconsistencies=False):
                 continue
 
             if change["added"] != "---":
-                if field not in bug:
-                    if not all_inconsistencies and is_expected_inconsistent_field(
-                        field, last_product, bug["id"]
-                    ):
-                        if verbose:
-                            print(f'{field} is not in bug {bug["id"]}')
-                    else:
-                        assert False, f'{field} is not in bug {bug["id"]}'
+                if field not in bug and not is_expected_inconsistent_field(
+                    field, last_product, bug["id"]
+                ):
+                    assert_or_log(f"{field} is not present")
 
             if field in bug and isinstance(bug[field], list):
                 if change["added"]:
@@ -536,36 +555,14 @@ def rollback(bug, when, verbose=True, all_inconsistencies=False):
                         if field in FIELD_TYPES:
                             to_remove = FIELD_TYPES[field](to_remove)
 
-                        if is_email(to_remove):
-                            # TODO: Users can change their email, try with all emails from a mapping file.
-                            continue
-
-                        if field == "keywords" and to_remove in [
-                            "checkin-needed",
-                            "#relman/triage/defer-to-group",
-                            "conduit-needs-discussion",
-                        ]:
-                            # TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1513981.
-                            if to_remove in bug[field]:
-                                bug[field].remove(to_remove)
-                            continue
-
-                        # These keywords don't exist anymore.
-                        if field == "keywords" and to_remove in [
-                            "patch",
-                            "nsbeta1",
-                            "mozilla1.1",
-                            "mozilla1.0",
-                            "4xp",
-                            "sec-review-complete",
-                        ]:
-                            assert to_remove not in bug[field]
-                            continue
-
-                        assert (
-                            to_remove in bug[field]
-                        ), f'{to_remove} is not in {bug[field]}, for field {field} of {bug["id"]}'
-                        bug[field].remove(to_remove)
+                        if to_remove in bug[field]:
+                            bug[field].remove(to_remove)
+                        elif not is_expected_inconsistent_change_list_field(
+                            field, bug["id"], to_remove
+                        ):
+                            assert_or_log(
+                                f"{to_remove} is not in {bug[field]}, for field {field}"
+                            )
 
                 if change["removed"]:
                     for to_add in change["removed"].split(", "):
@@ -580,24 +577,16 @@ def rollback(bug, when, verbose=True, all_inconsistencies=False):
                     old_value = change["removed"]
                     new_value = change["added"]
 
-                # TODO: Users can change their email, try with all emails from a mapping file.
-                if field in bug and not is_email(bug[field]):
-                    if bug[field] != new_value:
-                        if (
-                            not all_inconsistencies
-                            and is_expected_inconsistent_change_field(
-                                field, bug["id"], new_value
-                            )
-                        ):
-                            # This case is too common, let's not print anything.
-                            if not (field == "severity" and new_value == "enhancement"):
-                                print(
-                                    f'Current value for field {field} of {bug["id"]}:\n{bug[field]}\nis different from previous value:\n{new_value}'
-                                )
-                        else:
-                            assert (
-                                False
-                            ), f'Current value for field {field} of {bug["id"]}:\n{bug[field]}\nis different from previous value:\n{new_value}'
+                if (
+                    field in bug
+                    and bug[field] != new_value
+                    and not is_expected_inconsistent_change_field(
+                        field, bug["id"], new_value
+                    )
+                ):
+                    assert_or_log(
+                        f"Current value for field {field}: ({bug[field]}) is different from previous value: ({new_value})"
+                    )
 
                 bug[field] = old_value
 
@@ -626,19 +615,26 @@ def rollback(bug, when, verbose=True, all_inconsistencies=False):
         <= rollback_date
     ]
 
-    assert (
-        len(bug["comments"]) >= 1
-    ), f"There must be at least one comment in bug {bug['id']}"
+    if len(bug["comments"]) == 0:
+        assert_or_log("There must be at least one comment")
+        bug["comments"] = [
+            {
+                "id": 0,
+                "text": "",
+                "author": bug["creator"],
+                "creation_time": bug["creation_time"],
+            }
+        ]
 
     return bug
 
 
-def get_inconsistencies(find_all=False):
+def get_inconsistencies():
     inconsistencies = []
 
     for bug in bugzilla.get_bugs():
         try:
-            rollback(bug, None, False, find_all)
+            rollback(bug, do_assert=True)
         except Exception as e:
             print(bug["id"])
             print(e)
@@ -658,4 +654,5 @@ def get_inconsistencies(find_all=False):
         if args.verbose:
             print(bug["id"])
             print(i)
-        rollback(bug, None, False)
+
+        rollback(bug, do_assert=True)


Test patch:
diff --git a/tests/test_bug_snapshot.py b/tests/test_bug_snapshot.py
--- a/tests/test_bug_snapshot.py
+++ b/tests/test_bug_snapshot.py
@@ -13,4 +13,4 @@ def test_bug_snapshot():
         print(bug["id"])
         print(i)
 
-        rollback(bug, None, False)
+        rollback(bug, do_assert=True)