Repository: fedora-infra/bodhi

Issue:
Can't submit update to multiple distros at once
I often build a new NEVR for multiple distros and then submit updates for all of them together. The old web interface happily accepted this: I could submit, say, 'fedfind-1.3-1.fc23' and 'fedfind-1.3-1.fc22' together and it'd create two updates, one for F22, one for F23. This saved me a lot of busywork.

The new web UI doesn't seem to allow this; it pops up a message for a few seconds saying 'multiple fedfind builds' or something like that. The failure cases in the new UI could stand to be more obvious.



Discussion:
Happened to me with 2 packages.

This is currently a known limitation of Bodhi2, and a high priority feature.

IMHO, ideally, they should be treated as ONE update group and in particular share the karma value (so that one needs only +1 or +2 for the identical update on all releases together rather than for every single release).

That's a significant policy change, not an engineering issue.

I never knew Bodhi1 could do this.  Sounds super useful.


Resolving patch:
diff --git a/bodhi/models/models.py b/bodhi/models/models.py
--- a/bodhi/models/models.py
+++ b/bodhi/models/models.py
@@ -607,42 +607,11 @@ class Update(Base):
     def new(cls, request, data):
         """ Create a new update """
         db = request.db
-        buildinfo = request.buildinfo
         user = request.user
         data['user'] = user
-        data['title'] = ' '.join(data['builds'])
-
-        releases = set()
-        builds = []
-
-        # Create the Package and Build entities
-        for nvr in data['builds']:
-            name, version, release = buildinfo[nvr]['nvr']
-            package = db.query(Package).filter_by(name=name).first()
-            if not package:
-                package = Package(name=name)
-                db.add(package)
-                db.flush()
-
-            # Fetch test cases from the wiki
-            package.fetch_test_cases(db)
-
-            build = Build.get(nvr, db)
-
-            if build is None:
-                build = Build(nvr=nvr, package=package)
-                db.add(build)
-                db.flush()
+        data['title'] = ' '.join([b.nvr for b in data['builds']])
 
-            build.package = package
-            build.release = buildinfo[build.nvr]['release']
-            builds.append(build)
-            releases.add(buildinfo[build.nvr]['release'])
-
-        data['builds'] = builds
-
-        assert len(releases) == 1, "TODO: multi-release updates"
-        data['release'] = list(releases)[0]
+        caveats = []
 
         critical = False
         critpath_pkgs = get_critpath_pkgs(data['release'].name.lower())
@@ -682,6 +651,11 @@ def new(cls, request, data):
         if not data['autokarma']:
             del(data['stable_karma'])
             del(data['unstable_karma'])
+            caveats.append({
+                'name': 'autokarma',
+                'description': 'Auto-karma stable requests disabled.',
+            })
+
         del(data['autokarma'])
 
         del(data['edited'])
@@ -697,7 +671,7 @@ def new(cls, request, data):
         db.add(up)
         db.flush()
 
-        return up
+        return up, caveats
 
     @classmethod
     def edit(cls, request, data):
@@ -708,6 +682,7 @@ def edit(cls, request, data):
         up = db.query(Update).filter_by(title=data['edited']).first()
         del(data['edited'])
 
+        caveats = []
         edited_builds = [build.nvr for build in up.builds]
 
         # Determine which builds have been added
@@ -724,8 +699,11 @@ def edit(cls, request, data):
                 if not package:
                     package = Package(name=name)
                     db.add(package)
-                b = Build(nvr=build, package=package)
-                b.release = up.release
+                b = db.query(Build).filter_by(nvr=build).first()
+                if not b:
+                    b = Build(nvr=build, package=package)
+                    b.release = up.release
+                    db.add(b)
                 koji.tagBuild(up.release.pending_testing_tag, build)
                 up.builds.append(b)
 
@@ -765,6 +743,7 @@ def edit(cls, request, data):
         if removed_builds:
             comment += 'Removed build(s): %s.' % ', '.join(removed_builds)
         up.comment(comment, karma=0, author=u'bodhi')
+        caveats.append({'name': 'builds', 'description': comment})
 
         data['title'] = ' '.join(sorted([b.nvr for b in up.builds]))
 
@@ -790,7 +769,7 @@ def edit(cls, request, data):
         notifications.publish(topic='update.edit', msg=dict(
             update=up, agent=request.user.name))
 
-        return up
+        return up, caveats
 
     def obsolete_older_updates(self, request):
         """Obsolete any older pending/testing updates.
diff --git a/bodhi/services/updates.py b/bodhi/services/updates.py
--- a/bodhi/services/updates.py
+++ b/bodhi/services/updates.py
@@ -12,6 +12,7 @@
 # along with this program; if not, write to the Free Software
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 
+import copy
 import math
 
 from cornice import Service
@@ -292,24 +293,100 @@ def new_update(request):
     # it since the models don't care about a csrf argument.
     data.pop('csrf_token')
 
+    caveats = []
     try:
+
+        releases = set()
+        builds = []
+
+        # Create the Package and Build entities
+        for nvr in data['builds']:
+            name, version, release = request.buildinfo[nvr]['nvr']
+            package = request.db.query(Package).filter_by(name=name).first()
+            if not package:
+                package = Package(name=name)
+                request.db.add(package)
+                request.db.flush()
+
+            # Fetch test cases from the wiki
+            package.fetch_test_cases(request.db)
+
+            build = Build.get(nvr, request.db)
+
+            if build is None:
+                print "Adding nvr", nvr
+                build = Build(nvr=nvr, package=package)
+                request.db.add(build)
+                request.db.flush()
+
+            build.package = package
+            build.release = request.buildinfo[build.nvr]['release']
+            builds.append(build)
+            releases.add(request.buildinfo[build.nvr]['release'])
+
+
         if data.get('edited'):
+
             log.info('Editing update: %s' % data['edited'])
-            up = Update.edit(request, data)
-        else:
-            log.info('Creating new update: %s' % ' '.join(data['builds']))
-            up = Update.new(request, data)
-            log.debug('update = %r' % up)
 
+            assert len(releases) == 1, "Updates may not span multiple releases"
+            data['release'] = list(releases)[0]
+            data['builds'] = [b.nvr for b in builds]
+            result, _caveats = Update.edit(request, data)
+            caveats.extend(_caveats)
+        else:
+            if len(releases) > 1:
+                caveats.append({
+                    'name': 'releases',
+                    'description': 'Your update is being split '
+                    'into %i, one for each release.' % len(releases)
+
+                })
+            updates = []
+            for release in releases:
+                _data = copy.copy(data)  # Copy it because .new(..) mutates it
+                _data['builds'] = [b for b in builds if b.release == release]
+                _data['release'] = release
+
+                log.info('Creating new update: %r' % _data['builds'])
+                result, _caveats = Update.new(request, _data)
+                log.debug('update = %r' % result)
+
+                updates.append(result)
+                caveats.extend(_caveats)
+
+            if len(releases) > 1:
+                result = dict(updates=updates)
     except LockedUpdateException as e:
-        request.errors.add('body', 'builds', "%s" % e)
+        log.warn(str(e))
+        request.errors.add('body', 'builds', "%s" % str(e))
         return
-
     except Exception as e:
-        log.exception(e)
-        request.errors.add('body', 'builds', 'Unable to create update')
+        log.exception('Failed to create update')
+        request.errors.add(
+            'body', 'builds', 'Unable to create update.  %s' % str(e))
         return
 
-    up.obsolete_older_updates(request)
+    # Obsolete older updates for three different cases...
+    # editing an update, submitting a new single update, submitting multiple.
+
+    if isinstance(result, dict):
+        updates = result['updates']
+    else:
+        updates = [result]
+
+    for update in updates:
+        try:
+            update.obsolete_older_updates(request)
+        except Exception as e:
+            caveats.append({
+                'name': 'update',
+                'description': 'Problem obsoleting older updates: %s' % str(e),
+            })
+
+    if not isinstance(result, dict):
+        result = result.__json__()
+
+    result['caveats'] = caveats
 
-    return up
+    return result
diff --git a/bodhi/validators.py b/bodhi/validators.py
--- a/bodhi/validators.py
+++ b/bodhi/validators.py
@@ -321,26 +321,30 @@ def validate_acls(request):
 
 
 def validate_uniqueness(request):
-    """ Check for multiple builds from the same package """
+    """ Check for multiple builds from the same package and same release """
     builds = request.validated.get('builds', [])
     if not builds:  # validate_nvr failed
         return
-    for build in builds:
-        nvr = request.buildinfo[build]['nvr']
+    for build1 in builds:
+        nvr1 = request.buildinfo[build1]['nvr']
         seen_build = 0
-        for other_build in builds:
-            other_build_nvr = request.buildinfo[other_build]['nvr']
-            if build == other_build:
+        for build2 in builds:
+            nvr2 = request.buildinfo[build2]['nvr']
+            if build1 == build2:
                 seen_build += 1
                 if seen_build > 1:
                     request.errors.add('body', 'builds', 'Duplicate builds: '
-                                       '{}'.format(build))
+                                       '{}'.format(build1))
                     return
                 continue
-            if nvr[0] == other_build_nvr[0]:
+
+            release1 = nvr1[-1].split('.')[-1]
+            release2 = nvr2[-1].split('.')[-1]
+
+            if nvr1[0] == nvr2[0] and release1 == release2:
                 request.errors.add('body', 'builds', "Multiple {} builds "
-                                   "specified: {} & {}".format(nvr[0], build,
-                                   other_build))
+                                   "specified: {} & {}".format(nvr1[0], build1,
+                                   build2))
                 return
 
 


Test patch:
diff --git a/bodhi/tests/functional/test_updates.py b/bodhi/tests/functional/test_updates.py
--- a/bodhi/tests/functional/test_updates.py
+++ b/bodhi/tests/functional/test_updates.py
@@ -1597,3 +1597,41 @@ def test_list_updates_by_alias_and_updateid(self):
         res = self.app.get('/updates/', {"updateid": 'BLARG'})
         body = res.json_body
         self.assertEquals(len(body['updates']), 0)
+
+    @mock.patch(**mock_valid_requirements)
+    @mock.patch('bodhi.notifications.publish')
+    def test_submitting_multi_release_updates(self, publish, *args):
+        """ https://github.com/fedora-infra/bodhi/issues/219 """
+        # Add another release and package
+        Release._tag_cache = None
+        release = Release(
+            name=u'F18', long_name=u'Fedora 18',
+            id_prefix=u'FEDORA', version=u'18',
+            dist_tag=u'f18', stable_tag=u'f18-updates',
+            testing_tag=u'f18-updates-testing',
+            candidate_tag=u'f18-updates-candidate',
+            pending_testing_tag=u'f18-updates-testing-pending',
+            pending_stable_tag=u'f18-updates-pending',
+            override_tag=u'f18-override',
+            branch=u'f18')
+        DBSession.add(release)
+        pkg = Package(name=u'nethack')
+        DBSession.add(pkg)
+
+        # A multi-release submission!!!  This should create *two* updates
+        args = self.get_update('bodhi-2.0.0-2.fc17,bodhi-2.0.0-2.fc18')
+        r = self.app.post_json('/updates/', args)
+        data = r.json_body
+
+        self.assertIn('caveats', data)
+        import pprint; pprint.pprint(data['caveats'])
+        self.assertEquals(len(data['caveats']), 1)
+        self.assertEquals(data['caveats'][0]['description'], "Your update is being split into 2, one for each release.")
+
+        self.assertIn('updates', data)
+        self.assertEquals(len(data['updates']), 2)
+
+
+        publish.assert_called_with(topic='update.request.testing', msg=ANY)
+        # Make sure two fedmsg messages were published
+        self.assertEquals(len(publish.call_args_list), 2)