Index: conf/repoinfo.conf
===================================================================
--- /dev/null
+++ conf/repoinfo.conf
@@ -0,0 +1,55 @@
+[DEFAULT]
+# list of parent repos to this repo
+parents =
+# list of supported architectures
+arches = i386, x86_64
+# whether installation composes are created for this repo or not
+composes = no
+# tag defaults to section name
+tag = %(__name__)s
+baseurl = http://download.fedoraproject.org/pub/fedora/linux
+goldurl = %(baseurl)s/releases/%(path)s/Everything/%(arch)s/os
+updatesurl = %(baseurl)s/updates/%(path)s/%(arch)s
+rawhideurl = %(baseurl)s/%(path)s/%(arch)s/os
+# true for "main" repos corresponding to currently supported Fedora releases
+isactiverelease =
+
+# Rawhide
+[rawhide]
+arches = i386, x86_64
+path = development/rawhide
+url = %(rawhideurl)s
+tag = f21
+
+# Fedora 20
+[f20]
+isactiverelease = yes
+path = 20
+url = %(goldurl)s
+
+[f20-updates]
+path = 20
+url = %(updatesurl)s
+parents = f20
+
+[f20-updates-testing]
+path = testing/20
+url = %(updatesurl)s
+parents = f20-updates, f20
+
+# Fedora 19
+[f19]
+isactiverelease = yes
+path = 19
+url = %(goldurl)s
+
+[f19-updates]
+path = 19
+url = %(updatesurl)s
+parents = f19
+
+[f19-updates-testing]
+path = testing/19
+url = %(updatesurl)s
+parents = f19-updates, f19
+
Index: conf/secrets.conf
===================================================================
--- /dev/null
+++ conf/secrets.conf
@@ -0,0 +1,11 @@
+# All login credentials and other secrets are here. This file should not be
+# world readable.
+
+[secrets]
+
+# FAS (Fedora Accounts System) credentials
+# These credentials are used when reporting results in the name of Taskotron,
+# e.g. posting a comment into Bodhi.
+fas_username = taskotron
+fas_password =
+
Index: conf/taskotron.conf
===================================================================
--- /dev/null
+++ conf/taskotron.conf
@@ -0,0 +1,93 @@
+# Main configuration file for Taskotron
+# More information on Taskotron configuration can be found at:
+# https://fedoraproject.org/wiki/AutoQA_Configuration
+# FIXME: create Taskotron docs and fix the link
+
+[notifications]
+# This section controls which notifications you want to send after the test
+# execution is completed
+
+# If "true", test results (for selected tests) will be sent as comments to
+# Fedora Update System (Bodhi). This requires that you have Bodhi credentials
+# filled in secrets.conf.
+send_bodhi_comments = false
+
+# How long (in minutes) should we wait before allowing consequent test to
+# re-post a 'FAILED' comment into Bodhi once again.
+# By default 3 days (3*24*60 = 4320).
+bodhi_posting_comments_span = 4320
+
+
+[resources]
+# This section specifies access details to various external services
+
+# URL of Koji instance used for querying about new builds
+koji_url = http://koji.fedoraproject.org/kojihub
+
+# URL of repository of all the RPM packages built in Koji
+pkg_url = http://kojipkgs.fedoraproject.org/packages
+
+# URL of Bodhi instance used for communication about package updates
+# (leave blank for default)
+bodhi_server =
+
+# ResultsDB server can store all test results.
+# Provide URL of ResultsDB's RESTful interface, leave blank to disable.
+resultsdb_server =
+
+
+[bodhi_email]
+# These configuration options affect how Taskotron decideds to send emails through
+# bodhi in specific situations. This section only applies when
+# 'send_bodhi_comments' is enabled.
+
+# Send email even when all tests are passing for an update. Note that this is
+# overridden by the state_change configurations if an update's tests transition
+# from FAIL -> PASS (as opposed to INCOMPLETE -> PASS which is affected by this
+# configuration value).
+email_all_passed = false
+
+# Send emails when an update's test state has changed. This would be the
+# update as a whole is affected. Example:
+# old_status:
+#   depcheck_i386:PASS, depcheck_x86_64:PASS, upgradepath:FAIL
+# new_status:
+#   depcheck_i386:PASS, depcheck_x86_64:PASS, upgradepath:PASS
+#
+# Going from old_status -> new_status represents a state change since all
+# of the tests are now passing. Assuming that this wasn't overridden by other
+# settings, a state change for the update would be detected and an email sent.
+#
+# Example of no update state change:
+# old_status:
+#   depcheck_i386:PASS, depcheck_x86_64:FAIL, upgradepath:FAIL
+# new_status:
+#   depcheck_i386:PASS, depcheck_x86_64:FAIL, upgradepath:PASS
+#
+# Since depcheck_x86_64 is still FAIL, the tests as a whole for the update are
+# still FAIL and the overall status has not changed.
+email_update_state_change = true
+
+# send email on state change for any individual test instead of waiting for an
+# update's state to change as a whole (ex: email when upgradepath goes from
+# FAIL -> PASS even though depcheck_i386 is still FAILED and the test state as
+# a whole for this update haven't changed from FAIL)
+email_test_state_change = false
+
+# Always send an email for all bodhi comments
+# This WILL override ANY other email settings
+email_always_send = false
+
+# send emails when all tests for updates_testing are complete instead of
+# waiting for all tests to be done on dist-fXX-updates. This does
+# not affect sending emails when all tests are completed for dist-fXX-updates.
+email_updates_testing = true
+
+
+[paths]
+# Location of various parts of the project. You don't want to change anything
+# in here except for some rare cases.
+
+# Path containing log file for the taskotron runner and watchers
+logfile = /var/log/taskotron/taskotron.log
+
Index: libtaskotron/bodhi_utils.py
===================================================================
--- /dev/null
+++ libtaskotron/bodhi_utils.py
@@ -0,0 +1,103 @@
+# -*- coding: utf-8 -*-
+# Copyright 2010-2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''Utility functions for dealing with Bodhi'''
+
+import fedora.client
+
+from . import system_utils
+from .config import Config
+from .logger import log
+
+
+_ops = {'base_url': Config.resources.bodhi_server} if \
+                    Config.resources.bodhi_server else {}
+bodhi = fedora.client.bodhi.BodhiClient(username=Config.secrets.fas_username,
+                                        password=Config.secrets.fas_password,
+                                        **_ops)
+
+
+def query_update(package):
+    '''Get the last Bodhi update matching criteria.
+
+       @param package package NVR or package name or update title or update ID
+                      Note: Only NVR allowed, not ENVR. See
+                      https://fedorahosted.org/bodhi/ticket/592.
+
+       @return most recent Bodhi update object matching criteria, or None if
+               no such available
+    '''
+    log.debug('Searching Bodhi updates for: %s', package)
+    res = bodhi.query(package=package, limit=1)
+    if res['updates']:
+        return res['updates'][0]
+    else:
+        return None
+
+
+def build2update(builds, strict=False):
+    '''Find matching Bodhi updates for provided builds.
+
+    @param builds builds to search for; iterable of strings
+    @param strict if False, incomplete Bodhi updates are allowed.
+                  if True, every Bodhi update will be compared with the set of
+                  provided builds. If there is an Bodhi update which contains
+                  builds not provided in @param builds, that update is marked
+                  as incomplete and removed from the result - i.e. all builds
+                  from @param builds that were part of this incomplete update
+                  are placed in the second dictionary of the result tuple.
+    @return tuple of two dictionaries:
+            * The first dict provides mapping between builds and their updates
+              where no error occured. {build (string): Bodhi update (Bunch)}
+            * The second dict provides mapping between builds and their updates
+              where some error occured.
+              {build (string): Bodhi update (Bunch) or None}
+              The value is None if the matching Bodhi update could not be found
+              (the only possible cause of failure if @param strict=False). Or
+              the value is a Bodhi update that was incomplete (happens only if
+              @param strict=True).
+    '''
+    build_update = {}
+    failures = {}
+    # Bodhi works with NVR only, but we have to ensure we receive and return
+    # even other formats, like ENVRA. So we need to convert internally.
+    builds_nvr = set([system_utils.envra(build, 'nvr') for build in builds])
+
+    for build in sorted(builds):
+        # if already found answer for this build, skip it
+        if build in build_update or build in failures:
+            continue
+
+        # get Bodhi update object
+        update = query_update(system_utils.envra(build, 'nvr'))
+        if update is None:
+            # no such Bodhi update
+            failures[build] = None
+            continue
+
+        # all builds listed in the update
+        bodhi_builds = set([build['nvr'] for build in update['builds']])
+        # builds *not* provided in @param builds but part of the update (NVRs)
+        missing_builds = bodhi_builds.difference(builds_nvr)
+        # builds provided in @param builds and part of the update
+        matched_builds = [build for build in builds if system_utils.envra(build,
+                          'nvr') in bodhi_builds]
+
+        # reject incomplete updates
+        if missing_builds and strict:
+            for matched_build in matched_builds:
+                failures[matched_build] = update
+            continue
+
+        # create item in build_update
+        for build in matched_builds:
+            build_update[build] = update
+
+    # some build might have been added to both @var failures and
+    # @var build_update. if that happens, remove it from @var build_update
+    for build in failures:
+        build_update.pop(build, None)
+
+    return (build_update, failures)
+
Index: libtaskotron/check.py
===================================================================
--- /dev/null
+++ libtaskotron/check.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''Helper tools for managing check status, results, and output.'''
+
+from . import python_utils
+from . import exc
+
+
+class CheckDetail(object):
+    '''Class encompassing everything related to the outcome of a check run.
+
+    @classattr result_order a tuple of result keywords sorted by priority from
+                            the least important to the most important
+    @attr result a keyword specifying the final result of the check. Available
+                 result keywords:
+                 * PASSED - everything went well
+                 * INFO - everything went well, but there is some important
+                          information that needs to be pointed out
+                 * FAILED - the item in question fails the check
+                 * NEEDS_INSPECTION - the result can't be determined and a
+                                      manual inspection is needed
+                 * ABORTED - the check aborted itself because of some unexpected
+                             problems, i.e. a necessary network server is not
+                             reachable. Running this check with the same
+                             arguments later can help to mitigate this problem.
+                 * CRASHED - the check crashed and did not provide usable
+                             results
+                 If no result is set, this attribute returns NEEDS_INSPECTION.
+    @attr summary a few words or one-sentence summary about the outcome of the
+                  check run. Should not duplicate @attr result.
+    @attr output output from the check run (a list of strings). This will form
+                 the check log. You can easily populate this by using
+                 @method log(), or you can modify it directly (for example
+                 filter out some messages you don't want to see in the check
+                 log).
+    @attr keyvals all key-value pairs in this dictionary are stored in ResultsDB
+                  as 'extra data'. This is most commonly used to store the
+                  information about tested ENVR/compose id/etc.
+    '''
+
+    result_order = ('PASSED', 'INFO', 'FAILED', 'NEEDS_INSPECTION',
+                    'ABORTED', 'CRASHED')
+
+
+    def __init__(self, result=None, summary=None, output=None):
+        '''Create new CheckDetail.
+        For param descriptions see class docstring.'''
+
+        # validate input
+        if output is not None and not python_utils.listlike(output):
+            raise exc.TaskotronValueError("'output' parameter must be a list. "
+                "Yours was: %s" % type(output))
+
+        self._result = result
+        self.summary = summary
+        self.output = output or []
+        self.keyvals = {}
+
+
+    @property
+    def result(self):
+        '''See class documetation.'''
+        return self._result or 'NEEDS_INSPECTION'
+
+
+    @result.setter
+    def result(self, value):
+        '''See class documetation.'''
+        if value not in self.result_order:
+            raise exc.TaskotronValueError('Unknown result keyword: %s' % value)
+        self._result = value
+
+
+    def update_result(self, result):
+        '''Update the self.result with provided value if it has higher priority
+        according to self.result_order than the current value. Handles None
+        values.'''
+        if self.cmp_result(result, self._result) > 0:
+            self.result = result
+
+
+    def broken(self):
+        '''Tells whether the check result is set to one of the broken states
+        (i.e. ABORTED or CRASHED).'''
+        return self.result in ['ABORTED', 'CRASHED']
+
+
+    def log(self, message, printout=True):
+        '''Add a string to the CheckDetail output log, and print it as well.
+
+        This is just a convenience method for most common use case, you can
+        of course always access and modify @attr output directly, and print or
+        use a logging directly as well. This combines both into a single call.
+
+        @param message a string to log
+        @param printout whether to print to standard output or not
+        '''
+
+        self.output.append(message)
+        if printout:
+            print message
+
+
+    @classmethod
+    def cmp_result(cls, result1, result2):
+        '''Compare two results according to self.result_order and return -1/0/1
+        of the result1 has lower/equal/higher priority than result2.
+
+        @param result1 a result keyword to compare
+        @param result2 a result keyword to compare
+        @throw TaskotronValueError if any of the result keywords are not
+                                   specified in @classatr result_order
+        '''
+
+        # validate input
+        for result in [result1, result2]:
+            if (result not in cls.result_order) and (result is not None):
+                raise exc.TaskotronValueError('Unknown result keyword: %s' %
+                                              result)
+
+        index1 = cls.result_order.index(result1) if result1 is not None else -1
+        index2 = cls.result_order.index(result2) if result2 is not None else -1
+        return cmp(index1, index2)
+
+
+    @classmethod
+    def create_multi_item_summary(cls, results, result_order=None):
+        '''Create a string containing a sum of all results, like this:
+        "3 PASSED, 1 INFO, 2 FAILED"
+
+        @param results either one CheckDetail instance or an iterable of
+                            CheckDetails or an iterable of result strings
+        @param result_order result keywords will be sorted in this order
+        '''
+        # validate input
+        if isinstance(results, CheckDetail):
+            results = (results,)
+
+        if result_order is None:
+            result_order = cls.result_order
+
+        if len(results) <= 0:
+            return ''
+
+        if isinstance(iter(results).next(), CheckDetail):
+            all_results = [detail.result for detail in results]
+        else:  # list of strings
+            all_results = results
+
+        # create the summary
+        summary = []
+        for res in result_order:
+            count = all_results.count(res)
+            if count > 0:
+                summary.append('%d %s' % (count, res))
+        summary = ', '.join(summary)
+
+        return summary
+
Index: libtaskotron/config.py
===================================================================
--- /dev/null
+++ libtaskotron/config.py
@@ -0,0 +1,208 @@
+# -*- coding: utf-8 -*-
+# Copyright 2010-2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''Global configuration for Taskotron and relevant helper methods.
+
+All configuration is done statically, all options must be defined here in this
+file in order to be accessible. They are not created dynamically from
+configuration files contents (just their values are).
+'''
+
+import os
+import ConfigParser
+
+import libtaskotron
+
+from . import python_utils
+from . import file_utils
+from . import exc
+from .logger import log
+
+
+# directory where all config files are stored
+CONF_DIR = '/etc/taskotron'
+# a conf dir location in the local checkout, in case you haven't installed the
+# config files into a system-wide location
+LOCAL_CONF_DIR = os.path.abspath(os.path.dirname(libtaskotron.__file__) +
+                                 '/../conf')
+
+# list of all configuration files loaded into Config class
+CONF_FILES = ['taskotron.conf',
+              'secrets.conf',
+             ]
+
+
+class Config(object):
+    '''Global configuration for Taskotron.
+
+       All configuration options are available as class attributes in the form
+       of Config.section.option = value. Different config files are not
+       distinguished, all sections are flattened into a single name space.
+
+       The documentation for individual options is available in their .conf
+       file.
+
+       Implementation notes:
+       * All new options must be provided here, placing them just in a .conf
+         file is not enough.
+       * When changing defaults in here, don't forget to also update the
+         corresponding .conf file.
+       * Always assign a correct value type to an attribute (e.g. use empty
+         string instead of None), because values read from .conf files are then
+         automatically re-typed according to the attribute's current value type.
+    '''
+
+    class _Section(object):
+        '''An empty class that represents a configuration section (relates to
+        sections in .conf files). All config options are assigned as instance
+        attributes.'''
+        pass
+
+    class _SectionPaths(_Section):
+        '''A special version of @class _Section for configuring file paths. It
+        makes sure that some selected paths exist after their assignment.'''
+
+        def __init__(self):
+            self._tmpdir = None
+
+        @property
+        def tmpdir(self):
+            '''Temporary directory for generic Taskotron files. If you set a
+            value, that directory is automatically created.'''
+            return self._tmpdir
+
+        @tmpdir.setter
+        def tmpdir(self, value):
+            self._tmpdir = value
+            file_utils.makedirs(self._tmpdir)
+
+
+    ### config keys definition ###
+    # == taskotron.conf ===
+    notifications = _Section()
+    notifications.send_bodhi_comments = False
+    notifications.bodhi_posting_comments_span = 4320
+
+    resources = _Section()
+    resources.koji_url = 'http://koji.fedoraproject.org/kojihub'
+    resources.pkg_url = 'http://kojipkgs.fedoraproject.org/packages'
+    resources.bodhi_server = ''
+    resources.resultsdb_server = ''
+
+    bodhi_email = _Section()
+    bodhi_email.email_all_passed = False
+    bodhi_email.email_update_state_change = True
+    bodhi_email.email_test_state_change = False
+    bodhi_email.email_always_send = False
+    bodhi_email.email_updates_testing = True
+
+    paths = _SectionPaths()
+    paths.cachedir = '/var/cache/taskotron'
+    paths.tmpdir = '/tmp/taskotron'
+    paths.lockdir = '/var/run/taskotron'
+    paths.logfile = '/var/log/taskotron/taskotron.log'
+    paths.repoinfo_conf = [os.path.join(CONF_DIR, 'repoinfo.conf'),
+                           os.path.join(LOCAL_CONF_DIR, 'repoinfo.conf')]
+
+    # == secrets.conf ==
+    secrets = _Section()
+    secrets.fas_username = 'taskotron'
+    secrets.fas_password = ''
+
+
+    @classmethod
+    def _load(cls, filelist):
+        '''Load options from a configuration file and then assign their values
+           to attributes in this class.
+
+           Empty values are ignored. Extra options and sections (not existing
+           here) are also ignored.
+
+           @param filelist a list of config file paths (type: list of strings)
+        '''
+        hint = ('Maybe you have an outdated configuration file and want to '
+                'merge it with the latest version? Or if you want to define a '
+                'new option, you have to edit libtaskotron.config module as '
+                'well.')
+
+        parser = ConfigParser.SafeConfigParser()
+        log.debug('Reading config files: %s', filelist)
+        parser.read(filelist)
+
+        for section in parser.sections():
+            if not hasattr(cls, section):
+                log.warn('Unknown section "%s" in config files "%s"', section,
+                            filelist)
+                log.warn(hint)
+                continue
+            section_conf = getattr(cls, section)
+
+            options = parser.items(section)
+            for (option, value) in options:
+                if not hasattr(section_conf, option):
+                    log.warn('Unknown option "%s" in section "%s" in config '
+                                'files "%s"', option, section, filelist)
+                    log.warn(hint)
+                    continue
+
+                # Only set option if it has a non-empty value.
+                # That allows users to leave 'option = ' lines in config file
+                # and still use default values. OTOH user can't empty out a
+                # default option value this way.
+                if value:
+                    # re-type to correct type
+                    try:
+                        value = cls._retype(getattr(section_conf, option),
+                                            value)
+                    except exc.TaskotronConfigError, e:
+                        log.exception(e)
+                        log.critical(hint)
+                        raise exc.TaskotronConfigError('Incorrect value '
+                            'provided for option "%s"' % option, e)
+
+                    # assign value
+                    setattr(section_conf, option, value)
+
+
+    @classmethod
+    def _retype(cls, old_value, new_value):
+        '''Re-type new_value according to the current type of old_value.
+
+           @return re-typed new_value (it may be the same if old_value was of
+                   the same type or None); if exception occurs during
+                   conversion then None is returned
+        '''
+
+        if old_value is not None:
+            try:
+                type_ = type(old_value)
+                # for booleans do advanced checking
+                if issubclass(type_, bool):
+                    newvalue = python_utils.getbool(new_value)
+                else:
+                    newvalue = type_(new_value)
+                return newvalue
+            except ValueError as e:
+                log.exception('Error re-typing config value')
+                raise exc.TaskotronConfigError("Can't re-type '%s' to %s" %
+                                               (new_value, type_), e)
+
+        return new_value
+
+
+    @classmethod
+    def init(cls):
+        '''Initialize the class and load values from all .conf files. This
+        method must be called before accessing class attributes. The call
+        should be ideally placed into package initialization module, as soon as
+        possible.
+        '''
+        # load configuration files
+        for conffile in CONF_FILES:
+            cls._load([os.path.join(CONF_DIR, conffile),
+                       os.path.join(LOCAL_CONF_DIR, conffile)])
+
+
+# initialize Config
+Config.init()
Index: libtaskotron/exc.py
===================================================================
--- /dev/null
+++ libtaskotron/exc.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+# Copyright 2010-2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''This module contains custom Taskotron exceptions'''
+
+class TaskotronError(Exception):
+    '''Common ancestor for Taskotron related exceptions'''
+    pass
+
+class TaskotronValueError(ValueError, TaskotronError):
+    '''Taskotron-specific ValueError'''
+    pass
+
+class TaskotronConfigError(TaskotronError):
+    '''All errors related to Taskotron config files'''
+    pass
Index: libtaskotron/file_utils.py
===================================================================
--- libtaskotron/file_utils.py
+++ libtaskotron/file_utils.py
@@ -1,26 +1,35 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''Helper functions for dealing with files and filesystems'''
+
 import os
 import sys
 import urlgrabber.grabber, urlgrabber.progress
-from libtaskotron.logger import log
+from .logger import log
 
 
 def makedirs(fullpath):
     '''This is the same as @method os.makedirs(), but does not throw an
     exception when the destination directory already exists.'''
     try:
         os.makedirs(fullpath)
         assert os.path.isdir(fullpath)
-    except OSError, e:
+    except OSError as e:
         if e.errno == 17: # "[Errno 17] File exists"
             # if it is a directory everything is ok
             if os.path.isdir(fullpath):
                 return
             # otherwise it is a file/socket/etc and it is an error
             else:
+                log.exception('Error during creating a directory')
                 raise
         else:
+            log.exception('Error during creating a directory')
             raise
 
+
 def get_urlgrabber(**kwargs):
     '''Get a new instance of URLGrabber that is reasonably set (progress
        bar when in terminal, automatic retries, connection timeout, etc)
@@ -77,18 +86,18 @@
             log.debug('Already downloaded: %s', os.path.basename(dest))
             return dest
 
-    log.info('Downloading: %s', url)
+    log.debug('Downloading: %s', url)
     try:
         grabber.urlgrab(url, dest)
-    except urlgrabber.grabber.URLGrabError, e:
-        log.error('Download error: %s', e)
+    except urlgrabber.grabber.URLGrabError as e:
+        log.exception('Could not download: %s', url)
         # the file can be incomplete, remove
         if os.path.exists(dest):
             try:
                 os.remove(dest)
-            except:
-                log.error('Could not delete incomplete file: %s', dest)
-        raise
+            except OSError:
+                log.exception('Could not delete incomplete file: %s', dest)
+        raise e
 
     return dest
 
Index: libtaskotron/koji_utils.py
===================================================================
--- /dev/null
+++ libtaskotron/koji_utils.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+# Copyright 2009-2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''Helper routines for dealing with Koji'''
+
+import xmlrpclib
+
+import koji
+import rpmUtils.miscutils
+
+from . import file_utils
+from .config import Config
+from .logger import log
+
+
+class TaskotronKojiClient(koji.ClientSession):
+    '''Convenience wrapper class for interacting with koji'''
+
+    def __init__(self, server=Config.resources.koji_url, opts=None):
+        koji.ClientSession.__init__(self, server, opts)
+
+
+    def check_connection(self):
+        '''Check the connection to koji.'''
+        # Idea taken from the koji CLI
+        try:
+            ret = self.getAPIVersion()
+            log.debug('Successfuly connected to Koji server')
+        except xmlrpclib.ProtocolError:
+            log.warn("Unable to connect to Koji server")
+            raise
+        if ret != koji.API_VERSION:
+            log.warn("Warning: Koji API mismatch (Server %d, client %d)",
+                        ret, koji.API_VERSION)
+
+
+    def nvr_to_urls(self, nvr, arches=None, debuginfo=False, src=True):
+        '''Get list of URLs for RPMs corresponding to a build.
+        @param nvr build NVR
+        @param arches restrict the arches of builds to provide URLs for. If
+                      basearch i386 is in the list, i686 arch is automatically
+                      added (since that's the arch Koji API uses).
+        '''
+        log.debug('Querying Koji for a list of RPMS for: %s', nvr)
+
+        # add i686 arch if i386 is present in arches
+        if arches and 'i386' in arches and 'i686' not in arches:
+            arches.append('i686')
+
+        info = self.getBuild(nvr)
+        baseurl = '/'.join((Config.resources.pkg_url, info['package_name'],
+                           info['version'], info['release']))
+
+        rpms = self.listRPMs(buildID=info['id'], arches=arches)
+        if not debuginfo:
+            rpms = filter(lambda r: not r['name'].endswith('-debuginfo'), rpms)
+        if not src:
+            rpms = filter(lambda r: not r['arch'] == 'src', rpms)
+
+        urls = ['%s/%s' % (baseurl, koji.pathinfo.rpm(r)) for r in rpms]
+        return sorted(urls)
+
+
+    def get_nvr_rpms(self, nvr, rpm_dir, arches=None, debuginfo=False,
+                     src=False):
+        '''Retrieve the RPMs associated with a build NVR into the specified
+           directory.
+
+        @param nvr build NVR
+        @param rpm_dir location where to store the RPMs
+        @return list of local filenames of the grabbed RPMs
+        @raise urlgrabber.grabber.URLGrabError if downloading failed
+        '''
+
+        rpm_urls = self.nvr_to_urls(nvr, arches=arches, debuginfo=debuginfo,
+                                    src=src)
+        rpm_files = []
+        log.debug('Fetching RPMs for: %s', nvr)
+        for url in rpm_urls:
+            rpm_file = file_utils.download(url, rpm_dir)
+            rpm_files.append(rpm_file)
+
+        return rpm_files
+
+
+def compareEVR(nvr1, nvr2):
+    '''Compare EVR from two NVR objects.
+    @param nvr1 Koji build object or None
+    @param nvr2 Koji build object or None
+    @return The higher of the two builds when compared according to EVR. If one
+            of the objects is None, then it's always lesser. If both are None,
+            then they are equal.
+    '''
+    # handle empty input
+    if not nvr1:
+        return 0 if not nvr2 else -1
+    if not nvr2:
+        return 1
+    # compare
+    return rpmUtils.miscutils.compareEVR((nvr1['epoch'] or '0',
+        nvr1['version'], nvr1['release']), (nvr2['epoch'] or '0',
+        nvr2['version'], nvr2['release']))
+
+
+def getENVR(build):
+    ''' Get NVR (if epoch is 0) or ENVR (otherwise) from build info
+    @param build buildinfo dictionary (as returned from koji methods like
+                 getBuild)
+    @return string NVR or ENVR '''
+    if build['epoch']:
+        return '%s:%s' % (build['epoch'], build['nvr'])
+    else:
+        return build['nvr']
+
Index: libtaskotron/python_utils.py
===================================================================
--- /dev/null
+++ libtaskotron/python_utils.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''A collection of convenience methods related to Python base libraries.'''
+
+import collections
+
+from . import exc
+
+
+def getbool(value):
+    '''Convert a string into a boolean.
+
+       Recognizes many usual strings: 1/0, yes/no, true/false, on/off; also
+       empty strings. Uses default bool conversion for non-string values.
+       @throw ValueError if @param value is a string but can't be recognized
+    '''
+    boolean_states = {'1': True, 'yes': True,  'true': True,   'on': True,
+                      '0': False, 'no': False, 'false': False, 'off': False,
+                      '': False}
+
+    if not isinstance(value, str):
+        return bool(value)
+
+    value = value.lower()
+    if value not in boolean_states:
+        raise exc.TaskotronValueError('Not a boolean: %r %s' %
+                                      (value, type(value)))
+
+    return boolean_states[value]
+
+
+def listlike(obj):
+    '''Return whether the object is iterable (list, tuple, set, etc), but not
+    string (which is an iterable, but we don't usually want to consider it as
+    such).
+    '''
+    return isinstance(obj, collections.Iterable) and not isinstance(obj,
+                                                         (str, unicode))
+
Index: libtaskotron/repoinfo.py
===================================================================
--- /dev/null
+++ libtaskotron/repoinfo.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright 2009-2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+'''A wrapper for reading repoinfo.conf easily'''
+
+import ConfigParser
+
+from .config import Config
+from .logger import log
+
+
+class RepoinfoConfig(object):
+    '''This class is a wrapper for easily accessing repoinfo.conf file.'''
+
+
+    def __init__(self, arch="$basearch", filelist=None, defaults=None):
+        '''Create RepoinfoConfig object.
+        Attributes:
+        * arch - architecture for which to adjust repo URLs
+        * filelist - list of config files to read information from
+        * defaults - dictionary of default values
+        '''
+        self.config = None
+        if defaults is None:
+            self.defaults = {}
+        else:
+            self.defaults = defaults
+        self.filelist = filelist or Config.paths.repoinfo_conf[:]
+        self.setarch(arch) # implies read()
+
+
+    def _getlist(self, name, item):
+        '''Transform a list of comma separated values into a Python list
+        Returns empty list for non-existent item'''
+        itemlist = self.config.get(name, item)
+        if not itemlist:
+            return []
+        else:
+            return [t.strip() for t in itemlist.split(',')]
+
+
+    def getarches(self, name):
+        '''Get the list of all supported architectures for a repo'''
+        return self._getlist(name, 'arches')
+
+
+    def getreleases(self):
+        '''Return the list of known, non-EOL releases.'''
+        return [r for r in repoinfo.repos() if self.config.get(r,
+                'isactiverelease')]
+
+
+    def get_bodhi_releases(self):
+        '''Return the list of releases known to Bodhi.'''
+        return [r.upper() for r in self.getreleases()]
+
+
+    def getparents(self, name):
+        '''Given a repo name, give the names of its parent repos
+        e.g.: getparents('f20-updates-testing') -> ['f20','f20-updates']'''
+        return self._getlist(name, 'parents')
+
+
+    def get_top_parents(self, reponame):
+        '''Get top parents reponames for a specified reponame.
+        @param reponame repository name
+        @return list of top parent reponames (list of strings). Usually this
+            is a single-item list, but in some cases there may be more
+            than one top parent.
+        '''
+        parents = self.getrepo(reponame)['parents']
+        if not parents:
+            return set([reponame])
+        else:
+            result = set()
+            for reponame in parents:
+                result.update(self.get_top_parents(reponame))
+            return result
+
+
+    def getrepo(self, name):
+        '''Given a repo name, return the repoinfo dict, with the keys:
+        'arches', 'parents', 'tag', 'url', 'path', 'name' and 'composes' '''
+        repo = {'arches': self.getarches(name),
+                'parents': self.getparents(name),
+                'tag': self.config.get(name,'tag'),
+                'url': self.config.get(name,'url'),
+                'path': self.config.get(name,'path'),
+                'name': name,
+                'composes': self.config.getboolean(name,'composes'),
+        }
+        return repo
+
+
+    def getrepo_by_tag(self, tag):
+        '''Given a koji tag, return the corresponding repo
+        e.g.: getrepo_by_tag('dist-f13')'''
+        # we don't have repo definition for -candidate tags,
+        # let's go up in the hierarchy (stripping '-candidate')
+        if tag.endswith('-candidate'):
+            tag = tag[:-len('-candidate')]
+        for r in self.repos():
+            if self.config.get(r,'tag') == tag:
+                return self.getrepo(r)
+
+
+    def repos(self):
+        '''Returns a list of all known repo names'''
+        return self.config.sections()
+
+
+    def setarch(self, arch):
+        '''Set architecture for which to adjust repo URLs'''
+        self.defaults['arch'] = arch
+        self.read()
+
+
+    def read(self):
+        '''Apply the defaults and then read first available config file from
+        the list of provided config files.'''
+        log.debug('Reading config files: %s', self.filelist)
+        self.config = ConfigParser.SafeConfigParser(defaults=self.defaults)
+        self.config.read(self.filelist)
+
+
+    def get(self, repo, key):
+        '''Get a specific key from a repo'''
+        return self.config.get(repo, key)
+
+
+repoinfo = RepoinfoConfig()
+
+if not repoinfo.repos():
+    log.error("No repos found in (%s)", ', '.join(
+                 Config.paths.repoinfo_conf))
+
+
+if __name__ == '__main__':
+    import pprint
+    rd = dict([(n, repoinfo.getrepo(n)) for n in repoinfo.repos()])
+    pprint.pprint(rd)
Index: libtaskotron/system_utils.py
===================================================================
--- /dev/null
+++ libtaskotron/system_utils.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, Red Hat, Inc.
+# License: GNU General Public License version 2 or later
+
+import rpmUtils
+import rpmUtils.arch
+import rpmUtils.miscutils
+
+from . import exc
+
+
+def envra(rpmstr, fmt = 'envra'):
+    """
+    @param rpmstr string to be 'manipulated' - can be standard style rpm
+                  filename, ENVRA, ENVR or NVR
+    @param fmt desired format of the string to be returned. Allowed options are:
+               ENVRA, ENVR, NVRA, NVR, E, N, V, R, A. If arch is not present in
+               rpmstr, 'noarch' is returned wherever needed.
+    @returns string based on the specified format; if epoch is requested but not
+             present, it is not included in the output string (rather than
+             prepending '0:')
+    """
+    fmt = fmt.lower()
+    supported_formats = ['envra', 'envr', 'nvra', 'nvr', 'e', 'n', 'v', 'r',
+                         'a']
+    if fmt not in supported_formats:
+        raise exc.TaskotronValueError("Format '%s' not in supported formats "
+            "(%s)" % (fmt, ', '.join(supported_formats)))
+
+    # prepare rpmstr so it can be used with rpmUtils.miscutils.splitFilename()
+    parts = rpmstr.split('.')
+    if parts[-1].lower() != 'rpm':
+        parts.append('rpm')
+    if parts[-2].lower() not in rpmUtils.arch.arches.keys() + ['noarch', 'src']:
+        parts.insert(-1, 'noarch')
+
+    # split the filename
+    (n, v, r, e, a) = rpmUtils.miscutils.splitFilename('.'.join(parts))
+
+    if len(fmt) == 1:
+        return {'e':e, 'n':n, 'v':v, 'r':r, 'a':a}[fmt]
+
+    # if epoch is not present, we do not want to return it in the
+    # envr(a) requests as '0:' but rather return nvr(a) (so we take
+    # advantage of `e` being empty string. This means, that if `e`
+    # is non-empty, we need to add add ':' to the end, since it's
+    # not present in the format string (see below)
+    if e != '':
+        e += ':'
+
+    return {'envra': "%s%s-%s-%s.%s" % (e, n, v, r, a),
+            'envr' : "%s%s-%s-%s" % (e, n, v ,r),
+            'nvra' : "%s-%s-%s.%s" % (n, v, r ,a),
+            'nvr'  : "%s-%s-%s" % (n, v, r),
+           }[fmt]
