From f7d9b5684e503fc0b28695f5dc745f4b24308308 Mon Sep 17 00:00:00 2001
From: mprahl <mprahl@redhat.com>
Date: Dec 13 2017 22:35:08 +0000
Subject: Port fedrepo-req and fedrepo-req-branch to fedpkg


Signed-off-by: mprahl <mprahl@redhat.com>

---

diff --git a/conf/bash-completion/fedpkg.bash b/conf/bash-completion/fedpkg.bash
index 57edb5c..03fa399 100644
--- a/conf/bash-completion/fedpkg.bash
+++ b/conf/bash-completion/fedpkg.bash
@@ -33,13 +33,14 @@ _fedpkg()
     # global options
 
     local options="--help -v -q"
-    local options_value="--dist --release --user --path"
+    local options_value="--dist --release --user --path --user-config"
     local commands="build chain-build ci clean clog clone co commit compile \
     container-build diff gimmespec giturl help gitbuildhash import install lint \
     local mockbuild mock-config module-build module-build-cancel \
     module-build-local module-build-info module-build-watch module-overview \
-    new new-sources patch prep pull push retire scratch-build sources srpm \
-    switch-branch tag unused-patches update upload verify-files verrel"
+    new new-sources patch prep pull push retire request-branch request-repo \
+    scratch-build sources srpm switch-branch tag unused-patches update upload \
+    verify-files verrel"
 
     # parse main options and get command
 
@@ -189,6 +190,13 @@ _fedpkg()
         retire)
             after_more=true
             ;;
+        request-branch)
+            options="--no-git-branch --all-releases"
+            options_string="--sl"
+        request-repo)
+            options="--exception"
+            options_string="--description --monitor --upstreamurl --summary"
+            ;;
         scratch-build)
             options="--nowait --background"
             options_target="--target"
diff --git a/conf/etc/rpkg/fedpkg-stage.conf b/conf/etc/rpkg/fedpkg-stage.conf
index 91cdb02..5a82141 100644
--- a/conf/etc/rpkg/fedpkg-stage.conf
+++ b/conf/etc/rpkg/fedpkg-stage.conf
@@ -29,3 +29,13 @@ oidc_id_provider = https://id.stg.fedoraproject.org/openidc/
 oidc_client_id = mbs-authorizer
 oidc_client_secret = notsecret
 oidc_scopes = openid,https://id.fedoraproject.org/scope/groups,https://mbs.fedoraproject.org/oidc/submit-build
+
+[fedpkg-stage.bugzilla]
+# Use production Bugzilla for read-only data
+url = https://bugzilla.redhat.com/
+
+[fedpkg-stage.pagure]
+url = https://stg.pagure.io/
+
+[fedpkg-stage.pdc]
+url = https://pdc.stg.fedoraproject.org/
diff --git a/conf/etc/rpkg/fedpkg.conf b/conf/etc/rpkg/fedpkg.conf
index 993ba03..4e45ebc 100644
--- a/conf/etc/rpkg/fedpkg.conf
+++ b/conf/etc/rpkg/fedpkg.conf
@@ -34,3 +34,12 @@ oidc_id_provider = https://id.fedoraproject.org/openidc/
 oidc_client_id = mbs-authorizer
 oidc_client_secret = notsecret
 oidc_scopes = openid,https://id.fedoraproject.org/scope/groups,https://mbs.fedoraproject.org/oidc/submit-build
+
+[fedpkg.bugzilla]
+url = https://bugzilla.redhat.com/
+
+[fedpkg.pagure]
+url = https://pagure.io/
+
+[fedpkg.pdc]
+url = https://pdc.fedoraproject.org/
diff --git a/conf/zsh-completion/_fedpkg b/conf/zsh-completion/_fedpkg
index d16d6c1..7e3ec28 100644
--- a/conf/zsh-completion/_fedpkg
+++ b/conf/zsh-completion/_fedpkg
@@ -302,6 +302,28 @@ _fedpkg-push () {
     '(-h --help)'{-h,--help}'[show help message and exit]'
 }
 
+(( $+functions[_fedpkg-request-branch] )) ||
+_fedpkg-request-branch () {
+  _arguments -C \
+    '(-h --help)'{-h,--help}'[show help message and exit]' \
+    '(--sl)'{--sl}'[The service levels (SLs) tied to the branch]' \
+    '--no-git-branch[Do not create the branch in git but still create it in PDC]:optional' \
+    '--all-releases[Make a new branch request for every active Fedora release]:optional' \
+    ':branch'
+}
+
+(( $+functions[_fedpkg-request-repo] )) ||
+_fedpkg-request-repo () {
+  _arguments -C \
+    '(-h --help)'{-h,--help}'[show help message and exit]' \
+    '(-d --description)'{-d,--description}'[The description in dist-git]' \
+    '(-m --monitor)'{-m,--monitor}'[The Koshei monitoring type for the repo]' \
+    '(-u --upstreamurl)'{-u,--upstreamurl}'[The upstream URL of the project]' \
+    '(-s --summary)'{-s,--summary}'[Override the package summary from the Bugzilla bug]' \
+    '--exception[The package is an exception to the regular package review process]:optional' \
+    ':bug'
+}
+
 (( $+functions[_fedpkg-scratch-build] )) ||
 _fedpkg-scratch-build () {
   _arguments -C \
@@ -426,6 +448,8 @@ _fedpkg_commands () {
     prep:'local test rpmbuild prep'
     pull:'pull changes from remote repository and update working copy'
     push:'push changes to remote repository'
+    request-branch:'request a new dist-git branch'
+    request-repo:'request a new dist-git repository'
     scratch-build:'request scratch build'
     sources:'download source files'
     srpm:'create a source rpm'
@@ -462,6 +486,7 @@ _fedpkg() {
   _arguments -C \
     '(- :)'{-h,--help}'[show help message and exit]' \
     '(-C --config)'{-C,--config}'[specify a config file to use]:config file:_files' \
+    '--user-config[specify a user config file to use]:user config file:_files' \
     '--dist[override the discovered distribution]:distribution' \
     '--release[override the discovered release]:release' \
     '--user[override the discovered user name]:user' \
diff --git a/fedpkg/__main__.py b/fedpkg/__main__.py
index be0ea28..31c2ade 100644
--- a/fedpkg/__main__.py
+++ b/fedpkg/__main__.py
@@ -15,6 +15,7 @@ import os
 import sys
 
 import fedpkg
+import fedpkg.utils
 import pyrpkg
 import pyrpkg.utils
 
@@ -29,10 +30,15 @@ cli_name = os.path.basename(sys.argv[0])
 
 
 def main():
+    default_user_config_path = os.path.join(
+        os.path.expanduser('~'), '.config', 'rpkg', '%s.conf' % cli_name)
     # Setup an argparser and parse the known commands to get the config file
     parser = argparse.ArgumentParser(add_help=False)
     parser.add_argument('-C', '--config', help='Specify a config file to use',
                         default='/etc/rpkg/%s.conf' % cli_name)
+    parser.add_argument(
+        '--user-config', help='Specify a user config file to use',
+        default=default_user_config_path)
 
     (args, other) = parser.parse_known_args()
 
@@ -45,6 +51,7 @@ def main():
     # Setup a configuration object and read config file data
     config = ConfigParser()
     config.read(args.config)
+    config.read(args.user_config)
 
     client = fedpkg.cli.fedpkgClient(config, name=cli_name)
     client.do_imports(site='fedpkg')
diff --git a/fedpkg/bugzilla.py b/fedpkg/bugzilla.py
new file mode 100644
index 0000000..274fc29
--- /dev/null
+++ b/fedpkg/bugzilla.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+# cli.py - a cli client class module for fedpkg
+#
+# Copyright (C) 2017 Red Hat Inc.
+# Author(s): Matt Prahl <mprahl@redhat.com>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
+# the full text of the license.
+
+# So that we import the bugzilla package and not fedpkg.bugzilla
+from __future__ import absolute_import
+from datetime import datetime
+
+import bugzilla
+from pyrpkg import rpkgError
+
+
+class BugzillaClient(object):
+    """A Bugzilla helper class"""
+    api_url = None
+    _client = None
+
+    def __init__(self, url):
+        self.api_url = '{0}/xmlrpc.cgi'.format(url.rstrip('/'))
+
+    @property
+    def client(self):
+        """
+        Only connect to Bugzilla when the client property is first used. This
+        will make the unit tests less complicated and shorten the connections
+        to the Bugzilla server.
+        """
+        if not self._client:
+            # use_creds is only available in python-bugzilla 2.0+
+            try:
+                self._client = bugzilla.Bugzilla(self.api_url, use_creds=False)
+            except TypeError:
+                self._client = bugzilla.Bugzilla(self.api_url)
+
+        return self._client
+
+    def get_review_bug(self, bug_id, namespace, pkg):
+        """
+        Gets a Bugzilla bug representing a Fedora package review and does as
+        much validation as it can without authenticating to Bugzilla. This
+        function was inspired by:
+        https://github.com/fedora-infra/pkgdb2/blob/master/pkgdb2/api/extras.py
+        https://pagure.io/pkgdb-cli/blob/master/f/pkgdb2client/utils.py
+        :param bug_id: string or integer of the Bugzilla bug ID
+        :param namespace: string of the dist-git namespace
+        :param pkg: string of the package name
+        """
+        try:
+            bug = self.client.getbug(bug_id)
+        except Exception as error:
+            raise rpkgError(
+                'The Bugzilla bug could not be verified. The following '
+                'error was encountered: {0}'.format(str(error)))
+
+        # Do some basic validation on the bug
+        pagure_namespace_to_component = {
+            'rpms': 'Package Review',
+            'container': 'Container Review',
+            'modules': 'Module Review',
+            'test-modules': 'Module Review'
+        }
+        pagure_namespace_to_product = {
+            'rpms': ['Fedora', 'Fedora EPEL'],
+            'container': ['Fedora Container Images'],
+            'modules': ['Fedora Modules'],
+            'test-modules': ['Fedora']
+        }
+        bz_proper_component = pagure_namespace_to_component.get(namespace)
+        bz_proper_products = pagure_namespace_to_product.get(namespace)
+        if bz_proper_component is None or bug.component != bz_proper_component:
+            raise rpkgError('The Bugzilla bug provided is not the proper type')
+        elif bug.product not in bz_proper_products:
+            raise rpkgError('The Bugzilla bug provided is not for "{0}"'
+                            .format('" or "'.join(bz_proper_products)))
+        elif bug.assigned_to in ['', None, 'nobody@fedoraproject.org']:
+            raise rpkgError(
+                'The Bugzilla bug provided is not assigned to anyone')
+        # Check if the review was approved
+        flag_set = False
+        for flag in bug.flags:
+            name, status = flag.get('name'), flag.get('status')
+            if name == 'fedora-review' and status == '+':
+                flag_set = True
+                update_dt = flag.get('modification_date')
+                if update_dt:
+                    dt = datetime.strptime(
+                        update_dt.value, '%Y%m%dT%H:%M:%S')
+                    delta = datetime.utcnow().date() - dt.date()
+                    if delta.days > 60:
+                        raise rpkgError('The Bugzilla bug\'s review was '
+                                        'approved over 60 days ago')
+                break
+        if not flag_set:
+            raise rpkgError('The Bugzilla bug is not approved yet')
+        # Check the format of the Bugzilla bug title
+        tmp_summary = bug.summary.partition(':')[2]
+        if not tmp_summary:
+            raise rpkgError(
+                'Invalid title for this Bugzilla bug (no ":" present)')
+        if ' - ' not in tmp_summary:
+            raise rpkgError(
+                'Invalid title for this Bugzilla bug (no "-" present)')
+        pkg_in_bug = tmp_summary.split(' - ', 1)[0].strip()
+        if pkg != pkg_in_bug:
+            error = ('The package in the Bugzilla bug "{0}" doesn\'t match '
+                     'the one provided "{1}"'.format(pkg_in_bug, pkg))
+            raise rpkgError(error)
+        return bug
diff --git a/fedpkg/cli.py b/fedpkg/cli.py
index a2d0aac..0e8fb6b 100644
--- a/fedpkg/cli.py
+++ b/fedpkg/cli.py
@@ -10,16 +10,22 @@
 # option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
 # the full text of the license.
 
+from __future__ import print_function
 from pyrpkg.cli import cliClient
 import hashlib
 import os
 import re
+import json
 import six
 import textwrap
 
 from six.moves.configparser import NoSectionError
 from six.moves.configparser import NoOptionError
 from pyrpkg import rpkgError
+from fedpkg.bugzilla import BugzillaClient
+from fedpkg.utils import (
+    get_release_branches, sl_list_to_dict, verify_sls, new_pagure_issue,
+    get_pagure_token)
 
 
 class fedpkgClient(cliClient):
@@ -31,6 +37,10 @@ class fedpkgClient(cliClient):
     def setup_argparser(self):
         super(fedpkgClient, self).setup_argparser()
 
+        # This line is added here so that it shows up with the "--help" option,
+        # but it isn't used for anything else
+        self.parser.add_argument(
+            '--user-config', help='Specify a user config file to use')
         opt_release = self.parser._option_string_actions['--release']
         opt_release.help = 'Override the discovered release, e.g. f25, which has to match ' \
                            'the remote branch name created in package repository. ' \
@@ -41,6 +51,8 @@ class fedpkgClient(cliClient):
 
         self.register_retire()
         self.register_update()
+        self.register_request_repo()
+        self.register_request_branch()
 
     # Target registry goes here
     def register_retire(self):
@@ -65,6 +77,55 @@ class fedpkgClient(cliClient):
         )
         update_parser.set_defaults(command=self.update)
 
+    def register_request_repo(self):
+        help_msg = 'Request a new dist-git repository'
+        request_repo_parser = self.subparsers.add_parser(
+            'request-repo', help=help_msg, description=help_msg)
+        request_repo_parser.add_argument(
+            'bug', nargs='?', type=int,
+            help='Bugzilla bug ID of the package review request')
+        request_repo_parser.add_argument(
+            '--description', '-d', help='The repo\'s description in dist-git')
+        monitoring_choices = [
+            'no-monitoring', 'monitoring', 'monitoring-with-scratch']
+        request_repo_parser.add_argument(
+            '--monitor', '-m', help='The Koshei monitoring type for the repo',
+            choices=monitoring_choices, default=monitoring_choices[1])
+        request_repo_parser.add_argument(
+            '--upstreamurl', '-u',
+            help='The upstream URL of the project')
+        request_repo_parser.add_argument(
+            '--summary', '-s',
+            help='Override the package\'s summary from the Bugzilla bug')
+        request_repo_parser.add_argument(
+            '--exception', action='store_true',
+            help='The package is an exception to the regular package review '
+                 'process (specifically, it does not require a Bugzilla bug)')
+        request_repo_parser.set_defaults(command=self.request_repo)
+
+    def register_request_branch(self):
+        help_msg = 'Request a new dist-git branch'
+        request_branch_parser = self.subparsers.add_parser(
+            'request-branch', help=help_msg, description=help_msg)
+        request_branch_parser.add_argument(
+            'branch', nargs='?', help='The branch to request')
+        request_branch_parser.add_argument(
+            '--sl', nargs='*',
+            help=('The service levels (SLs) tied to the branch. This must be '
+                  'in the format of "sl_name:2020-12-01". This is only for '
+                  'non-release branches. You may provide more than one by '
+                  'separating each SL with a space.')
+        )
+        request_branch_parser.add_argument(
+            '--no-git-branch', default=False, action='store_true',
+            help='Don\'t create the branch in git but still create it in PDC'
+        )
+        request_branch_parser.add_argument(
+            '--all-releases', default=False, action='store_true',
+            help='Make a new branch request for every active Fedora release'
+        )
+        request_branch_parser.set_defaults(command=self.request_branch)
+
     # Target functions go here
     def retire(self):
         # Skip if package is already retired...
@@ -191,3 +252,125 @@ suggest_reboot=False
         # Clean up
         os.unlink('bodhi.template')
         os.unlink('clog')
+
+    def request_repo(self):
+        # bug is not a required parameter in the event the packager has an
+        # exception, in which case, they may use the --exception flag
+        if not self.args.bug and not self.args.exception:
+            raise rpkgError(
+                'A Bugzilla bug is required on new repository requests')
+        repo_regex = r'^[a-zA-Z0-9_][a-zA-Z0-9-_.+]*$'
+        if not bool(re.match(repo_regex, self.cmd.module_name)):
+            raise rpkgError(
+                'The repository name "{0}" is invalid. It must be at least '
+                'two characters long with only letters, numbers, hyphens, '
+                'underscores, plus signs, and/or periods. Please note that '
+                'the project cannot start with a period or a plus sign.'
+                .format(self.cmd.module_name))
+
+        summary_from_bug = ''
+        if self.args.bug:
+            bz_url = self.config.get('{0}.bugzilla'.format(self.name), 'url')
+            bz_client = BugzillaClient(bz_url)
+            bug = bz_client.get_review_bug(
+                self.args.bug, self.cmd.ns, self.cmd.module_name)
+            summary_from_bug = bug.summary.split(' - ', 1)[1].strip()
+
+        ticket_body = {
+            'action': 'new_repo',
+            'branch': 'master',
+            'bug_id': self.args.bug or '',
+            'description': self.args.description or '',
+            'exception': self.args.exception,
+            'monitor': self.args.monitor,
+            'namespace': self.cmd.ns,
+            'repo': self.cmd.module_name,
+            'summary': self.args.summary or summary_from_bug,
+            'upstreamurl': self.args.upstreamurl or ''
+        }
+
+        ticket_body = json.dumps(ticket_body, indent=True)
+        ticket_body = '```\n{0}\n```'.format(ticket_body)
+        ticket_title = 'New Repo for "{0}/{1}"'.format(
+            self.cmd.ns, self.cmd.module_name)
+
+        pagure_url = self.config.get('{0}.pagure'.format(self.name), 'url')
+        pagure_token = get_pagure_token(self.config, self.name)
+        print(new_pagure_issue(
+            pagure_url, pagure_token, ticket_title, ticket_body))
+
+    def request_branch(self):
+        service_levels = self.args.sl
+        branch = None
+
+        if self.args.all_releases:
+            if self.args.branch:
+                raise rpkgError('You cannot specify a branch with the '
+                                '"--all-releases" option')
+            elif service_levels:
+                raise rpkgError('You cannot specify service levels with the '
+                                '"--all-releases" option')
+        elif not self.args.branch:
+            try:
+                branch = self.cmd.repo.active_branch.name
+            except rpkgError:
+                raise rpkgError('You must specify a branch if you are not in '
+                                'a git repository')
+        else:
+            branch = self.args.branch
+
+        bodhi_url = self.config.get('{0}.bodhi'.format(self.name), 'url')
+        if branch:
+            if self.cmd.ns in ['modules', 'test-modules']:
+                branch_valid = bool(re.match(r'^[a-zA-Z0-9.\-_+]+$', branch))
+                if not branch_valid:
+                    raise rpkgError(
+                        'Only characters, numbers, periods, dashes, '
+                        'underscores, and pluses are allowed in module branch '
+                        'names')
+            release_branches = get_release_branches(bodhi_url)
+            if branch in release_branches:
+                if service_levels:
+                    raise rpkgError(
+                        'You can\'t provide SLs for release branches')
+            else:
+                if re.match(r'^(f\d+|el\d+|epel\d+)$', branch):
+                    raise rpkgError('{0} is not a current release branch'
+                                    .format(branch))
+                elif not service_levels:
+                    raise rpkgError(
+                        'You must provide SLs for non-release branches')
+
+        # If service levels were provided, verify them
+        if service_levels:
+            pdc_url = self.config.get('{0}.pdc'.format(self.name), 'url')
+            sl_dict = sl_list_to_dict(service_levels)
+            verify_sls(pdc_url, sl_dict)
+
+        pagure_url = self.config.get('{0}.pagure'.format(self.name), 'url')
+        pagure_token = get_pagure_token(self.config, self.name)
+        if self.args.all_releases:
+            release_branches = get_release_branches(bodhi_url)
+            branches = [b for b in release_branches
+                        if re.match(r'^(f\d+)$', b)]
+        else:
+            branches = [branch]
+
+        for b in sorted(list(branches), reverse=True):
+            ticket_body = {
+                'action': 'new_branch',
+                'branch': b,
+                'namespace': self.cmd.ns,
+                'repo': self.cmd.module_name,
+                'create_git_branch': not self.args.no_git_branch
+            }
+            if service_levels:
+                ticket_body['sls'] = sl_dict
+
+            ticket_body = json.dumps(ticket_body, indent=True)
+            ticket_body = '```\n{0}\n```'.format(ticket_body)
+            ticket_title = 'New Branch "{0}" for "{1}/{2}"'.format(
+                b, self.cmd.ns, self.cmd.module_name)
+
+            print(new_pagure_issue(
+                pagure_url, pagure_token, ticket_title, ticket_body))
diff --git a/fedpkg/utils.py b/fedpkg/utils.py
new file mode 100644
index 0000000..a4bffc8
--- /dev/null
+++ b/fedpkg/utils.py
@@ -0,0 +1,187 @@
+# -*- coding: utf-8 -*-
+# cli.py - a cli client class module for fedpkg
+#
+# Copyright (C) 2017 Red Hat Inc.
+# Author(s): Matt Prahl <mprahl@redhat.com>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
+# the full text of the license.
+
+import re
+import json
+from datetime import datetime
+
+from six.moves.urllib.parse import urlencode
+from six.moves.configparser import NoSectionError, NoOptionError
+import requests
+from requests.exceptions import ConnectionError
+from fedora.client.bodhi import Bodhi2Client
+from pyrpkg import rpkgError
+
+
+def get_sl_type(url, sl_name):
+    """
+    Gets the service level (SL) type from PDC
+    :param url: a string of the URL to PDC
+    :param sl_name: a string of the SL name
+    :return: a dictionary representing the SL type or None
+    """
+    api_url = '{0}/rest_api/v1/component-sla-types/'.format(url.rstrip('/'))
+    api_url_w_args = '{0}?{1}'.format(api_url, urlencode({'name': sl_name}))
+    try:
+        rv = requests.get(api_url_w_args, timeout=60)
+    except ConnectionError as error:
+        error_msg = ('The connection to PDC failed while trying to validate '
+                     'the passed in service level. The error was: {0}'
+                     .format(str(error)))
+        raise rpkgError(error_msg)
+
+    if not rv.ok:
+        base_error_msg = ('The following error occurred while validating the '
+                          'passed in service level in PDC: {0}')
+        raise rpkgError(base_error_msg.format(rv.text))
+
+    rv_json = rv.json()
+    if rv_json['count'] == 1:
+        return rv_json['results'][0]
+    else:
+        return None
+
+
+def new_pagure_issue(url, token, title, body):
+    """
+    Posts a new Pagure issue
+    :param url: a string of the URL to Pagure
+    :param token: a string of the Pagure API token that has rights to create
+    a ticket
+    :param title: a string of the issue's title
+    :param body: a string pf the issue's body
+    :return: a string of the URL to the created issue in the UI
+    """
+    api_url = '{0}/api/0'.format(url.rstrip('/'))
+    new_issue_url = '{0}/releng/fedora-scm-requests/new_issue'.format(api_url)
+
+    headers = {
+        'Authorization': 'token {0}'.format(token),
+        'Accept': 'application/json',
+        'Content-Type': 'application/json'
+    }
+    payload = json.dumps({
+        'title': title,
+        'issue_content': body
+    })
+    try:
+        rv = requests.post(
+            new_issue_url, headers=headers, data=payload, timeout=60)
+    except ConnectionError as error:
+        error_msg = ('The connection to Pagure failed while trying to '
+                     'create a new issue. The error was: {0}'.format(
+                         str(error)))
+        raise rpkgError(error_msg)
+
+    base_error_msg = ('The following error occurred while creating a new '
+                      'issue in Pagure: {0}')
+    if not rv.ok:
+        # Lets see if the API returned an error message in JSON that we can
+        # show the user
+        try:
+            rv_error = rv.json().get('error')
+        except ValueError:
+            rv_error = rv.text
+        raise rpkgError(base_error_msg.format(rv_error))
+
+    return '{0}/releng/fedora-scm-requests/issue/{1}'.format(
+        url.rstrip('/'), rv.json()['issue']['id'])
+
+
+def get_release_branches(bodhi_url):
+    """
+    Get the active Fedora release branches from Bodhi
+    :param bodhi_url: a string of the URL to Bodhi
+    :return: a set containing the active Fedora release branches
+    """
+    bodhi = Bodhi2Client(bodhi_url)
+    branches = set()
+    page = 1
+    while True:
+        rv = bodhi.send_request('releases', auth=False, params={'page': page})
+        for release in rv['releases']:
+            if release['state'] == 'current':
+                branches.add(release['branch'])
+        if page < rv['pages']:
+            page += 1
+        else:
+            break
+
+    return branches
+
+
+def sl_list_to_dict(sls):
+    """
+    Takes a list of SLs and returns them in a dictionary format. Any errors in
+    the SLs will be raised as an rpkgError.
+    :param sls: list of SLs in the format of sl_name:2017-12-25
+    :return: dictionary in the format of {'sl_name': '2017-12-25'}
+    """
+    sl_dict = {}
+    # Ensures the SL is in the format "security_fixes:2020-01-01"
+    sl_regex = re.compile(r'^(.+)(?:\:)(\d{4}-\d{2}-\d{2})$')
+    for sl in sls:
+        sl_match = re.match(sl_regex, sl)
+        if sl_match:
+            sl_name = sl_match.groups()[0]
+            sl_date = sl_match.groups()[1]
+            sl_dict[sl_name] = sl_date
+        else:
+            raise rpkgError(
+                'The SL "{0}" is in an invalid format'.format(sl))
+
+    return sl_dict
+
+
+def verify_sls(pdc_url, sl_dict):
+    """
+    Verifies that the service levels are properly formatted and exist in PDC
+    :param pdc_url: a string of the URL to PDC
+    :param sl_dict: a dictionary with the SLs of the request
+    :return: None or ValidationError
+    """
+    # Make sure the EOL date is in the format of 2020-12-01
+    eol_date_regex = re.compile(r'\d{4}-\d{2}-\d{2}')
+    for sl, eol in sl_dict.items():
+        if re.match(eol_date_regex, eol):
+            eol_date = datetime.strptime(eol, '%Y-%m-%d').date()
+            today = datetime.utcnow().date()
+            if eol_date < today:
+                raise rpkgError(
+                    'The SL "{0}" is already expired'.format(eol))
+            elif eol_date.month not in [6, 12] or eol_date.day != 1:
+                raise rpkgError(
+                    'The SL "{0}" must expire on June 1st or December 1st'
+                    .format(eol))
+        else:
+            raise rpkgError(
+                'The EOL date "{0}" is in an invalid format'.format(eol))
+
+        sl_obj = get_sl_type(pdc_url, sl)
+        if not sl_obj:
+            raise rpkgError('The SL "{0}" is not in PDC'.format(sl))
+
+
+def get_pagure_token(config, cli_name):
+    """
+    Gets the Pagure token configured in the user's configuration file
+    :param config: ConfigParser object
+    :param cli_name: string of the CLI's name (e.g. fedpkg)
+    :return: string of the Pagure token
+    """
+    conf_section = '{0}.pagure'.format(cli_name)
+    try:
+        return config.get(conf_section, 'token')
+    except (NoSectionError, NoOptionError):
+        raise rpkgError('The "token" value must be set under the "{0}" '
+                        'section in your "{1}" user configuration'
+                        .format(conf_section, cli_name))
diff --git a/test/fedpkg-stage.conf b/test/fedpkg-stage.conf
index cc3ee04..3c09ebc 100644
--- a/test/fedpkg-stage.conf
+++ b/test/fedpkg-stage.conf
@@ -12,4 +12,13 @@ kerberos_realms = STG.FEDORAPROJECT.ORG
 
 [fedpkg-stage.bodhi]
 url = https://bodhi.stg.example.com/
-staging = True
\ No newline at end of file
+staging = True
+
+[fedpkg-stage.bugzilla]
+url = https://bugzilla.stg.example.com
+
+[fedpkg-stage.pagure]
+url = https://pagure.stg.example.com/
+
+[fedpkg-stage.pdc]
+url = https://pdc.stg.example.com/
diff --git a/test/fedpkg-test.conf b/test/fedpkg-test.conf
index 8917e85..2173f0e 100644
--- a/test/fedpkg-test.conf
+++ b/test/fedpkg-test.conf
@@ -12,4 +12,13 @@ kerberos_realms = FEDORAPROJECT.ORG
 
 [fedpkg.bodhi]
 url = https://bodhi.dummy.example.com/
-staging = False
\ No newline at end of file
+staging = False
+
+[fedpkg.bugzilla]
+url = https://bugzilla.example.com
+
+[fedpkg.pagure]
+url = https://pagure.example.com/
+
+[fedpkg.pdc]
+url = https://pdc.example.com/
diff --git a/test/fedpkg-user-stage.conf b/test/fedpkg-user-stage.conf
new file mode 100644
index 0000000..9a3e5d0
--- /dev/null
+++ b/test/fedpkg-user-stage.conf
@@ -0,0 +1,2 @@
+[fedpkg-stage.pagure]
+token = iamnotsecret
diff --git a/test/fedpkg-user.conf b/test/fedpkg-user.conf
new file mode 100644
index 0000000..4764f8b
--- /dev/null
+++ b/test/fedpkg-user.conf
@@ -0,0 +1,2 @@
+[fedpkg.pagure]
+token = iamnotsecret
diff --git a/test/test_cli.py b/test/test_cli.py
index 632367c..7d177ca 100644
--- a/test/test_cli.py
+++ b/test/test_cli.py
@@ -10,15 +10,22 @@
 # option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
 # the full text of the license.
 
-import six
+import sys
+import json
+from datetime import datetime, timedelta
+from tempfile import mkdtemp
+from os import rmdir
 
+import six
 from six.moves.configparser import NoOptionError
 from six.moves.configparser import NoSectionError
+from six.moves import StringIO
 
 from pyrpkg.errors import rpkgError
 from utils import CliTestCase
+from fedpkg.bugzilla import BugzillaClient
 
-from mock import call, patch, mock_open, PropertyMock
+from mock import call, patch, mock_open, PropertyMock, Mock
 
 
 class TestUpdate(CliTestCase):
@@ -181,3 +188,769 @@ class TestUpdate(CliTestCase):
                   '--user', 'someone', '--staging', self.mock_nvr.return_value],
                  shell=True)
         ])
+
+
+@patch.object(BugzillaClient, 'client')
+class TestRequestRepo(CliTestCase):
+    """Test the request-repo command"""
+
+    def setUp(self):
+        self.mock_bug = Mock()
+        self.mock_bug.creator = 'Tom Hanks'
+        self.mock_bug.component = 'Package Review'
+        self.mock_bug.product = 'Fedora'
+        self.mock_bug.assigned_to = 'Tom Brady'
+        self.mock_bug.setter = 'Tom Brady'
+        mod_date = Mock()
+        mod_date.value = datetime.utcnow().strftime('%Y%m%dT%H:%M:%S')
+        self.mock_bug.flags = [{
+            'status': '+',
+            'name': 'fedora-review',
+            'type_id': 65,
+            'is_active': 1,
+            'id': 1441813,
+            'setter': 'Tom Brady',
+            'modification_date': mod_date
+        }]
+        self.mock_bug.summary = ('Review Request: nethack - A rogue-like '
+                                 'single player dungeon exploration game')
+        super(TestRequestRepo, self).setUp()
+
+    def get_cli(self, cli_cmd, name='fedpkg-stage', cfg='fedpkg-stage.conf',
+                user_cfg='fedpkg-user-stage.conf'):
+        with patch('sys.argv', new=cli_cmd):
+            return self.new_cli(name=name, cfg=cfg, user_cfg=user_cfg)
+
+    @patch('requests.post')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_repo(self, mock_request_post, mock_bz):
+        """Tests a standard request-repo call"""
+        self.mock_bug.summary = ('Review Request: testpkg - a description')
+        mock_bz.getbug.return_value = self.mock_bug
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        cli.request_repo()
+
+        expected_issue_content = {
+            'action': 'new_repo',
+            'branch': 'master',
+            'bug_id': 1441813,
+            'description': '',
+            'exception': False,
+            'monitor': 'monitoring',
+            'namespace': 'rpms',
+            'repo': 'testpkg',
+            'summary': 'a description',
+            'upstreamurl': ''
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_repo_override(self, mock_request_post, mock_bz):
+        """Tests a request-repo call with an overridden repo name"""
+        mock_bz.getbug.return_value = self.mock_bug
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        cli.request_repo()
+
+        expected_issue_content = {
+            'action': 'new_repo',
+            'branch': 'master',
+            'bug_id': 1441813,
+            'description': '',
+            'exception': False,
+            'monitor': 'monitoring',
+            'namespace': 'rpms',
+            'repo': 'nethack',
+            'summary': ('A rogue-like single player dungeon exploration '
+                        'game'),
+            'upstreamurl': ''
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_repo_module(self, mock_request_post, mock_bz):
+        """Tests a request-repo call for a new module"""
+        self.mock_bug.product = 'Fedora Modules'
+        self.mock_bug.component = 'Module Review'
+        mock_bz.getbug.return_value = self.mock_bug
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'modules/nethack', 'request-repo',
+                   '1441813']
+        cli = self.get_cli(cli_cmd)
+        cli.request_repo()
+
+        expected_issue_content = {
+            'action': 'new_repo',
+            'branch': 'master',
+            'bug_id': 1441813,
+            'description': '',
+            'exception': False,
+            'monitor': 'monitoring',
+            'namespace': 'modules',
+            'repo': 'nethack',
+            'summary': ('A rogue-like single player dungeon exploration '
+                        'game'),
+            'upstreamurl': ''
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_repo_container(self, mock_request_post, mock_bz):
+        """Tests a request-repo call for a new container"""
+        self.mock_bug.product = 'Fedora Container Images'
+        self.mock_bug.component = 'Container Review'
+        mock_bz.getbug.return_value = self.mock_bug
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'container/nethack', 'request-repo',
+                   '1441813']
+        cli = self.get_cli(cli_cmd)
+        cli.request_repo()
+
+        expected_issue_content = {
+            'action': 'new_repo',
+            'branch': 'master',
+            'bug_id': 1441813,
+            'description': '',
+            'exception': False,
+            'monitor': 'monitoring',
+            'namespace': 'container',
+            'repo': 'nethack',
+            'summary': ('A rogue-like single player dungeon exploration '
+                        'game'),
+            'upstreamurl': ''
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_repo_with_optional_details(
+            self, mock_request_post, mock_bz):
+        """Tests a request-repo call with the optional details"""
+        mock_bz.getbug.return_value = self.mock_bug
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813', '-d',
+                   'a description', '-s', 'a summary', '-m', 'no-monitoring',
+                   '-u', 'http://test.local']
+        cli = self.get_cli(cli_cmd)
+        cli.request_repo()
+
+        expected_issue_content = {
+            'action': 'new_repo',
+            'branch': 'master',
+            'bug_id': 1441813,
+            'description': 'a description',
+            'exception': False,
+            'monitor': 'no-monitoring',
+            'namespace': 'rpms',
+            'repo': 'nethack',
+            'summary': 'a summary',
+            'upstreamurl': 'http://test.local'
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_repo_exception(self, mock_request_post, mock_bz):
+        """Tests a request-repo call with the exception flag"""
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '--exception']
+        cli = self.get_cli(cli_cmd)
+        cli.request_repo()
+
+        expected_issue_content = {
+            'action': 'new_repo',
+            'branch': 'master',
+            'bug_id': '',
+            'description': '',
+            'exception': True,
+            'monitor': 'monitoring',
+            'namespace': 'rpms',
+            'repo': 'nethack',
+            'summary': '',
+            'upstreamurl': ''
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+        # Since it is an exception, Bugzilla will not have been queried
+        mock_bz.getbug.assert_not_called()
+
+    def test_request_repo_wrong_package(self, mock_bz):
+        """Tests request-repo errors when the package is wrong"""
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'not-nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = ('The package in the Bugzilla bug "nethack" doesn\'t '
+                          'match the one provided "not-nethack"')
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_wrong_bug_product(self, mock_bz):
+        """Tests request-repo errors when the bug product is not Fedora"""
+        self.mock_bug.product = 'Red Hat'
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = \
+            'The Bugzilla bug provided is not for "Fedora" or "Fedora EPEL"'
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_invalid_summary(self, mock_bz):
+        """Tests request-repo errors when the bug summary has no colon"""
+        self.mock_bug.summary = 'I am so wrong'
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = \
+            'Invalid title for this Bugzilla bug (no ":" present)'
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_invalid_summary_two(self, mock_bz):
+        """Tests request-repo errors when the bug summary has no dash"""
+        self.mock_bug.summary = 'So:Wrong'
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = \
+            'Invalid title for this Bugzilla bug (no "-" present)'
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_wrong_summary(self, mock_bz):
+        """Tests request-repo errors when the bug summary is wrong"""
+        self.mock_bug.summary = ('Review Request: fedpkg - lorum ipsum')
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = ('The package in the Bugzilla bug "fedpkg" doesn\'t '
+                          'match the one provided "nethack"')
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_expired_bug(self, mock_bz):
+        """Tests request-repo errors when the bug was approved over 60 days ago
+        """
+        self.mock_bug.flags[0]['modification_date'].value = \
+            (datetime.utcnow() - timedelta(days=75)).strftime(
+                '%Y%m%dT%H:%M:%S')
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = \
+            'The Bugzilla bug\'s review was approved over 60 days ago'
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_bug_not_approved(self, mock_bz):
+        """Tests request-repo errors when the bug is not approved"""
+        self.mock_bug.flags[0]['name'] = 'something else'
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = 'The Bugzilla bug is not approved yet'
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_bug_not_assigned(self, mock_bz):
+        """Tests request-repo errors when the bug is not assigned"""
+        self.mock_bug.assigned_to = None
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = 'The Bugzilla bug provided is not assigned to anyone'
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_invalid_name(self, mock_bz):
+        """Tests request-repo errors when the repo name is invalid"""
+        self.mock_bug.product = 'Red Hat'
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', '$nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        expected_error = (
+            'The repository name "$nethack" is invalid. It must be at least '
+            'two characters long with only letters, numbers, hyphens, '
+            'underscores, plus signs, and/or periods. Please note that the '
+            'project cannot start with a period or a plus sign.')
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    @patch('requests.post')
+    def test_request_repo_pagure_error(self, mock_request_post, mock_bz):
+        """Tests a standard request-repo call when the Pagure API call fails"""
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo', '1441813']
+        cli = self.get_cli(cli_cmd)
+        mock_rv = Mock()
+        mock_rv.ok = False
+        mock_rv.json.return_value = {'error': 'some error'}
+        mock_request_post.return_value = mock_rv
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            expected_error = ('The following error occurred while creating a '
+                              'new issue in Pagure: some error')
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_repo_no_bug(self, mock_bz):
+        """Tests request-repo errors when no bug or exception is provided"""
+        self.mock_bug.product = 'Red Hat'
+        mock_bz.getbug.return_value = self.mock_bug
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-repo']
+        cli = self.get_cli(cli_cmd)
+        expected_error = \
+            'A Bugzilla bug is required on new repository requests'
+        try:
+            cli.request_repo()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+
+class TestRequestBranch(CliTestCase):
+    """Test the request-branch command"""
+
+    def setUp(self):
+        super(TestRequestBranch, self).setUp()
+
+    def tearDown(self):
+        super(TestRequestBranch, self).tearDown()
+
+    def get_cli(self, cli_cmd, name='fedpkg-stage', cfg='fedpkg-stage.conf',
+                user_cfg='fedpkg-user-stage.conf'):
+        with patch('sys.argv', new=cli_cmd):
+            return self.new_cli(name=name, cfg=cfg, user_cfg=user_cfg)
+
+    @patch('requests.post')
+    @patch('fedpkg.cli.get_release_branches')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_branch(self, mock_grb, mock_request_post):
+        """Tests request-branch"""
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+        # Checkout the f27 branch
+        self.run_cmd(['git', 'checkout', 'f27'], cwd=self.cloned_repo_path)
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   'request-branch']
+        cli = self.get_cli(cli_cmd)
+        cli.request_branch()
+
+        expected_issue_content = {
+            'action': 'new_branch',
+            'repo': 'testpkg',
+            'namespace': 'rpms',
+            'branch': 'f27',
+            'create_git_branch': True
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('fedpkg.cli.get_release_branches')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_branch_override(self, mock_grb, mock_request_post):
+        """Tests request-branch with an overriden package and branch name"""
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-branch', 'f27']
+        cli = self.get_cli(cli_cmd)
+        cli.request_branch()
+
+        expected_issue_content = {
+            'action': 'new_branch',
+            'repo': 'nethack',
+            'namespace': 'rpms',
+            'branch': 'f27',
+            'create_git_branch': True
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('fedpkg.cli.get_release_branches')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_branch_module(self, mock_grb, mock_request_post):
+        """Tests request-branch for a new module branch"""
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'modules/nethack', 'request-branch', 'f27']
+        cli = self.get_cli(cli_cmd)
+        cli.request_branch()
+
+        expected_issue_content = {
+            'action': 'new_branch',
+            'repo': 'nethack',
+            'namespace': 'modules',
+            'branch': 'f27',
+            'create_git_branch': True
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('fedpkg.cli.get_release_branches')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_branch_container(self, mock_grb, mock_request_post):
+        """Tests request-branch for a new container branch"""
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'container/nethack', 'request-branch',
+                   'f27']
+        cli = self.get_cli(cli_cmd)
+        cli.request_branch()
+
+        expected_issue_content = {
+            'action': 'new_branch',
+            'repo': 'nethack',
+            'namespace': 'container',
+            'branch': 'f27',
+            'create_git_branch': True
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('fedpkg.cli.get_release_branches')
+    @patch('fedpkg.cli.verify_sls')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_branch_sls(self, mock_verify_sls, mock_grb,
+                                mock_request_post):
+        """Tests request-branch with service levels"""
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+        mock_rv_post = Mock()
+        mock_rv_post.ok = True
+        mock_rv_post.json.return_value = {'issue': {'id': 2}}
+        mock_request_post.return_value = mock_rv_post
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-branch', '9', '--sl',
+                   'security_fixes:2030-12-01', 'bug_fixes:2030-12-01']
+        cli = self.get_cli(cli_cmd)
+        cli.request_branch()
+
+        expected_issue_content = {
+            'action': 'new_branch',
+            'repo': 'nethack',
+            'namespace': 'rpms',
+            'branch': '9',
+            'create_git_branch': True,
+            'sls': {
+                'security_fixes': '2030-12-01',
+                'bug_fixes': '2030-12-01'
+            }
+        }
+        # Get the data that was submitted to Pagure
+        post_data = mock_request_post.call_args_list[0][1]['data']
+        actual_issue_content = json.loads(json.loads(
+            post_data)['issue_content'].strip('```'))
+        self.assertEqual(expected_issue_content, actual_issue_content)
+        output = sys.stdout.getvalue().strip()
+        expected_output = ('https://pagure.stg.example.com/releng/'
+                           'fedora-scm-requests/issue/2')
+        self.assertEqual(output, expected_output)
+
+    @patch('requests.post')
+    @patch('fedpkg.cli.get_release_branches')
+    @patch('sys.stdout', new=StringIO())
+    def test_request_branch_all_releases(self, mock_grb, mock_request_post):
+        """Tests request-branch with the '--all-releases' option """
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+        post_side_effect = []
+        for i in range(1, 4):
+            mock_rv = Mock()
+            mock_rv.ok = True
+            mock_rv.json.return_value = {'issue': {'id': i}}
+            post_side_effect.append(mock_rv)
+        mock_request_post.side_effect = post_side_effect
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-branch',
+                   '--all-releases']
+        cli = self.get_cli(cli_cmd)
+        cli.request_branch()
+
+        for i in range(3):
+            expected_issue_content = {
+                'action': 'new_branch',
+                'repo': 'nethack',
+                'namespace': 'rpms',
+                'branch': 'f' + str(27 - i),
+                'create_git_branch': True
+            }
+            post_data = mock_request_post.call_args_list[i][1]['data']
+            actual_issue_content = json.loads(json.loads(
+                post_data)['issue_content'].strip('```'))
+            self.assertEqual(expected_issue_content, actual_issue_content)
+
+        output = sys.stdout.getvalue().strip()
+        expected_output = """\
+https://pagure.stg.example.com/releng/fedora-scm-requests/issue/1
+https://pagure.stg.example.com/releng/fedora-scm-requests/issue/2
+https://pagure.stg.example.com/releng/fedora-scm-requests/issue/3"""
+        self.assertEqual(output, expected_output)
+
+    def test_request_branch_invalid_use_of_all_releases(self):
+        """Tests request-branch with a branch and the '--all-releases' option
+        """
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-branch', 'f27',
+                   '--all-releases']
+        cli = self.get_cli(cli_cmd)
+        expected_error = \
+            'You cannot specify a branch with the "--all-releases" option'
+        try:
+            cli.request_branch()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_branch_invalid_use_of_all_releases_sl(self):
+        """Tests request-branch with an SL and the '--all-releases' option
+        """
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-branch',
+                   '--all-releases', '--sl', 'security_fixes:2020-01-01']
+        cli = self.get_cli(cli_cmd)
+        expected_error = ('You cannot specify service levels with the '
+                          '"--all-releases" option')
+        try:
+            cli.request_branch()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    @patch('fedpkg.cli.get_release_branches')
+    def test_request_branch_invalid_sls(self, mock_grb):
+        """Tests request-branch with invalid service levels"""
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-branch', '9', '--sl',
+                   'security_fixes-2030-12-01', 'bug_fixes:2030-12-01']
+        cli = self.get_cli(cli_cmd)
+        expected_error = \
+            'The SL "security_fixes-2030-12-01" is in an invalid format'
+        try:
+            cli.request_branch()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    @patch('fedpkg.cli.get_release_branches')
+    def test_request_branch_sls_on_release_branch_error(self, mock_grb):
+        """Tests request-branch with a release branch and service levels"""
+        mock_grb.return_value = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'nethack', 'request-branch', 'f27', '--sl',
+                   'security_fixes-2030-12-01', 'bug_fixes:2030-12-01']
+        cli = self.get_cli(cli_cmd)
+        expected_error = 'You can\'t provide SLs for release branches'
+        try:
+            cli.request_branch()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_branch_invalid_module_branch_name(self):
+        """Test request-branch raises an exception when a invalid module branch
+        name is supplied"""
+        cli_cmd = ['fedpkg-stage', '--path', self.cloned_repo_path,
+                   '--module-name', 'modules/nethack', 'request-branch',
+                   'some:branch']
+        cli = self.get_cli(cli_cmd)
+        expected_error = (
+            'Only characters, numbers, periods, dashes, underscores, and '
+            'pluses are allowed in module branch names')
+        try:
+            cli.request_branch()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+
+    def test_request_branch_no_branch(self):
+        """Test request-branch raises an exception when a branch isn't supplied
+        """
+        tempdir = mkdtemp()
+        cli_cmd = ['fedpkg-stage', '--path', tempdir,
+                   '--module-name', 'nethack', 'request-branch']
+        cli = self.get_cli(cli_cmd)
+        expected_error = (
+            'You must specify a branch if you are not in a git repository')
+        try:
+            cli.request_branch()
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            self.assertEqual(str(error), expected_error)
+        finally:
+            rmdir(tempdir)
diff --git a/test/test_utils.py b/test/test_utils.py
new file mode 100644
index 0000000..cff22bd
--- /dev/null
+++ b/test/test_utils.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+# fedpkg - a Python library for RPM Packagers
+#
+# Copyright (C) 2017 Red Hat Inc.
+# Author(s): Matt Prahl <mprahl@redhat.com>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
+# the full text of the license.
+
+from mock import patch, Mock
+from pyrpkg.errors import rpkgError
+
+from utils import CliTestCase
+from fedpkg import utils
+
+
+class TestUtils(CliTestCase):
+    """Test functions in fedpkg.utils"""
+
+    @patch('requests.get')
+    def test_get_sl_type(self, mock_get):
+        """Test get_sl_type"""
+        sl_type = {
+            'id': 1,
+            'name': 'security_fixes',
+            'description': 'security_fixes',
+        }
+        mock_rv = Mock()
+        mock_rv.ok = True
+        mock_rv.json.return_value = {
+            'count': 1,
+            'results': [sl_type]
+        }
+        mock_get.return_value = mock_rv
+        rv = utils.get_sl_type('http://pdc.local/', 'securty_fixes')
+        self.assertEqual(rv, sl_type)
+
+    @patch('requests.get')
+    def test_get_sl_type_pdc_error(self, mock_request_get):
+        """Test get_sl_type when PDC errors"""
+        mock_rv = Mock()
+        mock_rv.ok = False
+        mock_rv.text = 'Some error'
+        mock_request_get.return_value = mock_rv
+        try:
+            utils.get_sl_type('http://pdc.local/', 'securty_fixes')
+            assert False, 'rpkgError not raised'
+        except rpkgError as error:
+            expected_error = ('The following error occurred while validating '
+                              'the passed in service level in PDC: Some error')
+            self.assertEqual(str(error), expected_error)
+
+    @patch('fedpkg.utils.get_sl_type')
+    def test_verify_sls(self, mock_get_sl_type):
+        """Test verify_sls"""
+        mock_get_sl_type.return_value = {
+            'id': 1,
+            'name': 'security_fixes',
+            'description': 'security_fixes',
+        }
+        sls = {'security_fixes': '2222-12-01'}
+        # If it's invalid, an rpkgError will be raised
+        try:
+            utils.verify_sls('http://pdc.local/', sls)
+        except rpkgError:
+            assert False, 'An rpkgError exception was raised but not expected'
+
+    @patch('fedpkg.utils.get_sl_type')
+    def test_verify_sls_eol_expired(self, mock_get_sl_type):
+        """Test verify_sls raises an exception when an EOL is expired"""
+        mock_get_sl_type.return_value = {
+            'id': 1,
+            'name': 'security_fixes',
+            'description': 'security_fixes',
+        }
+        sls = {'security_fixes': '2001-12-01'}
+
+        try:
+            utils.verify_sls('http://pdc.local/', sls)
+            assert False, 'An rpkgError exception was not raised'
+        except rpkgError as e:
+            self.assertEqual(str(e), 'The SL "2001-12-01" is already expired')
+
+    def test_sl_list_dict(self):
+        """Test sl_list_to_dict"""
+        sls = ['security_fixes:2030-01-01', 'bug_fixes:2029-01-01']
+        sls_dict = {'security_fixes': '2030-01-01', 'bug_fixes': '2029-01-01'}
+        self.assertEqual(utils.sl_list_to_dict(sls), sls_dict)
+
+    def test_sl_list_to_dict_invalid_format(self):
+        """Tests sl_list_to_dict with an invalid SL format. An error is
+        expected.
+        """
+        try:
+            sls = ['security_fixes:2030-12-01', 'bug_fixes/2030-12-01']
+            utils.sl_list_to_dict(sls)
+            assert False, 'An rpkgError exception was not raised'
+        except rpkgError as e:
+            assert str(e) == \
+                'The SL "bug_fixes/2030-12-01" is in an invalid format'
+
+    def test_verify_sls_invalid_date(self):
+        """Test verify_sls with an SL that is not June 1st or December 1st. An
+        error is expected.
+        """
+        for eol in ['2030-01-01', '2030-12-25']:
+            try:
+                sls = {'security_fixes': eol, 'bug_fixes': '2030-12-01'}
+                utils.verify_sls('abc', sls)
+                assert False, 'An rpkgError exception was not raised'
+            except rpkgError as e:
+                assert str(e) == ('The SL "{0}" must expire on June 1st or '
+                                  'December 1st'.format(eol))
+
+    @patch('fedpkg.utils.Bodhi2Client')
+    def test_get_release_branches(self, mock_bodhi):
+        """Test that get_release_branches returns all the active Fedora release
+        branches.
+        """
+        mock_bodhi_client = Mock()
+        mock_bodhi_client.send_request.return_value = {
+            u'page': 1,
+            u'pages': 1,
+            u'releases': [
+                {'state': 'current', 'branch': 'el6'},
+                {'state': 'archived', 'branch': 'f24'},
+                {'state': 'current', 'branch': 'epel7'},
+                {'state': 'current', 'branch': 'f25'},
+                {'state': 'archived', 'branch': 'el5'},
+                {'state': 'archived', 'branch': 'f23'},
+                {'state': 'current', 'branch': 'f26'},
+                {'state': 'pending', 'branch': 'f27m'},
+                {'state': 'current', 'branch': 'f27'}
+            ],
+            u'rows_per_page': 20,
+            u'total': 11}
+        mock_bodhi.return_value = mock_bodhi_client
+        expected = set(['el6', 'epel7', 'f25', 'f26', 'f27'])
+        actual = utils.get_release_branches('http://bodhi.local')
+        self.assertEqual(expected, actual)
diff --git a/test/utils.py b/test/utils.py
index 04fb220..52e72f5 100644
--- a/test/utils.py
+++ b/test/utils.py
@@ -136,7 +136,10 @@ rm -rf $$RPM_BUILD_ROOT
 
     def setUp(self):
         # create a base repo
-        self.repo_path = tempfile.mkdtemp(prefix='fedpkg-commands-tests-')
+        self.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)
 
         self.spec_filename = 'docpkg.spec'
 
@@ -175,7 +178,7 @@ rm -rf $$RPM_BUILD_ROOT
             self.run_cmd(cmd, cwd=self.cloned_repo_path)
 
     def tearDown(self):
-        shutil.rmtree(self.repo_path)
+        shutil.rmtree(self.repo_base)
         shutil.rmtree(self.cloned_repo_path)
 
     def make_commands(self, path=None, user=None, dist=None, target=None,
@@ -232,7 +235,7 @@ class CliTestCase(CommandTestCase):
     default_config_file = os.path.join(os.path.dirname(__file__),
                                        'fedpkg-test.conf')
 
-    def new_cli(self, name='fedpkg', cfg=None):
+    def new_cli(self, name='fedpkg', cfg=None, user_cfg=None):
         config = configparser.SafeConfigParser()
         if cfg:
             config_file = os.path.join(os.path.dirname(__file__), cfg)
@@ -240,6 +243,11 @@ class CliTestCase(CommandTestCase):
             config_file = self.default_config_file
         config.read(config_file)
 
+        if user_cfg:
+            user_config_file = os.path.join(
+                os.path.dirname(__file__), user_cfg)
+            config.read(user_config_file)
+
         client = fedpkg.cli.fedpkgClient(config, name=name)
         client.setupLogging(pyrpkg.log)
         pyrpkg.log.setLevel(logging.CRITICAL)

