From fe6ef8507fb13949e9f5c26a17bb3cb39b58836a Mon Sep 17 00:00:00 2001
From: Chenxiong Qi <cqi@redhat.com>
Date: Aug 15 2018 03:03:27 +0000
Subject: Allow to create update directly with CLI options


Resolves: #93 rhbz#1007157

Signed-off-by: Chenxiong Qi <cqi@redhat.com>

---

diff --git a/conf/bash-completion/fedpkg.bash b/conf/bash-completion/fedpkg.bash
index baa5491..8f7ea47 100644
--- a/conf/bash-completion/fedpkg.bash
+++ b/conf/bash-completion/fedpkg.bash
@@ -88,10 +88,11 @@ _fedpkg()
 
     local options=
     local options_target= options_arches= options_branch= options_string= options_file= options_dir= options_srpm= options_mroot= options_builder= options_namespace=
+    local options_update_type= options_update_request=
     local after= after_more=
 
     case $command in
-        help|gimmespec|gitbuildhash|giturl|lint|new|push|unused-patches|update|verrel)
+        help|gimmespec|gitbuildhash|giturl|lint|new|push|unused-patches|verrel)
             ;;
         build)
             options="--nowait --background --skip-tag --scratch"
@@ -221,10 +222,18 @@ _fedpkg()
             after="file"
             after_more=true
             ;;
+        update)
+            options="--not-close-bugs --suggest-reboot --disable-autokarma"
+            options_string="--notes --bugs --stable-karma --unstable-karma"
+            options_update_type="--type"
+            options_update_request="--request"
+            ;;
     esac
 
     local all_options="--help $options"
-    local all_options_value="$options_target $options_arches $options_branch $options_string $options_file $options_dir $options_srpm $options_mroot $options_builder $options_namespace"
+    local all_options_value="$options_target $options_arches $options_branch \
+    $options_string $options_file $options_dir $options_srpm $options_mroot \
+    $options_builder $options_namespace $options_update_type $options_update_request"
 
     # count non-option parameters
 
@@ -280,6 +289,12 @@ _fedpkg()
     elif [[ -n $options_namespace ]] && in_array "$prev" "$options_namespace"; then
         COMPREPLY=( $(compgen -W "$(_fedpkg_namespaces)" -- "$cur") )
 
+    elif [[ -n $options_update_type ]] && in_array "$prev" "$options_update_type"; then
+        COMPREPLY=( $(compgen -W "bugfix security enhancement newpackage" -- "$cur") )
+
+    elif [[ -n $options_update_request ]] && in_array "$prev" "$options_update_request"; then
+        COMPREPLY=( $(compgen -W "testing stable" -- "$cur") )
+
     else
         local after_options=
 
diff --git a/conf/zsh-completion/_fedpkg b/conf/zsh-completion/_fedpkg
index 054bf7e..f69b1ac 100644
--- a/conf/zsh-completion/_fedpkg
+++ b/conf/zsh-completion/_fedpkg
@@ -410,7 +410,16 @@ _fedpkg-retire () {
 (( $+functions[_fedpkg-update] )) ||
 _fedpkg-update () {
   _arguments -C \
-    '(-h --help)'{-h,--help}'[show help message and exit]'
+    '(-h --help)'{-h,--help}'[show help message and exit]' \
+    '(--type)'{--type}'[update type]' \
+    '(--request)'{--request}'[update request type]' \
+    '(--bugs)'{--bugs}'[bug numbers to be resolved]' \
+    '(--notes)'{--notes}'[update description]' \
+    '(--disable-autokarma)'{--disable-autokarma}'[disable karma automatism]' \
+    '(--stable-karma)'{--stable-karma}'[stable karma]' \
+    '(--unstable-karma)'{--unstable-karma}'[unstable karma]' \
+    '(--not-close-bugs)'{--not-close-bugs}'[do not close bugs automatically]' \
+    '(--suggest-reboot)'{--suggest-reboot}'[suggest reboot]'
 }
 
 (( $+functions[_fedpkg_commands] )) ||
diff --git a/fedpkg/cli.py b/fedpkg/cli.py
index 24e70be..38e737c 100644
--- a/fedpkg/cli.py
+++ b/fedpkg/cli.py
@@ -13,7 +13,6 @@
 from __future__ import print_function
 from pyrpkg.cli import cliClient
 import argparse
-import hashlib
 import io
 import os
 import re
@@ -40,6 +39,39 @@ from fedpkg.utils import (
 RELEASE_BRANCH_REGEX = r'^(f\d+|el\d+|epel\d+)$'
 LOCAL_PACKAGE_CONFIG = 'package.cfg'
 
+BODHI_TEMPLATE = """\
+[ %(nvr)s ]
+
+# bugfix, security, enhancement, newpackage (required)
+type=%(type_)s
+
+# testing, stable
+request=%(request)s
+
+# Bug numbers: 1234,9876
+bugs=%(bugs)s
+
+%(changelog)s
+# Here is where you give an explanation of your update.
+# Content can span multiple lines, as long as they are indented deeper than
+# the first line. For example,
+# notes=first line
+#     second line
+#     and so on
+notes=%(descr)s
+
+# Enable request automation based on the stable/unstable karma thresholds
+autokarma=%(autokarma)s
+stable_karma=%(stable_karma)s
+unstable_karma=%(unstable_karma)s
+
+# Automatically close bugs when this marked as stable
+close_bugs=%(close_bugs)s
+
+# Suggest that users restart after update
+suggest_reboot=%(suggest_reboot)s
+"""
+
 
 def check_bodhi_version():
     try:
@@ -95,12 +127,110 @@ class fedpkgClient(cliClient):
         retire_parser.set_defaults(command=self.retire)
 
     def register_update(self):
+        description = '''
+This will create a bodhi update request for the current package n-v-r.
+
+There are two ways to specify update details. Without any argument from command
+line, either update type or notes is omitted, a template editor will be shown
+and let you edit the detail information interactively.
+
+Alternatively, you could specify argument from command line to create an update
+directly, for example:
+
+    {0} update --type bugfix --notes 'Rebuilt' --bugs 1000 1002
+
+When all lines in template editor are commented out or deleted, the creation
+process is aborted. If the template keeps unchanged, {0} continues on creating
+update. That gives user a chance to confirm the auto-generated notes from
+change log if option --notes is omitted.
+'''.format(self.name)
+
         update_parser = self.subparsers.add_parser(
             'update',
+            formatter_class=argparse.RawDescriptionHelpFormatter,
             help='Submit last build as update',
-            description='This will create a bodhi update request for the '
-                        'current package n-v-r.'
+            description=description,
         )
+
+        def validate_stable_karma(value):
+            error = argparse.ArgumentTypeError(
+                'Stable karma must be an integer which is greater than zero.')
+            try:
+                karma = int(value)
+            except ValueError:
+                raise error
+            if karma <= 0:
+                raise error
+
+        def validate_unstable_karma(value):
+            error = argparse.ArgumentTypeError(
+                'Unstable karma must be an integer which is less than zero.')
+            try:
+                karma = int(value)
+            except ValueError:
+                raise error
+            if karma >= 0:
+                raise error
+
+        def validate_bugs(value):
+            if not value.isdigit():
+                raise argparse.ArgumentTypeError(
+                    'Invalid bug {0}. It should be an integer.'.format(value))
+
+        update_parser.add_argument(
+            '--type',
+            choices=['bugfix', 'security', 'enhancement', 'newpackage'],
+            dest='update_type',
+            help='Update type. Template editor will be shown if type is '
+                 'omitted.')
+        update_parser.add_argument(
+            '--request',
+            choices=['testing', 'stable'],
+            default='testing',
+            help='Requested repository.')
+        update_parser.add_argument(
+            '--bugs',
+            nargs='+',
+            type=validate_bugs,
+            help='Bug numbers. If omitted, bug numbers will be extracted from'
+                 ' change logs.')
+        update_parser.add_argument(
+            '--notes',
+            help='Update description. Multiple lines of notes could be '
+                 'specified. If omitted, template editor will be shown.')
+        update_parser.add_argument(
+            '--disable-autokarma',
+            action='store_false',
+            default=True,
+            dest='autokarma',
+            help='Karma automatism is enabled by default. Use this option to '
+                 'disable that.')
+        update_parser.add_argument(
+            '--stable-karma',
+            type=validate_stable_karma,
+            metavar='KARMA',
+            default=3,
+            help='Stable karma. Default is 3.')
+        update_parser.add_argument(
+            '--unstable-karma',
+            type=validate_unstable_karma,
+            metavar='KARMA',
+            default=-3,
+            help='Unstable karma. Default is -3.')
+        update_parser.add_argument(
+            '--not-close-bugs',
+            action='store_false',
+            default=True,
+            dest='close_bugs',
+            help='By default, update will be created by enabling to close bugs'
+                 ' automatically. If this is what you do not want, use this '
+                 'option to disable the default behavior.')
+        update_parser.add_argument(
+            '--suggest-reboot',
+            action='store_true',
+            default=False,
+            dest='suggest_reboot',
+            help='Suggest user to reboot after update. Default is False.')
         update_parser.set_defaults(command=self.update)
 
     def get_distgit_namespaces(self):
@@ -450,95 +580,105 @@ targets to build the package for a particular stream.
                             'Please try to reinstall %s or consult developers to see what '
                             'is wrong with it.' % self.name)
 
-    def update(self):
-        check_bodhi_version()
-        bodhi_config = self._get_bodhi_config()
-        template = """\
-[ %(nvr)s ]
-
-# bugfix, security, enhancement, newpackage (required)
-type=
-
-# testing, stable
-request=testing
-
-# Bug numbers: 1234,9876
-bugs=%(bugs)s
-
-%(changelog)s
-# Here is where you give an explanation of your update.
-# Content can span multiple lines, as long as they are indented deeper than
-# the first line. For example,
-# notes=first line
-#     second line
-#     and so on
-notes=%(descr)s
+    @staticmethod
+    def is_update_aborted(template_file):
+        """Check if the update is aborted
 
-# Enable request automation based on the stable/unstable karma thresholds
-autokarma=True
-stable_karma=3
-unstable_karma=-3
+        As long as the template file cannot be loaded by configparse, abort
+        immediately.
 
-# Automatically close bugs when this marked as stable
-close_bugs=True
-
-# Suggest that users restart after update
-suggest_reboot=False
-"""
+        From user's perspective, it is similar with aborting commit when option
+        -m is omitted. If all lines are commented out, abort.
+        """
+        config = configparser.ConfigParser()
+        try:
+            loaded_files = config.read(template_file)
+        except configparser.MissingSectionHeaderError:
+            return True
+        # Something wrong with the template, which causes it cannot be loaded.
+        if not loaded_files:
+            return True
+        # template can be loaded even if it's empty.
+        if not config.sections():
+            return True
+        return False
 
+    def _prepare_bodhi_template(self, template_file):
         bodhi_args = {
             'nvr': self.cmd.nvr,
             'bugs': six.u(''),
             'descr': six.u(
-                'Here is where you give an explanation of your update.')
+                'Here is where you give an explanation of your update.'),
+            'request': self.args.request,
+            'autokarma': str(self.args.autokarma),
+            'stable_karma': self.args.stable_karma,
+            'unstable_karma': self.args.unstable_karma,
+            'close_bugs': str(self.args.close_bugs),
+            'suggest_reboot': str(self.args.suggest_reboot),
         }
 
-        # Extract bug numbers from the latest changelog entry
+        if self.args.update_type:
+            bodhi_args['type_'] = self.args.update_type
+        else:
+            bodhi_args['type_'] = ''
+
         self.cmd.clog()
         clog_file = os.path.join(self.cmd.path, 'clog')
         with io.open(clog_file, encoding='utf-8') as f:
             clog = f.read()
-        bugs = re.findall(r'#([0-9]*)', clog)
-        if bugs:
-            bodhi_args['bugs'] = ','.join(bugs)
-
-        # Use clog as default message
-        bodhi_args['descr'], bodhi_args['changelog'] = \
-            self._format_update_clog(clog)
 
-        template = textwrap.dedent(template) % bodhi_args
+        if self.args.bugs:
+            bodhi_args['bugs'] = self.args.bugs
+        else:
+            # Extract bug numbers from the latest changelog entry
+            bugs = re.findall(r'#([0-9]*)', clog)
+            if bugs:
+                bodhi_args['bugs'] = ','.join(bugs)
+
+        if self.args.notes:
+            bodhi_args['descr'] = self.args.notes.replace('\n', '\n    ')
+            bodhi_args['changelog'] = ''
+        else:
+            # Use clog as default message
+            bodhi_args['descr'], bodhi_args['changelog'] = \
+                self._format_update_clog(clog)
 
-        # Calculate the hash of the unaltered template
-        orig_hash = hashlib.new('sha1')
-        orig_hash.update(template.encode('utf-8'))
-        orig_hash = orig_hash.hexdigest()
+        template = textwrap.dedent(BODHI_TEMPLATE) % bodhi_args
 
-        # Write out the template
-        with io.open('bodhi.template', 'w', encoding='utf-8') as f:
+        with io.open(template_file, 'w', encoding='utf-8') as f:
             f.write(template)
 
-        # Open the template in a text editor
-        editor = os.getenv('EDITOR', 'vi')
-        self.cmd._run_command([editor, 'bodhi.template'], shell=True)
+        if not self.args.update_type or not self.args.notes:
+            # Open the template in a text editor
+            editor = os.getenv('EDITOR', 'vi')
+            self.cmd._run_command([editor, template_file], shell=True)
+
+            # Check to see if we got a template written out.  Bail otherwise
+            if not os.path.isfile(template_file):
+                raise rpkgError('No bodhi update details saved!')
+
+            return not self.is_update_aborted(template_file)
+
+        return True
 
-        # Check to see if we got a template written out.  Bail otherwise
-        if not os.path.isfile('bodhi.template'):
-            raise rpkgError('No bodhi update details saved!')
+    def update(self):
+        check_bodhi_version()
+        bodhi_config = self._get_bodhi_config()
 
-        # If the template was changed, submit it to bodhi
-        new_hash = self.cmd.lookasidecache.hash_file('bodhi.template', 'sha1')
-        if new_hash != orig_hash:
+        bodhi_template_file = 'bodhi.template'
+        ready = self._prepare_bodhi_template(bodhi_template_file)
+
+        if ready:
             try:
-                self.cmd.update(bodhi_config, template='bodhi.template')
+                self.cmd.update(bodhi_config, template=bodhi_template_file)
             except Exception as e:
                 raise rpkgError('Could not generate update request: %s' % e)
+            finally:
+                os.unlink(bodhi_template_file)
+                os.unlink('clog')
         else:
             self.log.info('Bodhi update aborted!')
 
-        # Clean up
-        os.unlink('bodhi.template')
-        os.unlink('clog')
-
     def request_repo(self):
         self._request_repo(
             repo_name=self.args.name,
diff --git a/test/test_cli.py b/test/test_cli.py
index d58bd6a..20eae56 100644
--- a/test/test_cli.py
+++ b/test/test_cli.py
@@ -15,6 +15,7 @@ import io
 import json
 import os
 import pkg_resources
+import re
 import six
 import sys
 
@@ -40,14 +41,55 @@ from pyrpkg.errors import rpkgError
 from six.moves.configparser import NoOptionError
 from six.moves.configparser import NoSectionError
 from six.moves import StringIO
-from tempfile import mkdtemp
+from tempfile import mkdtemp, mkstemp
 from utils import CliTestCase
 
 
+class TestIsUpdateAborted(CliTestCase):
+    """Test is_update_aborted"""
+
+    require_test_repos = False
+
+    def setUp(self):
+        fd, self.bodhi_template = mkstemp()
+        os.close(fd)
+
+    def tearDown(self):
+        os.unlink(self.bodhi_template)
+
+    def _is_update_aborted(self):
+        with patch('sys.argv', new=['fedpkg', 'update']):
+            cli = self.new_cli()
+        return cli.is_update_aborted(self.bodhi_template)
+
+    def test_template_is_emtpy(self):
+        self.assertTrue(self._is_update_aborted())
+
+    def test_all_line_are_commented_out(self):
+        with io.open(self.bodhi_template, 'w', encoding='utf-8') as f:
+            f.write(six.u('# line 1\n#line 2\n#line 3\n'))
+
+        self.assertTrue(self._is_update_aborted())
+
+    def test_template_is_ok(self):
+        with io.open(self.bodhi_template, 'w', encoding='utf-8') as f:
+            f.write(six.u('[fedpkg-1.34-1.fc28]\ntype=\nnotes=abc\n'))
+
+        self.assertFalse(self._is_update_aborted())
+
+    def test_template_content_is_broken(self):
+        with io.open(self.bodhi_template, 'w', encoding='utf-8') as f:
+            f.write(six.u('#[fedpkg-1.34-1.fc28]\ntype=\nnotes=abc\n'))
+
+        self.assertTrue(self._is_update_aborted())
+
+
 @unittest.skipUnless(bodhi, 'Skip if no supported bodhi-client is available')
 class TestUpdate(CliTestCase):
     """Test update command"""
 
+    create_repo_per_test = False
+
     def setUp(self):
         super(TestUpdate, self).setUp()
 
@@ -60,17 +102,23 @@ class TestUpdate(CliTestCase):
         self.mock_run_command = self.run_command_patcher.start()
 
         # Let's always use the bodhi 2 command line to test here
-        self.check_bodhi_version_patcher = patch('fedpkg.cli.check_bodhi_version')
-        self.mock_check_bodhi_version = self.check_bodhi_version_patcher.start()
-
-        # Not write clog actually. Instead, file object will be mocked and
-        # return fake clog content for tests.
-        self.clog_patcher = patch('fedpkg.Commands.clog')
-        self.clog_patcher.start()
+        self.check_bodhi_version_patcher = patch(
+            'fedpkg.cli.check_bodhi_version')
+        self.mock_check_bodhi_version = \
+            self.check_bodhi_version_patcher.start()
 
         self.os_environ_patcher = patch.dict('os.environ', {'EDITOR': 'vi'})
         self.os_environ_patcher.start()
 
+        self.user_patcher = patch('pyrpkg.Commands.user',
+                                  return_value='someone')
+        self.mock_user = self.user_patcher.start()
+
+        # Not write clog actually.
+        self.clog_patcher = patch('fedpkg.Commands.clog')
+        self.clog_patcher.start()
+
+        # Logs will be read in the tests which do not specify --notes option
         self.fake_clog = list(six.moves.map(six.u, [
             'Add tests for command update',
             'New command update - #1000',
@@ -85,6 +133,7 @@ class TestUpdate(CliTestCase):
         if os.path.exists('bodhi.template'):
             os.unlink('bodhi.template')
         os.unlink(os.path.join(self.cloned_repo_path, 'clog'))
+        self.user_patcher.stop()
         self.os_environ_patcher.stop()
         self.clog_patcher.stop()
         self.check_bodhi_version_patcher.stop()
@@ -101,10 +150,12 @@ class TestUpdate(CliTestCase):
     # Do not operate OpenIDC session file with lock
     @patch('fedora.client.OpenIdBaseClient._load_cookies')
     def assert_bodhi_update(self, cli, _load_cookies, send_request, csrf,
-                            update_type=None, request_type=None):
+                            update_type=None, request_type=None, notes=None):
         csrf.return_value = '123456'
 
         def run_command_side_effect(command, shell):
+            # The real call accepts first argument as ['vi', 'bodhi.template'],
+            # command[-1] returns the filename.
             filename = command[-1]
             with io.open(filename, 'r', encoding='utf-8') as f:
                 content = f.read()
@@ -112,35 +163,42 @@ class TestUpdate(CliTestCase):
                 # Update parameters here for test
                 if update_type:
                     content = content.replace(
-                        'type=', 'type={0}'.format(update_type))
+                        'type=\n', 'type={0}\n'.format(update_type))
                 if request_type:
-                    content = content.replace(
-                        'request=', 'request={0}'.format(request_type))
+                    # CLI option --request has default value, so the template
+                    # contains the default value.
+                    content = re.sub('request=[a-z]+\n',
+                                     'request={0}\n'.format(request_type),
+                                     content)
                 f.write(content)
 
         self.mock_run_command.side_effect = run_command_side_effect
 
+        expected_data = {
+            'autokarma': 'True',
+            'bugs': '1000,2000',
+            'builds': ' {0} '.format(self.mock_nvr.return_value),
+            'close_bugs': True,
+            'request': 'testing',
+            'severity': 'unspecified',
+            'suggest': 'unspecified',
+            'type': update_type,
+            'type_': update_type,
+            'stable_karma': '3',
+            'unstable_karma': '-3',
+            'csrf_token': csrf.return_value,
+        }
+        if notes:
+            expected_data['notes'] = notes
+        else:
+            expected_data['notes'] = self.fake_clog[0]
+
         with patch('os.unlink') as unlink:
             cli.update()
 
             csrf.assert_called_once_with()
             send_request.assert_called_once_with(
-                'updates/', verb='POST', auth=True,
-                data={
-                    'autokarma': 'True',
-                    'bugs': '1000,2000',
-                    'builds': ' {0} '.format(self.mock_nvr.return_value),
-                    'close_bugs': True,
-                    'notes': self.fake_clog[0],
-                    'request': 'testing',
-                    'severity': 'unspecified',
-                    'suggest': 'unspecified',
-                    'type': update_type,
-                    'type_': update_type,
-                    'stable_karma': '3',
-                    'unstable_karma': '-3',
-                    'csrf_token': csrf.return_value,
-                })
+                'updates/', verb='POST', auth=True, data=expected_data)
 
             unlink.assert_has_calls([
                 call('bodhi.template'),
@@ -151,11 +209,14 @@ class TestUpdate(CliTestCase):
             bodhi_template = f.read()
         self.assertTrue(self.mock_nvr.return_value in bodhi_template)
         self.assertTrue('1000,2000' in bodhi_template)
-        self.assertTrue(self.fake_clog[0] in bodhi_template)
-        rest_clog = os.linesep.join([
-            six.u('# {0}').format(line) for line in self.fake_clog[1:]
-        ])
-        self.assertTrue(rest_clog in bodhi_template)
+        if notes:
+            self.assertTrue(notes.replace('\n', '\n    ') in bodhi_template)
+        else:
+            self.assertTrue(self.fake_clog[0] in bodhi_template)
+            rest_clog = os.linesep.join([
+                six.u('# {0}').format(line) for line in self.fake_clog[1:]
+            ])
+            self.assertTrue(rest_clog in bodhi_template)
 
     def test_fail_if_missing_config_options(self):
         cli_cmd = ['fedpkg', '--path', self.cloned_repo_path, 'update']
@@ -183,14 +244,7 @@ class TestUpdate(CliTestCase):
         self.mock_run_command.assert_called_once_with(
             ['vi', 'bodhi.template'], shell=True)
 
-    @patch('hashlib.new')
-    @patch('fedpkg.lookaside.FedoraLookasideCache.hash_file')
-    @patch('fedpkg.Commands.user', new_callable=PropertyMock)
-    def test_request_update(self, user, hash_file, hashlib_new):
-        user.return_value = 'cqi'
-        hashlib_new.return_value.hexdigest.return_value = 'origin hash'
-        hash_file.return_value = 'different hash'
-
+    def test_request_update(self):
         cli_cmd = ['fedpkg', '--path', self.cloned_repo_path, 'update']
 
         cli = self.get_cli(cli_cmd)
@@ -199,14 +253,8 @@ class TestUpdate(CliTestCase):
         self.mock_run_command.assert_called_once_with(
             ['vi', 'bodhi.template'], shell=True)
 
-    @patch('hashlib.new')
-    @patch('fedpkg.lookaside.FedoraLookasideCache.hash_file')
     @patch('fedpkg.Commands.update', side_effect=OSError)
-    def test_handle_any_errors_raised_when_execute_bodhi(
-            self, update, hash_file, hashlib_new):
-        hashlib_new.return_value.hexdigest.return_value = 'origin hash'
-        hash_file.return_value = 'different hash'
-
+    def test_handle_any_errors_raised_when_execute_bodhi(self, update):
         cli_cmd = ['fedpkg', '--path', self.cloned_repo_path, 'update']
 
         cli = self.get_cli(cli_cmd)
@@ -214,14 +262,7 @@ class TestUpdate(CliTestCase):
             self, rpkgError, 'Could not generate update request',
             self.assert_bodhi_update, cli)
 
-    @patch('hashlib.new')
-    @patch('fedpkg.lookaside.FedoraLookasideCache.hash_file')
-    @patch('fedpkg.Commands.user', new_callable=PropertyMock)
-    def test_create_update_in_stage_bodhi(self, user, hash_file, hashlib_new):
-        user.return_value = 'someone'
-        hashlib_new.return_value.hexdigest.return_value = 'origin hash'
-        hash_file.return_value = 'different hash'
-
+    def test_create_update_in_stage_bodhi(self):
         cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path, 'update']
         cli = self.get_cli(cli_cmd,
                            name='fedpkg-stage',
@@ -231,43 +272,19 @@ class TestUpdate(CliTestCase):
         self.mock_run_command.assert_called_once_with(
             ['vi', 'bodhi.template'], shell=True)
 
-    @patch('hashlib.new')
-    @patch('fedpkg.lookaside.FedoraLookasideCache.hash_file')
-    @patch('fedpkg.Commands.user', new_callable=PropertyMock)
-    def test_missing_update_type_in_template(
-            self, user, hash_file, hashlib_new):
-        user.return_value = 'someone'
-        hashlib_new.return_value.hexdigest.return_value = 'origin hash'
-        hash_file.return_value = 'different hash'
-
+    def test_missing_update_type_in_template(self):
         cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path, 'update']
         cli = self.get_cli(cli_cmd)
         six.assertRaisesRegex(self, rpkgError, 'Missing update type',
                               self.assert_bodhi_update, cli)
 
-    @patch('hashlib.new')
-    @patch('fedpkg.lookaside.FedoraLookasideCache.hash_file')
-    @patch('fedpkg.Commands.user', new_callable=PropertyMock)
-    def test_incorrect_update_type_in_template(
-            self, user, hash_file, hashlib_new):
-        user.return_value = 'someone'
-        hashlib_new.return_value.hexdigest.return_value = 'origin hash'
-        hash_file.return_value = 'different hash'
-
+    def test_incorrect_update_type_in_template(self):
         cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path, 'update']
         cli = self.get_cli(cli_cmd)
         six.assertRaisesRegex(self, rpkgError, 'Incorrect update type',
                               self.assert_bodhi_update, cli, update_type='xxx')
 
-    @patch('hashlib.new')
-    @patch('fedpkg.lookaside.FedoraLookasideCache.hash_file')
-    @patch('fedpkg.Commands.user', new_callable=PropertyMock)
-    def test_incorrect_request_type_in_template(
-            self, user, hash_file, hashlib_new):
-        user.return_value = 'someone'
-        hashlib_new.return_value.hexdigest.return_value = 'origin hash'
-        hash_file.return_value = 'different hash'
-
+    def test_incorrect_request_type_in_template(self):
         cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path, 'update']
         cli = self.get_cli(cli_cmd)
         six.assertRaisesRegex(self, rpkgError, 'Incorrect request type',
@@ -275,6 +292,90 @@ class TestUpdate(CliTestCase):
                               update_type='enhancement',
                               request_type='xxx')
 
+    def test_create_with_cli_options(self):
+        cli_cmd = [
+            'fedpkg-stage', '--path', self.cloned_repo_path,
+            'update', '--type', 'bugfix', '--notes', 'Mass rebuild'
+        ]
+
+        cli = self.get_cli(cli_cmd)
+
+        self.assert_bodhi_update(
+            cli, update_type='bugfix', notes='Mass rebuild')
+        self.mock_run_command.assert_not_called()
+
+    def test_show_editor_if_update_type_is_omitted(self):
+        cli_cmd = [
+            'fedpkg-stage', '--path', self.cloned_repo_path,
+            'update', '--notes', 'Mass rebuild'
+        ]
+
+        cli = self.get_cli(cli_cmd)
+
+        self.assert_bodhi_update(
+            cli, update_type='enhancement', notes='Mass rebuild')
+        self.mock_run_command.assert_called_once_with(
+            ['vi', 'bodhi.template'], shell=True)
+
+    def test_show_editor_if_notes_is_omitted(self):
+        cli_cmd = [
+            'fedpkg-stage', '--path', self.cloned_repo_path,
+            'update', '--type', 'bugfix'
+        ]
+
+        cli = self.get_cli(cli_cmd)
+
+        self.assert_bodhi_update(cli, update_type='bugfix')
+        self.mock_run_command.assert_called_once_with(
+            ['vi', 'bodhi.template'], shell=True)
+
+    def test_create_with_notes_in_multiple_lines(self):
+        cli_cmd = [
+            'fedpkg-stage', '--path', self.cloned_repo_path,
+            'update', '--type', 'bugfix', '--notes', 'Line 1\nLine 2\nLine 3'
+        ]
+
+        cli = self.get_cli(cli_cmd)
+
+        self.assert_bodhi_update(
+            cli, update_type='bugfix', notes='Line 1\nLine 2\nLine 3')
+
+    @patch('sys.stderr', new=six.StringIO())
+    def test_invalid_stable_karma_option(self):
+        with self.assertRaises(SystemExit):
+            self.get_cli([
+                'fedpkg-stage', '--path', self.cloned_repo_path,
+                'update', '--stable-karma', '10s'
+            ])
+
+        with self.assertRaises(SystemExit):
+            self.get_cli([
+                'fedpkg-stage', '--path', self.cloned_repo_path,
+                'update', '--stable-karma', '-3'
+            ])
+
+    @patch('sys.stderr', new=six.StringIO())
+    def test_invalid_unstable_karma_option(self):
+        with self.assertRaises(SystemExit):
+            self.get_cli([
+                'fedpkg-stage', '--path', self.cloned_repo_path,
+                'update', '--unstable-karma', '10s'
+            ])
+
+        with self.assertRaises(SystemExit):
+            self.get_cli([
+                'fedpkg-stage', '--path', self.cloned_repo_path,
+                'update', '--unstable-karma', '3'
+            ])
+
+    @patch('sys.stderr', new=six.StringIO())
+    def test_invalid_bug(self):
+        with self.assertRaises(SystemExit):
+            self.get_cli([
+                'fedpkg-stage', '--path', self.cloned_repo_path,
+                'update', '--bugs', '1000', '1001', '100l'
+            ])
+
 
 @patch.object(BugzillaClient, 'client')
 class TestRequestRepo(CliTestCase):
diff --git a/test/utils.py b/test/utils.py
index 45d7c02..d677287 100644
--- a/test/utils.py
+++ b/test/utils.py
@@ -59,7 +59,8 @@ class Assertions(object):
 
 class Utils(object):
 
-    def run_cmd(self, cmd, allow_output=None, **kwargs):
+    @staticmethod
+    def run_cmd(cmd, allow_output=None, **kwargs):
         if not allow_output:
             kwargs.update({
                 'stdout': subprocess.PIPE,
@@ -105,7 +106,8 @@ class fedpkgConfig(object):
 fedpkg_test_config = fedpkgConfig()
 
 
-class CommandTestCase(Assertions, Utils, unittest.TestCase):
+class RepoCreationMixin(object):
+    """Mixin providing method to create repos for running tests"""
 
     spec_file_content = '''Summary: Dummy summary
 Name: docpkg
@@ -134,19 +136,20 @@ rm -rf $$RPM_BUILD_ROOT
 - Initial version
 '''
 
-    def setUp(self):
+    @classmethod
+    def create_fake_repos(cls):
         # create a base repo
-        self.repo_base = tempfile.mkdtemp(prefix='fedpkg-commands-tests-')
+        cls.repo_base = tempfile.mkdtemp(prefix='fedpkg-commands-tests-')
         # Have a namespace of rpms and a repo name of testpkg
-        self.repo_path = os.path.join(self.repo_base, 'rpms', 'testpkg')
-        os.makedirs(self.repo_path)
+        cls.repo_path = os.path.join(cls.repo_base, 'rpms', 'testpkg')
+        os.makedirs(cls.repo_path)
 
-        self.spec_filename = 'docpkg.spec'
+        cls.spec_filename = 'docpkg.spec'
 
         # Add spec file to this repo and commit
-        spec_file_path = os.path.join(self.repo_path, self.spec_filename)
+        spec_file_path = os.path.join(cls.repo_path, cls.spec_filename)
         with open(spec_file_path, 'w') as f:
-            f.write(self.spec_file_content)
+            f.write(cls.spec_file_content)
 
         git_cmds = [
             ['git', 'init'],
@@ -163,12 +166,12 @@ rm -rf $$RPM_BUILD_ROOT
             ['git', 'branch', '8'],
             ]
         for cmd in git_cmds:
-            self.run_cmd(cmd, cwd=self.repo_path)
+            cls.run_cmd(cmd, cwd=cls.repo_path)
 
         # Clone the repo
-        self.cloned_repo_path = tempfile.mkdtemp(
+        cls.cloned_repo_path = tempfile.mkdtemp(
             prefix='fedpkg-commands-tests-cloned-')
-        self.run_cmd(['git', 'clone', self.repo_path, self.cloned_repo_path])
+        cls.run_cmd(['git', 'clone', cls.repo_path, cls.cloned_repo_path])
         git_cmds = [
             ['git', 'config', 'user.email', 'tester@example.com'],
             ['git', 'config', 'user.name', 'tester'],
@@ -178,11 +181,36 @@ rm -rf $$RPM_BUILD_ROOT
             ['git', 'branch', '--track', '8', 'origin/8'],
             ]
         for cmd in git_cmds:
-            self.run_cmd(cmd, cwd=self.cloned_repo_path)
+            cls.run_cmd(cmd, cwd=cls.cloned_repo_path)
+
+    @classmethod
+    def destroy_fake_repos(cls):
+        shutil.rmtree(cls.repo_base)
+        shutil.rmtree(cls.cloned_repo_path)
+
+
+class CommandTestCase(RepoCreationMixin, Assertions, Utils, unittest.TestCase):
+
+    create_repo_per_test = True
+    require_test_repos = True
+
+    @classmethod
+    def setUpClass(cls):
+        if cls.require_test_repos and not cls.create_repo_per_test:
+            cls.create_fake_repos()
+
+    @classmethod
+    def tearDownClass(cls):
+        if cls.require_test_repos and not cls.create_repo_per_test:
+            cls.destroy_fake_repos()
+
+    def setUp(self):
+        if self.require_test_repos and self.create_repo_per_test:
+            self.create_fake_repos()
 
     def tearDown(self):
-        shutil.rmtree(self.repo_base)
-        shutil.rmtree(self.cloned_repo_path)
+        if self.require_test_repos and self.create_repo_per_test:
+            self.destroy_fake_repos()
 
     def make_commands(self, path=None, user=None, dist=None, target=None,
                       quiet=None):

