From aa57ba8ac9ad4de256468e24d45dca27299323af Mon Sep 17 00:00:00 2001 From: Randy Barlow Date: Wed, 11 Oct 2017 18:17:16 -0400 Subject: [PATCH] Display the push to batched button for pushed updates. The HTML template code is crazy[0,1] and its craziness was underestimated when the batching feature was first written. It turns out that it would only display the push to batched button for unpushed updates. This commit refactors the template code to be slightly less crazy (further refactoring is certainly needed), and displays that button on pushed updates. It also adds several test cases to assert that the correct buttons appear at the right times. These new tests revealed that the CI tests were leaking state, as they would pass on their own but fail if the CI tests were run first. Thus, this commit also refactors the CI tests to stop leaking state by using mock. fixes #1875 re #1887 re #1888 [0] https://github.com/fedora-infra/bodhi/issues/1887 [1] https://github.com/fedora-infra/bodhi/issues/1888 Signed-off-by: Randy Barlow --- bodhi/server/templates/update.html | 18 +- bodhi/server/util.py | 23 ++ bodhi/tests/server/services/test_updates.py | 303 +++++++++++++++++--- bodhi/tests/server/test_util.py | 55 +++- docs/release_notes.rst | 10 + 5 files changed, 356 insertions(+), 53 deletions(-) diff --git a/bodhi/server/templates/update.html b/bodhi/server/templates/update.html index 82d7610179..78b9d46780 100644 --- a/bodhi/server/templates/update.html +++ b/bodhi/server/templates/update.html @@ -508,21 +508,19 @@

% if update.status.description not in ['stable', 'obsolete']: Push to Batched % endif - % elif update.request.description == 'batched': - Push to Stable + % elif update.request.description in ['batched', 'stable']: + ${self.util.push_to_batched_or_stable_button(update) | n} % else: Revoke % endif % elif update.pushed and (update.status.description != 'stable' or (update.status.description == 'stable' and 'releng' in [group.name for group in request.user.groups])): Edit - % if update.critpath and getattr(update.request, 'description', None) != 'stable': - % if update.critpath_approved: - Push to Stable - % endif - % elif update.meets_testing_requirements and getattr(update.request, 'description', None) != 'stable': - Push to Stable - % elif update.stable_karma not in (0, None) and update.karma >= update.stable_karma and not update.autokarma and getattr(update.request, 'description', None) != 'stable': - Push to Stable + % if update.critpath and update.critpath_approved: + ${self.util.push_to_batched_or_stable_button(update) | n} + % elif update.meets_testing_requirements: + ${self.util.push_to_batched_or_stable_button(update) | n} + % elif update.stable_karma not in (0, None) and update.karma >= update.stable_karma and not update.autokarma: + ${self.util.push_to_batched_or_stable_button(update) | n} % endif Unpush % endif diff --git a/bodhi/server/util.py b/bodhi/server/util.py index dd4e59637d..516fb08c35 100644 --- a/bodhi/server/util.py +++ b/bodhi/server/util.py @@ -583,6 +583,29 @@ def bug_link(context, bug, short=False): return link +def push_to_batched_or_stable_button(context, update): + """ + Form the push to batched or push to stable button, as appropriate. + + Args: + context (mako.runtime.Context): The current template rendering context. Unused. + update (bodhi.server.models.Update): The Update we are rendering a button about. + Returns: + basestring: HTML for a button that will draw the push to stable or push to batched button, + as appropriate. + """ + if getattr(update.request, 'description', None) == 'batched' or \ + getattr(update.severity, 'description', None) == 'urgent': + button = ('stable', 'Stable') + elif getattr(update.request, 'description', None) in ('stable', None): + button = ('batched', 'Batched') + else: + return '' + + return ('' + ' Push to {}').format(*button) + + def testcase_link(context, test, short=False): url = config.get('test_case_base_url') + test.name display = test.name.replace('QA:Testcase ', '') diff --git a/bodhi/tests/server/services/test_updates.py b/bodhi/tests/server/services/test_updates.py index 608054a712..85f089d220 100644 --- a/bodhi/tests/server/services/test_updates.py +++ b/bodhi/tests/server/services/test_updates.py @@ -733,13 +733,11 @@ def test_provenpackager_request_update_queued_in_test_gating(self, publish, *arg self.db.add(user) group = self.db.query(Group).filter_by(name=u'provenpackager').one() user.groups.append(group) - self.app_settings['test_gating.required'] = True self.db.commit() - app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings)) up_data = self.get_update(nvr) - up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token'] - res = app.post_json('/updates/', up_data) + up_data['csrf_token'] = self.app.get('/csrf').json_body['csrf_token'] + res = self.app.post_json('/updates/', up_data) publish.assert_called_once_with( topic='update.request.testing', msg=mock.ANY) @@ -749,8 +747,9 @@ def test_provenpackager_request_update_queued_in_test_gating(self, publish, *arg # Try and submit the update to stable as a provenpackager post_data = dict(update=nvr, request='stable', - csrf_token=app.get('/csrf').json_body['csrf_token']) - res = app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) + csrf_token=self.app.get('/csrf').json_body['csrf_token']) + with mock.patch.dict(config, {'test_gating.required': True}): + res = self.app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) # Ensure we can't push it until it passed test gating self.assertEqual(res.json_body['status'], 'error') @@ -769,13 +768,11 @@ def test_provenpackager_request_update_running_in_test_gating(self, publish, *ar self.db.add(user) group = self.db.query(Group).filter_by(name=u'provenpackager').one() user.groups.append(group) - self.app_settings['test_gating.required'] = True self.db.commit() - app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings)) up_data = self.get_update(nvr) - up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token'] - res = app.post_json('/updates/', up_data) + up_data['csrf_token'] = self.app.get('/csrf').json_body['csrf_token'] + res = self.app.post_json('/updates/', up_data) publish.assert_called_once_with( topic='update.request.testing', msg=mock.ANY) @@ -785,8 +782,9 @@ def test_provenpackager_request_update_running_in_test_gating(self, publish, *ar # Try and submit the update to stable as a provenpackager post_data = dict(update=nvr, request='stable', - csrf_token=app.get('/csrf').json_body['csrf_token']) - res = app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) + csrf_token=self.app.get('/csrf').json_body['csrf_token']) + with mock.patch.dict(config, {'test_gating.required': True}): + res = self.app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) # Ensure we can't push it until it passed test gating self.assertEqual(res.json_body['status'], 'error') @@ -805,13 +803,11 @@ def test_provenpackager_request_update_failed_test_gating(self, publish, *args): self.db.add(user) group = self.db.query(Group).filter_by(name=u'provenpackager').one() user.groups.append(group) - self.app_settings['test_gating.required'] = True self.db.commit() - app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings)) up_data = self.get_update(nvr) - up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token'] - res = app.post_json('/updates/', up_data) + up_data['csrf_token'] = self.app.get('/csrf').json_body['csrf_token'] + res = self.app.post_json('/updates/', up_data) publish.assert_called_once_with( topic='update.request.testing', msg=mock.ANY) @@ -821,8 +817,9 @@ def test_provenpackager_request_update_failed_test_gating(self, publish, *args): # Try and submit the update to stable as a provenpackager post_data = dict(update=nvr, request='stable', - csrf_token=app.get('/csrf').json_body['csrf_token']) - res = app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) + csrf_token=self.app.get('/csrf').json_body['csrf_token']) + with mock.patch.dict(config, {'test_gating.required': True}): + res = self.app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) # Ensure we can't push it until it passed test gating self.assertEqual(res.json_body['status'], 'error') @@ -841,13 +838,11 @@ def test_provenpackager_request_update_ignored_by_test_gating(self, publish, *ar self.db.add(user) group = self.db.query(Group).filter_by(name=u'provenpackager').one() user.groups.append(group) - self.app_settings['test_gating.required'] = True self.db.commit() - app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings)) up_data = self.get_update(nvr) - up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token'] - res = app.post_json('/updates/', up_data) + up_data['csrf_token'] = self.app.get('/csrf').json_body['csrf_token'] + res = self.app.post_json('/updates/', up_data) assert 'does not have commit access to bodhi' not in res, res publish.assert_called_once_with( topic='update.request.testing', msg=mock.ANY) @@ -858,8 +853,9 @@ def test_provenpackager_request_update_ignored_by_test_gating(self, publish, *ar # Try and submit the update to stable as a provenpackager post_data = dict(update=nvr, request='stable', - csrf_token=app.get('/csrf').json_body['csrf_token']) - res = app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) + csrf_token=self.app.get('/csrf').json_body['csrf_token']) + with mock.patch.dict(config, {'test_gating.required': True}): + res = self.app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) # Ensure the reason we cannot push isn't test gating this time self.assertEqual(res.json_body['status'], 'error') @@ -878,13 +874,11 @@ def test_provenpackager_request_update_waiting_on_test_gating(self, publish, *ar self.db.add(user) group = self.db.query(Group).filter_by(name=u'provenpackager').one() user.groups.append(group) - self.app_settings['test_gating.required'] = True self.db.commit() - app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings)) up_data = self.get_update(nvr) - up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token'] - res = app.post_json('/updates/', up_data) + up_data['csrf_token'] = self.app.get('/csrf').json_body['csrf_token'] + res = self.app.post_json('/updates/', up_data) publish.assert_called_once_with( topic='update.request.testing', msg=mock.ANY) @@ -894,8 +888,9 @@ def test_provenpackager_request_update_waiting_on_test_gating(self, publish, *ar # Try and submit the update to stable as a provenpackager post_data = dict(update=nvr, request='stable', - csrf_token=app.get('/csrf').json_body['csrf_token']) - res = app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) + csrf_token=self.app.get('/csrf').json_body['csrf_token']) + with mock.patch.dict(config, {'test_gating.required': True}): + res = self.app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) # Ensure we can't push it until it passed test gating self.assertEqual(res.json_body['status'], 'error') @@ -914,13 +909,11 @@ def test_provenpackager_request_update_with_none_test_gating(self, publish, *arg self.db.add(user) group = self.db.query(Group).filter_by(name=u'provenpackager').one() user.groups.append(group) - self.app_settings['test_gating.required'] = True self.db.commit() - app = TestApp(main({}, testing=u'bob', session=self.db, **self.app_settings)) up_data = self.get_update(nvr) - up_data['csrf_token'] = app.get('/csrf').json_body['csrf_token'] - res = app.post_json('/updates/', up_data) + up_data['csrf_token'] = self.app.get('/csrf').json_body['csrf_token'] + res = self.app.post_json('/updates/', up_data) publish.assert_called_once_with( topic='update.request.testing', msg=mock.ANY) @@ -930,8 +923,9 @@ def test_provenpackager_request_update_with_none_test_gating(self, publish, *arg # Try and submit the update to stable as a provenpackager post_data = dict(update=nvr, request='stable', - csrf_token=app.get('/csrf').json_body['csrf_token']) - res = app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) + csrf_token=self.app.get('/csrf').json_body['csrf_token']) + with mock.patch.dict(config, {'test_gating.required': True}): + res = self.app.post_json('/updates/%s/request' % str(nvr), post_data, status=400) # Ensure the reason we can't push is not test gating self.assertEqual(res.json_body['status'], 'error') @@ -3686,26 +3680,251 @@ def test_autopush_non_critical_update_with_no_negative_karma(self, publish, *arg @mock.patch(**mock_valid_requirements) @mock.patch('bodhi.server.notifications.publish') - def test_manually_push_to_stable_from_batched(self, publish, *args): + def test_edit_button_not_present_when_stable(self, publish, *args): + """ + Assert that the edit button is not present on stable updates. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.date_stable = datetime.utcnow() + update.status = UpdateStatus.stable + update.pushed = True + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + + # Checks Edit text not in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertNotIn('Push to Batched', resp) + self.assertNotIn('Push to Stable', resp) + self.assertNotIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_batched_button_present_when_karma_reached(self, publish, *args): + """ + Assert that the "Push to Batched" button appears when the required karma is + reached. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.status = UpdateStatus.testing + update.request = None + update.pushed = True + update.autokarma = False + update.stable_karma = 1 + update.comment(self.db, 'works', 1, 'bowlofeggs') + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + + # Checks Push to Batched text in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertIn('Push to Batched', resp) + self.assertNotIn('Push to Stable', resp) + self.assertIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_stable_button_present_when_karma_reached_urgent(self, publish, *args): """ - Test manually push to stable from batched when autokarma is disabled + Assert that the "Push to Stable" button appears when the required karma is + reached for an urgent update. """ nvr = u'bodhi-2.0.0-2.fc17' args = self.get_update(nvr) resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.severity = UpdateSeverity.urgent + update.status = UpdateStatus.testing + update.request = None + update.pushed = True + update.autokarma = False + update.stable_karma = 1 + update.comment(self.db, 'works', 1, 'bowlofeggs') + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + # Checks Push to Stable text in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertNotIn('Push to Batched', resp) + self.assertIn('Push to Stable', resp) + self.assertIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_stable_button_present_when_karma_reached_and_batched(self, publish, *args): + """ + Assert that the "Push to Stable" button appears when the required karma is + reached and the update is already batched. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) update = Update.get(nvr, self.db) update.status = UpdateStatus.testing update.request = UpdateRequest.batched + update.pushed = True + update.autokarma = False + update.stable_karma = 1 + update.comment(self.db, 'works', 1, 'bowlofeggs') self.db.commit() + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + # Checks Push to Stable text in the html page for this update - id = 'bodhi-2.0.0-2.fc17' - resp = self.app.get('/updates/%s' % id, - headers={'Accept': 'text/html'}) self.assertIn('text/html', resp.headers['Content-Type']) - self.assertIn(id, resp) + self.assertIn(nvr, resp) + self.assertNotIn('Push to Batched', resp) + self.assertIn('Push to Stable', resp) + self.assertIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_batched_button_present_when_time_reached(self, publish, *args): + """ + Assert that the "Push to Batched" button appears when the required time in testing is + reached. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.status = UpdateStatus.testing + update.request = None + update.pushed = True + # This update has been in testing a while, so a "Push to Batched" button should appear. + update.date_testing = datetime.now() - timedelta(days=30) + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + + # Checks Push to Batched text in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertIn('Push to Batched', resp) + self.assertNotIn('Push to Stable', resp) + self.assertIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_stable_button_present_when_time_reached_and_urgent(self, publish, *args): + """ + Assert that the "Push to Stable" button appears when the required time in testing is + reached. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.severity = UpdateSeverity.urgent + update.status = UpdateStatus.testing + update.request = None + update.pushed = True + # This urgent update has been in testing a while, so a "Push to Stable" button should + # appear. + update.date_testing = datetime.now() - timedelta(days=30) + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + + # Checks Push to Stable text in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertNotIn('Push to Batched', resp) + self.assertIn('Push to Stable', resp) + self.assertIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_stable_button_present_when_time_reached_and_batched(self, publish, *args): + """ + Assert that the "Push to Stable" button appears when the required time in testing is + reached and the update is already batched. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.status = UpdateStatus.testing + update.request = UpdateRequest.batched + update.pushed = True + # This update has been in testing a while, so a "Push to Stable" button should appear. + update.date_testing = datetime.now() - timedelta(days=30) + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + + # Checks Push to Stable text in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertNotIn('Push to Batched', resp) + self.assertIn('Push to Stable', resp) + self.assertIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_batched_button_present_when_time_reached_critpath(self, publish, *args): + """ + Assert that the "Push to Batched" button appears when it should for a critpath update. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.status = UpdateStatus.testing + update.request = None + update.pushed = True + update.critpath = True + # This update has been in testing a while, so a "Push to Batched" button should appear. + update.date_testing = datetime.now() - timedelta(days=30) + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + + # Checks Push to Batched text in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertIn('Push to Batched', resp) + self.assertNotIn('Push to Stable', resp) + self.assertIn('Edit', resp) + + @mock.patch(**mock_valid_requirements) + @mock.patch('bodhi.server.notifications.publish') + def test_push_to_stable_button_present_when_time_reached_and_batched_critpath(self, publish, + *args): + """ + Assert that the "Push to Stable" button appears when the required time in testing is + reached and the update is already batched. + """ + nvr = u'bodhi-2.0.0-2.fc17' + args = self.get_update(nvr) + resp = self.app.post_json('/updates/', args) + update = Update.get(nvr, self.db) + update.critpath = True + update.status = UpdateStatus.testing + update.request = UpdateRequest.batched + update.pushed = True + # This update has been in testing a while, so a "Push to Batched" button should appear. + update.date_testing = datetime.now() - timedelta(days=30) + self.db.commit() + + resp = self.app.get('/updates/%s' % nvr, headers={'Accept': 'text/html'}) + + # Checks Push to Stable text in the html page for this update + self.assertIn('text/html', resp.headers['Content-Type']) + self.assertIn(nvr, resp) + self.assertNotIn('Push to Batched', resp) self.assertIn('Push to Stable', resp) + self.assertIn('Edit', resp) @mock.patch(**mock_valid_requirements) @mock.patch('bodhi.server.notifications.publish') diff --git a/bodhi/tests/server/test_util.py b/bodhi/tests/server/test_util.py index 8a1d2bcec0..7526538bc2 100644 --- a/bodhi/tests/server/test_util.py +++ b/bodhi/tests/server/test_util.py @@ -25,7 +25,8 @@ from bodhi.server import util from bodhi.server.buildsys import setup_buildsystem, teardown_buildsystem from bodhi.server.config import config -from bodhi.server.models import TestGatingStatus +from bodhi.server.models import TestGatingStatus, Update, UpdateRequest, UpdateSeverity +from bodhi.tests.server import base class TestBugLink(unittest.TestCase): @@ -102,6 +103,58 @@ def test_short_true(self): "#1234567")) +class TestPushToBatchedOrStableButton(base.BaseTestCase): + """Test the push_to_batched_or_stable_button() function.""" + def test_request_is_batched(self): + """The function should render a Push to Stable button if the request is batched.""" + u = Update.query.all()[0] + u.request = UpdateRequest.batched + + a = util.push_to_batched_or_stable_button(None, u) + + self.assertTrue('id="stable"' in a) + self.assertTrue(' Push to Stable' in a) + + def test_request_is_none(self): + """The function should render a Push to Stable button if the request is None.""" + u = Update.query.all()[0] + u.request = None + + a = util.push_to_batched_or_stable_button(None, u) + + self.assertTrue('id="batched"' in a) + self.assertTrue(' Push to Batched' in a) + + def test_request_is_other(self): + """The function should return '' request is something else, like testing.""" + u = Update.query.all()[0] + u.request = UpdateRequest.testing + + a = util.push_to_batched_or_stable_button(None, u) + + self.assertEqual(a, '') + + def test_request_is_stable(self): + """The function should render a Push to Batched button if the request is stable.""" + u = Update.query.all()[0] + u.request = UpdateRequest.stable + + a = util.push_to_batched_or_stable_button(None, u) + + self.assertTrue('id="batched"' in a) + self.assertTrue(' Push to Batched' in a) + + def test_severity_is_urgent(self): + """The function should render a Push to Stable button if the severity is urgent.""" + u = Update.query.all()[0] + u.severity = UpdateSeverity.urgent + + a = util.push_to_batched_or_stable_button(None, u) + + self.assertTrue('id="stable"' in a) + self.assertTrue(' Push to Stable' in a) + + class TestUtils(unittest.TestCase): def setUp(self): diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 381e601358..f1d4f62dfd 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -9,6 +9,16 @@ Bugs * Positive karma on stable updates no longer sends them back to batched (`#1881 `_). +* Push to batched buttons now appear on pushed updates when appropriate + (`#1875 `_). + + +Release contributors +^^^^^^^^^^^^^^^^^^^^ + +The following developers contributed to Bodhi 2.12.2: + +* Randy Barlow 2.12.1