Repository: fedora-infra/bodhi

Issue:
[S] The CLI needs a way for users to create side tag updates
Similarly to #2324, the CLI also needs to be able to create side tag updates. This will also enable ```fedpkg``` to create side tag updates via the Bodhi CLI, if they like.


Discussion:
@Zlopez as discussed, here are the couple changes I had in my worktree:

```
diff --git a/bodhi/client/__init__.py b/bodhi/client/__init__.py
index dfd7ab38..130bd8c6 100644
--- a/bodhi/client/__init__.py
+++ b/bodhi/client/__init__.py
@@ -113,6 +113,8 @@ new_edit_options = [
                  help='Space or comma-separated list of required Taskotron tasks'),
     click.option('--suggest', help='Post-update user suggestion',
                  type=click.Choice(['logout', 'reboot'])),
+    click.option('--from-tag', help='Use builds from a Koji tag instead of '
+                                    'specifying them individually.'),
     staging_option]
 
 
@@ -392,8 +394,14 @@ def new(user, password, url, debug, openid_api, **kwargs):
         debug (bool): If the --debug flag was set
         openid_api (str): A URL for an OpenID API to use to authenticate to Bodhi.
         kwargs (dict): Other keyword arguments passed to us by click.
+    Raises:
+        click.BadOptionUsage: If both builds and --from-tag are specified.
     """
 
+    if kwargs['builds'] and kwargs['from_tag']:
+        raise click.BadOptionUsage('--from-tag',
+                                   "Can't use --from-tag and specify individual builds together.")
+
     client = bindings.BodhiClient(base_url=url, username=user, password=password,
                                   staging=kwargs['staging'], openid_api=openid_api)
 
diff --git a/bodhi/client/bindings.py b/bodhi/client/bindings.py
index b30577bc..6c96b741 100644
--- a/bodhi/client/bindings.py
+++ b/bodhi/client/bindings.py
@@ -249,6 +249,8 @@ class BodhiClient(OpenIdBaseClient):
                 update have been confirmed by testers.
             require_testcases (bool): A boolean to require that this update passes
                 all test cases before reaching stable.
+            from_tag (str): The name of a Koji tag from which to pull builds
+                instead of providing them manually in `builds`.
         Returns:
             munch.Munch: The Bodhi server's response to the request.
         """
```

You can use that as a starting point or inspiration. It might even only need tests :wink: but I'm not 100% sure.

Resolving patch:
diff --git a/bodhi/client/__init__.py b/bodhi/client/__init__.py
--- a/bodhi/client/__init__.py
+++ b/bodhi/client/__init__.py
@@ -134,6 +134,9 @@ def _set_logging_debug(
                  type=click.Choice(['logout', 'reboot'])),
     click.option('--display-name',
                  help='The name of the update'),
+    click.option('--from-tag', help='Use builds from a Koji tag instead of specifying '
+                                    'them individually.',
+                 is_flag=True),
     staging_option]
 
 
@@ -395,7 +398,7 @@ def require_severity_for_security_update(type: str, severity: str):
 @click.option('--type', default='bugfix', help='Update type', required=True,
               type=click.Choice(['security', 'bugfix', 'enhancement', 'newpackage']))
 @add_options(new_edit_options)
-@click.argument('builds')
+@click.argument('builds_or_tag')
 @click.option('--file', help='A text file containing all the update details')
 @handle_errors
 @openid_option
@@ -419,10 +422,20 @@ def new(user: str, password: str, url: str, debug: bool, openid_api: str, **kwar
         openid_api: A URL for an OpenID API to use to authenticate to Bodhi.
         kwargs: Other keyword arguments passed to us by click.
     """
-
     client = bindings.BodhiClient(base_url=url, username=user, password=password,
                                   staging=kwargs['staging'], openid_api=openid_api)
 
+    # Because bodhi.server.services.updates expects from_tag to be string
+    # copy builds to from_tag and remove builds
+    if kwargs['from_tag']:
+        if len(kwargs['builds_or_tag'].split(' ')) > 1:
+            click.echo("ERROR: Can't specify more than one tag.", err=True)
+            sys.exit(1)
+        kwargs['from_tag'] = kwargs.pop('builds_or_tag')
+    else:
+        kwargs['builds'] = kwargs.pop('builds_or_tag')
+        del kwargs['from_tag']
+
     if kwargs['file'] is None:
         updates = [kwargs]
 
@@ -432,7 +445,7 @@ def new(user: str, password: str, url: str, debug: bool, openid_api: str, **kwar
     kwargs['notes'] = _get_notes(**kwargs)
 
     if not kwargs['notes'] and not kwargs['file']:
-        click.echo("ERROR: must specify at least one of --file, --notes, or --notes-file")
+        click.echo("ERROR: must specify at least one of --file, --notes, or --notes-file", err=True)
         sys.exit(1)
 
     for update in updates:
@@ -517,13 +530,32 @@ def edit(user: str, password: str, url: str, debug: bool, openid_api: str, **kwa
 
         kwargs['builds'] = [b['nvr'] for b in former_update['builds']]
         kwargs['edited'] = former_update['alias']
-        if kwargs['addbuilds']:
-            for build in kwargs['addbuilds'].split(','):
-                if build not in kwargs['builds']:
-                    kwargs['builds'].append(build)
-        if kwargs['removebuilds']:
-            for build in kwargs['removebuilds'].split(','):
-                kwargs['builds'].remove(build)
+        # Because bodhi.server.services.updates expects from_tag to be string
+        # copy builds to from_tag and remove builds
+        if kwargs['from_tag']:
+            if not former_update.get('from_tag', None):
+                click.echo(
+                    "ERROR: This update was not created from a tag."
+                    " Please remove --from_tag and try again.", err=True
+                )
+                sys.exit(1)
+            if kwargs['addbuilds'] or kwargs['removebuilds']:
+                click.echo(
+                    "ERROR: The --from-tag option can't be used together with"
+                    " --addbuilds or --removebuilds.", err=True
+                )
+                sys.exit(1)
+            kwargs['from_tag'] = former_update['from_tag']
+            del kwargs['builds']
+        else:
+            kwargs.pop('from_tag')
+            if kwargs['addbuilds']:
+                for build in kwargs['addbuilds'].split(','):
+                    if build not in kwargs['builds']:
+                        kwargs['builds'].append(build)
+            if kwargs['removebuilds']:
+                for build in kwargs['removebuilds'].split(','):
+                    kwargs['builds'].remove(build)
         del kwargs['addbuilds']
         del kwargs['removebuilds']
 
diff --git a/bodhi/client/bindings.py b/bodhi/client/bindings.py
--- a/bodhi/client/bindings.py
+++ b/bodhi/client/bindings.py
@@ -268,6 +268,8 @@ def save(self, **kwargs) -> 'munch.Munch':
                 update have been confirmed by testers.
             require_testcases (bool): A boolean to require that this update passes
                 all test cases before reaching stable.
+            from_tag (str): The name of a Koji tag from which to pull builds
+                instead of providing them manually in `builds`.
         Returns:
             The Bodhi server's response to the request.
         """


Test patch:
diff --git a/bodhi/tests/client/test___init__.py b/bodhi/tests/client/test___init__.py
--- a/bodhi/tests/client/test___init__.py
+++ b/bodhi/tests/client/test___init__.py
@@ -757,6 +757,63 @@ def test_display_name_flag(self, send_request):
         self.assertEqual(send_request.mock_calls, calls)
         self.assertEqual(bindings_client.base_url, 'http://localhost:6543/')
 
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request',
+                return_value=client_test_data.EXAMPLE_UPDATE_MUNCH, autospec=True)
+    def test_from_tag_flag(self, send_request):
+        """
+        Assert correct behavior with the --from-tag flag.
+        """
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.new,
+            ['--user', 'bowlofeggs', '--password', 's3kr3t', '--autokarma', 'fake_tag',
+             '--bugs', '1234567', '--from-tag', '--url',
+             'http://localhost:6543', '--notes', 'No description.'])
+
+        self.assertEqual(result.exit_code, 0)
+        expected_output = client_test_data.EXPECTED_UPDATE_OUTPUT.replace('example.com/tests',
+                                                                          'localhost:6543')
+        self.assertTrue(compare_output(result.output, expected_output + '\n'))
+        bindings_client = send_request.mock_calls[0][1][0]
+        calls = [
+            mock.call(
+                bindings_client, 'updates/', auth=True, verb='POST',
+                data={
+                    'close_bugs': False, 'stable_karma': None, 'csrf_token': 'a_csrf_token',
+                    'staging': False, 'autokarma': True, 'autotime': False, 'stable_days': None,
+                    'suggest': None, 'notes': 'No description.', 'request': None,
+                    'bugs': '1234567', 'requirements': None, 'unstable_karma': None, 'file': None,
+                    'notes_file': None, 'type': 'bugfix', 'severity': None, 'display_name': None,
+                    'from_tag': 'fake_tag'
+                }
+            ),
+            mock.call(
+                bindings_client,
+                'updates/FEDORA-EPEL-2016-3081a94111/get-test-results',
+                verb='GET'
+            )
+        ]
+        self.assertEqual(send_request.mock_calls, calls)
+        self.assertEqual(bindings_client.base_url, 'http://localhost:6543/')
+
+    def test_from_tag_flag_multiple_tags(self):
+        """
+        Assert correct behavior with the --from-tag and multiple tags.
+        """
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.new,
+            ['--user', 'bowlofeggs', '--password', 's3kr3t', '--autokarma', 'fake tag',
+             '--bugs', '1234567', '--from-tag', '--url',
+             'http://localhost:6543', '--notes', 'No description.'])
+
+        self.assertEqual(result.exit_code, 1)
+        self.assertEqual(result.output, 'ERROR: Can\'t specify more than one tag.\n')
+
     def test_new_update_without_notes(self):
         """
         Assert providing neither --notes-file nor --notes parameters to new update request
@@ -783,7 +840,7 @@ def test_new_security_update_with_unspecified_severity(self):
              '--notes', 'bla bla bla', '--type', 'security'])
 
         self.assertEqual(result.exit_code, 2)
-        self.assertEqual(result.output, ('Usage: new [OPTIONS] BUILDS\n\nError: Invalid '
+        self.assertEqual(result.output, ('Usage: new [OPTIONS] BUILDS_OR_TAG\n\nError: Invalid '
                          'value for severity: must specify severity for a security update\n'))
 
 
@@ -1838,6 +1895,133 @@ def test_addbuilds_removebuilds(self, send_request, query):
         self.assertEqual(send_request.mock_calls, calls)
         self.assertEqual(bindings_client.base_url, 'http://localhost:6543/')
 
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.query', autospec=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient.send_request',
+                return_value=client_test_data.EXAMPLE_UPDATE_MUNCH, autospec=True)
+    def test_from_tag_flag(self, send_request, query):
+        """
+        Assert correct behavior with the --from-tag flag.
+        """
+        data = client_test_data.EXAMPLE_QUERY_MUNCH.copy()
+        data.updates[0]['from_tag'] = 'fake_tag'
+        query.return_value = data
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.edit, ['FEDORA-2017-c95b33872d', '--user', 'bowlofeggs',
+                          '--password', 's3kr3t', '--from-tag',
+                          '--notes', 'Updated package.',
+                          '--url', 'http://localhost:6543'])
+
+        self.assertEqual(result.exit_code, 0)
+        bindings_client = query.mock_calls[0][1][0]
+        query.assert_called_with(
+            bindings_client, updateid='FEDORA-2017-c95b33872d')
+        bindings_client = send_request.mock_calls[0][1][0]
+        calls = [
+            mock.call(
+                bindings_client, 'updates/', auth=True, verb='POST',
+                data={
+                    'close_bugs': False, 'stable_karma': 3, 'csrf_token': 'a_csrf_token',
+                    'autokarma': False, 'edited': 'FEDORA-2017-c95b33872d',
+                    'suggest': 'unspecified', 'notes': 'Updated package.',
+                    'notes_file': None, 'request': None, 'unstable_karma': -3,
+                    'bugs': '1420605', 'requirements': '', 'type': 'newpackage',
+                    'severity': u'low', 'display_name': None, 'autotime': False,
+                    'stable_days': None, 'from_tag': 'fake_tag',
+                    'staging': False,
+                }
+            ),
+            mock.call(
+                bindings_client,
+                'updates/FEDORA-EPEL-2016-3081a94111/get-test-results',
+                verb='GET'
+            )
+        ]
+        self.assertEqual(send_request.mock_calls, calls)
+        self.assertEqual(bindings_client.base_url, 'http://localhost:6543/')
+
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.query',
+                return_value=client_test_data.EXAMPLE_QUERY_MUNCH, autospec=True)
+    def test_from_tag_flag_no_tag(self, query):
+        """
+        Assert --from-tag bails out if the update wasn't created from a tag.
+        """
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.edit, ['FEDORA-2017-c95b33872d', '--user', 'bowlofeggs',
+                          '--password', 's3kr3t', '--from-tag',
+                          '--notes', 'Updated package.',
+                          '--url', 'http://localhost:6543'])
+
+        self.assertEqual(result.exit_code, 1)
+        self.assertEqual(result.output, "ERROR: This update was not created from a tag."
+                                        " Please remove --from_tag and try again.\n")
+        bindings_client = query.mock_calls[0][1][0]
+        query.assert_called_with(
+            bindings_client, updateid='FEDORA-2017-c95b33872d')
+
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.query', autospec=True)
+    def test_from_tag_addbuilds(self, query):
+        """
+        Assert --from-tag can't be used with --addbuilds.
+        """
+        data = client_test_data.EXAMPLE_QUERY_MUNCH.copy()
+        data.updates[0]['from_tag'] = 'fake_tag'
+        query.return_value = data
+
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.edit, ['FEDORA-2017-c95b33872d', '--user', 'bowlofeggs',
+                          '--password', 's3kr3t', '--from-tag',
+                          '--addbuilds', 'tar-1.29-4.fc25,nedit-5.7-1.fc25',
+                          '--notes', 'Updated package.',
+                          '--url', 'http://localhost:6543'])
+
+        self.assertEqual(result.exit_code, 1)
+        self.assertEqual(result.output, "ERROR: The --from-tag option can't be used together with"
+                                        " --addbuilds or --removebuilds.\n")
+        bindings_client = query.mock_calls[0][1][0]
+        query.assert_called_with(
+            bindings_client, updateid='FEDORA-2017-c95b33872d')
+
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.query', autospec=True)
+    def test_from_tag_removebuilds(self, query):
+        """
+        Assert --from-tag can't be used with --removebuilds.
+        """
+        data = client_test_data.EXAMPLE_QUERY_MUNCH.copy()
+        data.updates[0]['from_tag'] = 'fake_tag'
+        query.return_value = data
+
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.edit, ['FEDORA-2017-c95b33872d', '--user', 'bowlofeggs',
+                          '--password', 's3kr3t', '--from-tag',
+                          '--removebuilds', 'nodejs-grunt-wrap-0.3.0-2.fc25',
+                          '--notes', 'Updated package.',
+                          '--url', 'http://localhost:6543'])
+
+        print(result.output)
+
+        self.assertEqual(result.exit_code, 1)
+        self.assertEqual(result.output, "ERROR: The --from-tag option can't be used together with"
+                                        " --addbuilds or --removebuilds.\n")
+        bindings_client = query.mock_calls[0][1][0]
+        query.assert_called_with(
+            bindings_client, updateid='FEDORA-2017-c95b33872d')
+
     def test_notes_and_notes_file(self):
         """
         Assert providing both --notes-file and --notes parameters to an otherwise successful