Repository: fedora-infra/bodhi

Issue:
Can't push update to stable (days to stable is below zero)
Hi,

can someone please look at why I was not able to push https://bodhi.fedoraproject.org/updates/webkitgtk4-2.16.5-1.fc24 to stable? There was only Unpush button available and autopush enabled. Could it be related to the "days to stable" that is -13?

I had to ask a colleague to provide a karma to trigger autopush in the end.


Discussion:
Hello @tpopela,

I was able to use last night's database dump to see the state of the update and figure out what happened. There are a couple of separate bugs here actually, though I think only one of them explains why you were unable to push.

The big problem is that karma is not being counted correctly for critical path updates in the [```meets_testing_requirements()```](https://github.com/fedora-infra/bodhi/blob/2.8.1/bodhi/server/models.py#L2451-L2457) method. That method is using ```self.comments``` instead of using ```self.comments_since_karma_reset```. Because of this, that user that gave you a -1 is still counting against you, even though you have since added a new build (which reset the karma). Furthermore, there's an even easier way it could do this - it could just use ```self._composite_karma``` to get a count of -1's and share code (and that method already does it the right way). So that should actually be pretty easy to fix.

The other problem is that the [```days_to_stable()```](https://github.com/fedora-infra/bodhi/blob/2.8.1/bodhi/server/models.py#L2506-L2509) method can return a negative number if ```meets_testing_requirements()``` returns ```False``` (as it did in this case). We should rework that to only return the value if it is > 0, else return 0. That should also be pretty easy to fix.

Resolving patch:
diff --git a/bodhi/server/models.py b/bodhi/server/models.py
--- a/bodhi/server/models.py
+++ b/bodhi/server/models.py
@@ -2454,13 +2454,8 @@ def meets_testing_requirements(self):
             # Ensure there is no negative karma. We're looking at the sum of
             # each users karma for this update, which takes into account
             # changed votes.
-            feedback = defaultdict(int)
-            for comment in self.comments:
-                if not comment.anonymous:
-                    feedback[comment.user.name] += comment.karma
-            for karma in feedback.values():
-                if karma < 0:
-                    return False
+            if self._composite_karma[1] < 0:
+                return False
             return self.days_in_testing >= num_days
 
         if not num_days:
@@ -2510,9 +2505,9 @@ def days_to_stable(self):
         "truthy" date_testing attribute, or if it's been in testing for the release's
         mandatory_days_in_testing or longer."""
         if not self.meets_testing_requirements and self.date_testing:
-            return (
-                self.mandatory_days_in_testing -
-                (datetime.utcnow() - self.date_testing).days)
+            num_days = (self.mandatory_days_in_testing - self.days_in_testing)
+            if num_days > 0:
+                return num_days
         return 0
 
     @property


Test patch:
diff --git a/bodhi/tests/server/services/test_updates.py b/bodhi/tests/server/services/test_updates.py
--- a/bodhi/tests/server/services/test_updates.py
+++ b/bodhi/tests/server/services/test_updates.py
@@ -3858,6 +3858,49 @@ def test_edit_testing_update_with_build_from_different_update(self, publish, *ar
         self.assertEquals(len(up.builds), 1)
         self.assertEquals(up.builds[0].nvr, nvr1)
 
+    @mock.patch(**mock_valid_requirements)
+    @mock.patch('bodhi.server.notifications.publish')
+    def test_meets_testing_requirements_since_karma_reset_critpath(self, publish, *args):
+        """
+        Ensure a critpath update still meets testing requirements after receiving negative karma
+        and after a karma reset event.
+        """
+        nvr = u'bodhi-2.0.0-2.fc17'
+        args = self.get_update(nvr)
+        r = self.app.post_json('/updates/', args)
+        publish.assert_called_with(topic='update.request.testing', msg=ANY)
+
+        update = Update.get(nvr, self.db)
+        update.status = UpdateStatus.testing
+        update.request = None
+        update.critpath = True
+        update.autokarma = True
+        update.date_testing = datetime.utcnow() + timedelta(days=-20)
+        update.comment(self.db, u'lgtm', author=u'friend', karma=1)
+        update.comment(self.db, u'lgtm', author=u'friend2', karma=1)
+        update.comment(self.db, u'bad', author=u'enemy', karma=-1)
+        self.db.commit()
+
+        self.assertEqual(update.meets_testing_requirements, False)
+
+        args['edited'] = args['builds']
+        args['builds'] = 'bodhi-2.0.0-3.fc17'
+        r = self.app.post_json('/updates/', args)
+        up = r.json_body
+
+        self.assertEquals(up['title'], u'bodhi-2.0.0-3.fc17')
+        self.assertEquals(up['karma'], 0)
+
+        update = Update.get(u'bodhi-2.0.0-3.fc17', self.db)
+        update.status = UpdateStatus.testing
+        self.date_testing = update.date_testing + timedelta(days=7)
+        update.comment(self.db, u'lgtm', author='friend3', karma=1)
+        update.comment(self.db, u'lgtm2', author='friend4', karma=1)
+        self.db.commit()
+
+        self.assertEquals(update.days_to_stable, 0)
+        self.assertEqual(update.meets_testing_requirements, True)
+
     @mock.patch(**mock_taskotron_results)
     @mock.patch(**mock_valid_requirements)
     @mock.patch('bodhi.server.notifications.publish')
diff --git a/bodhi/tests/server/test_models.py b/bodhi/tests/server/test_models.py
--- a/bodhi/tests/server/test_models.py
+++ b/bodhi/tests/server/test_models.py
@@ -851,6 +851,44 @@ def test_days_to_stable_not_meets_testing_requirements_with_date_testing(self):
 
         self.assertEqual(update.days_to_stable, 3)
 
+    @mock.patch.dict('bodhi.server.config.config', {'ci.required': True})
+    def test_days_to_stable_zero(self):
+        """
+        The Update.days_to_stable() method should only return a positive integer or zero.
+        In the past, days_to_stable() could return negative integers when the mandatory days in
+        testing was less than the number of days in testing. If the mandatory days in testing is
+        less than or equal to the number of days in testing, days_to_stable() should return zero.
+        See issue #1708.
+        """
+        update = self.obj
+        update.autokarma = False
+        update.builds[0].ci_status = CiStatus.failed
+
+        update.date_testing = datetime.utcnow() + timedelta(days=-8)
+        self.assertEqual(update.meets_testing_requirements, False)
+
+        self.assertEqual(update.mandatory_days_in_testing <= update.days_in_testing, True)
+        self.assertEqual(update.days_to_stable, 0)
+
+    @mock.patch.dict('bodhi.server.config.config', {'ci.required': True})
+    def test_days_to_stable_positive(self):
+        """
+        The Update.days_to_stable() method should only return a positive integer or zero.
+        In the past, days_to_stable() could return negative integers when the mandatory days in
+        testing was less than the number of days in testing. If the mandatory days in testing is
+        greater than the number of days in testing, return the positive number of days until
+        stable. See issue #1708.
+        """
+        update = self.obj
+        update.autokarma = False
+        update.builds[0].ci_status = CiStatus.failed
+
+        update.date_testing = datetime.utcnow() + timedelta(days=-3)
+        self.assertEqual(update.meets_testing_requirements, False)
+
+        self.assertEqual(update.mandatory_days_in_testing > update.days_in_testing, True)
+        self.assertEqual(update.days_to_stable, 4)
+
     @mock.patch.dict('bodhi.server.config.config', {'ci.required': True})
     def test_ci_failed_no_testing_requirements(self):
         """
@@ -1550,6 +1588,16 @@ def test_meets_testing_requirements_with_non_autokarma_update_with_stable_karma_
         # meets_testing_requirement() should return False since the stable_karma threshold is None.
         self.assertEqual(self.obj.meets_testing_requirements, False)
 
+    def test_meets_testing_requirements_critpath_negative_karma(self):
+        """
+        Assert that meets_testing_requirements() correctly returns False for critpath updates
+        with negative karma.
+        """
+        update = self.obj
+        update.critpath = True
+        update.comment(self.db, u'testing', author=u'enemy', anonymous=False, karma=-1)
+        self.assertEqual(update.meets_testing_requirements, False)
+
     @mock.patch('bodhi.server.notifications.publish')
     def test_met_testing_requirements_with_karma_after_bodhi_comment(self, publish):
         """