 
Initial attempt to generalize checking out SRPM components, which currently
includes support for CVS, GIT, and SVN.

---

builder/kojid                     |  283 +++++++++++++++++++++++++++++++------
 builder/kojid.conf                |    7 +
 builder/kojid.init                |    2 +-
 cli/koji                          |   20 ++--
 koji/__init__.py                  |    2 +-
 www/kojiweb/taskinfo.chtml        |   10 +-
 www/kojiweb/tasks.chtml           |    2 +-
 7 files changed, 267 insertions(+), 59 deletions(-)

diff --git a/builder/kojid b/builder/kojid
index 16f18dd..0e41e9d 100755
--- a/builder/kojid
+++ b/builder/kojid
@@ -1423,7 +1423,7 @@ class ChainBuildTask(BaseTaskHandler):
             subtasks = []
             build_tasks = []
             for src in build_level:
-                if src.startswith('cvs://'):
+                if scm.is_scm_url(src):
                     task_id = session.host.subtask(method='build',
                                                    arglist=[src, target, opts],
                                                    parent=self.id)
@@ -1464,7 +1464,7 @@ class BuildTask(BaseTaskHandler):
             raise koji.BuildError, "arch_override is only allowed for scratch builds"
         task_info = session.getTaskInfo(self.id)
         # only allow admins to perform non-scratch builds from srpm
-        if not src.startswith('cvs://') and not opts.get('scratch') \
+        if not scm.is_scm_url(src) and not opts.get('scratch') \
                and not 'admin' in session.getUserPerms(task_info['owner']):
             raise koji.BuildError, "only admins may peform non-scratch builds from srpm"
         target_info = session.getBuildTarget(target)
@@ -1521,8 +1521,8 @@ class BuildTask(BaseTaskHandler):
     def getSRPM(self, src):
         """Get srpm from src"""
         if isinstance(src,str):
-            if src.startswith('cvs://'):
-                return self.getSRPMFromCVS(src)
+            if scm.is_scm_url(src):
+                return self.getSRPMFromSCM(src)
             else:
                 #assume this is a path under uploads
                 return src
@@ -1530,9 +1530,9 @@ class BuildTask(BaseTaskHandler):
             raise koji.BuildError, 'Invalid source specification: %s' % src
             #XXX - other methods?
 
-    def getSRPMFromCVS(self, url):
+    def getSRPMFromSCM(self, url):
         #TODO - allow different ways to get the srpm
-        task_id = session.host.subtask(method='buildSRPMFromCVS',
+        task_id = session.host.subtask(method='buildSRPMFromSCM',
                                        arglist=[url],
                                        label='srpm',
                                        parent=self.id)
@@ -1795,9 +1795,9 @@ class TagBuildTask(BaseTaskHandler):
             session.host.tagNotification(False, tag_id, fromtag, build_id, user_id, ignore_success, "%s: %s" % (exctype, value))
             raise e
             
-class BuildSRPMFromCVSTask(BaseTaskHandler):
+class BuildSRPMFromSCMTask(BaseTaskHandler):
 
-    Methods = ['buildSRPMFromCVS']
+    Methods = ['buildSRPMFromSCM']
     _taskWeight = 0.75
 
     def spec_sanity_checks(self, filename):
@@ -1810,42 +1810,21 @@ class BuildSRPMFromCVSTask(BaseTaskHandler):
                 raise koji.BuildError, "%s is not allowed to be defined in spec file" % tag
 
     def handler(self,url):
-        if not url.startswith('cvs://'):
-            raise koji.BuildError("invalid cvs URL: %s" % url)
-
-        # Hack it because it refuses to parse it properly otherwise
-        scheme, netloc, path, params, query, fragment = urlparse.urlparse('http'+url[3:])
-        if not (netloc and path and fragment and query):
-            raise koji.BuildError("invalid cvs URL: %s" % url)
-
-        # Steps:
-        # 1. CVS checkout into tempdir
-        # 2. create sources hardlinks
-        # 3. Run 'make srpm'
-
-        cvsdir = self.workdir + '/cvs'
-        self.logger.debug(cvsdir)
-        koji.ensuredir(cvsdir)
+        if not scm.is_scm_url(url):
+            raise koji.BuildError("invalid %s URL: %s" % (scm.get_scm_type(), url))
+
+        # Setup files and directories for SRPM creation
+        scmdir = self.workdir + '/scmroot'
+        self.logger.debug(scmdir)
+        koji.ensuredir(scmdir)
         logfile = self.workdir + "/srpm.log"
         uploadpath = self.getUploadDir()
-        sourcedir = '%s/%s' % (cvsdir, query)
+        sourcedir = '%s/%s' % (scmdir, scm.get_pkg_name(url))
 
-        #perform checkouts
-        cmd = ['cvs', '-d', ':pserver:anonymous@%s:%s' % (netloc, path),
-                         'checkout', '-r', fragment, query]
-        if log_output(cmd[0], cmd, logfile, uploadpath, cwd=cvsdir, logerror=1):
-            output = "(none)"
-            try:
-                output = open(logfile).read()
-            except IOError:
-                pass
-            raise koji.BuildError, "Error with checkout ':pserver:anonymous@%s:%s': %s" % (netloc, path, output)
-        cmd = ['cvs', '-d', ':pserver:anonymous@%s:%s' % (netloc, path),
-                        'checkout', 'common']
-        if log_output(cmd[0], cmd, logfile, uploadpath, cwd=cvsdir, logerror=1, append=1):
-            raise koji.BuildError, "Error with checkout :pserver:anonymous@%s:%s" % (netloc, path)
-        os.symlink('%s/common' % cvsdir, '%s/../common' % sourcedir)
+        # Check out spec file etc., from SCM
+        scm.do_checkout(url, scmdir, sourcedir, uploadpath, logfile)
 
+        # Find and verify that there is only one spec file.
         spec_files = glob.glob("%s/*.spec" % sourcedir)
         if len(spec_files) == 0:
             raise koji.BuildError("No spec file found")
@@ -1858,7 +1837,7 @@ class BuildSRPMFromCVSTask(BaseTaskHandler):
 
         #build srpm
         cmd = ['make', '-C', '%s' % sourcedir, 'srpm']
-        if log_output(cmd[0], cmd, logfile, uploadpath, cwd=cvsdir, logerror=1, append=1):
+        if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
             raise koji.BuildError, "Error building SRPM"
 
         srpms = glob.glob('%s/*.src.rpm' % sourcedir)
@@ -2330,6 +2309,221 @@ class WaitrepoTask(BaseTaskHandler):
             time.sleep(self.PAUSE)
         return "Successfully waited %s seconds for a %s repo (%s)" % (int(time.time() - start), build_target['build_tag_name'], repo['id'])
 
+class SCM(object):
+    "SCM abstraction class"
+
+    scmdict = { "CVS":('cvs://'), 
+        "GIT":('git://', 'http://', 'https://', 'rsync://'),
+        "GIT+SSH":('git+ssh://'),
+        "SVN":('svn://', 'http://', 'https://'),
+        "SVN+SSH":('svn+ssh://') }
+
+    def __init__(self,scmtype=None,scmusername=None):
+        global options
+        if scmtype is None:
+            self.set_scm_type(options.scmtype)
+        else:
+            self.set_scm_type(scmtype)
+        if scmusername is None:
+            self.set_scm_user(options.scmusername)
+        else:
+            self.set_scm_user(scmusername)
+
+    def get_scm_type(self):
+        "return the SCM type."
+        return self.scmtype
+
+    def set_scm_type(self, scmtype):
+        "set the SCM type."
+        if scmtype in self.scmdict.keys():
+            self.scmtype = scmtype
+        else:
+            raise GenericError, 'Unknown SCM type %s.  Known types include: %s.' % (scmtype, self.scmdict.keys())
+
+    def get_scm_user(self):
+        "return the SCM user's name."
+        return self.scmusername
+
+    def set_scm_user(self, newscmuser):
+        "set the SCM user's name."
+        global options
+        if newscmuser is None:
+            self.scmusername = options.scmusername
+        else:
+            self.scmusername = newscmuser
+
+    def is_scm_url(self,url):
+        """
+        return True if this string is a valid scm location for the configured scmtype 
+        and return False otherwise.
+        """
+    
+        # acceptable schemes from this scmtype
+        scmtup = self.scmdict[self.scmtype]
+
+        # is the scheme for the given url acceptable for this scmtype?
+        if self.get_scheme(url) in scmtup:
+            return True
+        else:
+            return False
+
+    def get_scheme(self,url):
+        "parse the scheme from the SCM url"
+
+        # parse scheme from url
+        scheme = url.split('://')[0]
+        scheme += str('://')
+
+        return scheme
+
+    def parse_url(self,url):
+        "parse the SCM url into usable components"
+
+        # ensure the url is valid for the given scmtype
+        if not self.is_scm_url(url):
+            raise GenericError("invalid %s URL: %s" % (self.scmtype, url))
+
+        # get the url's scheme
+        scheme = self.get_scheme(url)
+
+        # replace the scheme with http:// so that the urlparse works in all cases
+        hackedurl = url.replace(scheme, 'http://', 1)
+        dummyscheme, netloc, path, params, query, fragment = urlparse.urlparse(hackedurl)
+
+        # check for failure
+        if not (scheme and netloc and path and fragment and query):
+            raise GenericError("Unable to parse %s URL: %s" % (self.scmtype, url))
+
+        # return parsed values
+        return [scheme, netloc, path, params, query, fragment]
+
+    def get_pkg_name(self,url):
+        "return the package name parsed from the SCM url"
+        return self.parse_url(url)[4]
+
+    def do_checkout(self, url, scmdir, sourcedir, uploadpath, logfile):
+        "checkout common and package directories from the given SCM URL."
+
+        # TODO: sanity check arguments
+
+        if self.scmtype == "CVS":
+
+            # parse the url
+            [scheme, netloc, path, params, query, fragment] = self.parse_url(url)
+
+            # perform checkouts
+            cmd = ['cvs', '-d', ':pserver:anonymous@%s:%s' % (netloc, path),
+                             'checkout', '-r', fragment, query]
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1):
+                output = "(none)"
+                try:
+                    output = open(logfile).read()
+                except IOError:
+                    pass
+                raise BuildError, "Error with checkout ':pserver:anonymous@%s:%s': %s" % (netloc, path, output)
+            cmd = ['cvs', '-d', ':pserver:anonymous@%s:%s' % (netloc, path),
+                            'checkout', 'common']
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                raise BuildError, "Error with checkout :pserver:anonymous@%s:%s" % (netloc, path)
+            os.symlink('%s/common' % scmdir, '%s/../common' % sourcedir)
+
+        elif self.scmtype == "GIT":
+
+            # parse the url
+            [scheme, netloc, path, params, query, fragment] = self.parse_url(url)
+
+            # perform checkouts
+            cmd = ['git', 'clone', 'git://%s%s/%s' % (netloc, path, query), query]
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                output = "(none)"
+                try:
+                    output = open(logfile).read()
+                except IOError:
+                    pass
+                raise BuildError, "Error with clone 'git://%s%s/%s: %s" % (netloc, path, query, output)
+                
+            cmd = ['git', 'clone', 'git://%s%s/common' % (netloc, path)]
+            self.logger.debug("executing: %s" % cmd)
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                output = "(none)"
+                try:
+                    output = open(logfile).read()
+                except IOError:
+                    pass
+                raise BuildError, "Error with clone 'git://%s%s/common: %s" % (netloc, path, output)
+
+        elif self.scmtype == "GIT+SSH":
+
+            # parse the url
+            [scheme, netloc, path, params, query, fragment] = self.parse_url(url)
+
+            # Remove username@ from netloc if it is specified
+            netloc = re.sub('^.*@', '',netloc)
+
+            # perform checkouts
+            cmd = ['git', 'clone', '%s%s@%s%s/%s' % (scheme, self.get_scm_user(), netloc, path, query), query]
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                output = "(none)"
+                try:
+                    output = open(logfile).read()
+                except IOError:
+                    pass
+                raise BuildError, "Error with clone '%s%s@%s%s/%s: %s" % (scheme, self.get_scm_user(), netloc, path, query, output)
+                
+            cmd = ['git', 'clone', '%s%s@%s%s/common' % (scheme, self.get_scm_user(), netloc, path)]
+            self.logger.debug("executing: %s" % cmd)
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                output = "(none)"
+                try:
+                    output = open(logfile).read()
+                except IOError:
+                    pass
+                raise BuildError, "Error with clone '%s%s@%s%s/common: %s" % (scheme, self.get_scm_user(), netloc, path, output)
+
+        elif self.scmtype == "SVN":
+
+            # parse the url
+            [scheme, netloc, path, params, query, fragment] = self.parse_url(url)
+
+            # perform checkouts
+            cmd = ['svn', 'checkout', '-r', fragment, '%s%s/%s/%s' % (scheme, netloc, path, query)]
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                output = "(none)"
+                try:
+                    output = open(logfile).read()
+                except IOError:
+                    pass
+                raise BuildError, "Error with checkout %s%s/%s/%s revsion %s: %s" % (scheme, netloc, path, query, fragment, output)
+
+            cmd = ['svn', 'checkout', '%s%s/%s/common' % (scheme, netloc, path)]
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                raise BuildError, "Error with checkout %s://%s/%s/common: %s" % (scheme, netloc, path, output)
+
+        elif self.scmtype == "SVN+SSH":
+
+            # parse the url
+            [scheme, netloc, path, params, query, fragment] = self.parse_url(url)
+
+            # Remove username@ from netloc if it is specified
+            netloc = re.sub('^.*@', '',netloc)
+
+            # perform checkouts
+            cmd = ['svn', 'checkout', '-r', fragment, '%s%s@%s/%s/%s' % (scheme, self.get_scm_user(), netloc, path, query)]
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                output = "(none)"
+                try:
+                    output = open(logfile).read()
+                except IOError:
+                    pass
+                raise BuildError, "Error with checkout %s%s@%s/%s/%s revsion %s: %s" % (scheme, self.get_scm_user(), netloc, path, query, fragment, output)
+
+            cmd = ['svn', 'checkout', '%s%s@%s/%s/common' % (scheme, self.get_scm_user(), netloc, path)]
+            if log_output(cmd[0], cmd, logfile, uploadpath, cwd=scmdir, logerror=1, append=1):
+                raise BuildError, "Error with checkout %s%s@%s/%s/common: %s" % (scheme, self.get_scm_user(), netloc, path, output)
+
+        else:
+            raise koji.GenericError, "Unknown SCM type %s" % (scmtype)
+
 def get_options():
     """process options from command line and config file"""
     global options
@@ -2402,7 +2596,9 @@ def get_options():
                 'password': None,
                 'cert': '/etc/kojid/client.crt',
                 'ca': '/etc/kojid/clientca.crt',
-                'serverca': '/etc/kojid/serverca.crt'}
+                'serverca': '/etc/kojid/serverca.crt',
+                'scmtype': 'CVS',
+                'scmusername': 'anscmuser'}
     if config.has_section('kojid'):
         for name, value in config.items('kojid'):
             if name in ['sleeptime', 'maxjobs', 'minspace']:
@@ -2449,6 +2645,9 @@ if __name__ == "__main__":
     if options.admin_emails:
         koji.add_mail_logger("koji", options.admin_emails)
 
+    # get an SCM
+    scm = SCM()
+
     #build session options
     session_opts = {}
     for k in ('user','password','debug_xmlrpc', 'debug'):
diff --git a/builder/kojid.conf b/builder/kojid.conf
index c6d5ee4..bb0b0b3 100644
--- a/builder/kojid.conf
+++ b/builder/kojid.conf
@@ -48,3 +48,10 @@ from_addr=Koji Build System <buildsys@example.com>
 
 ;certificate of the CA that issued the HTTP server certificate
 ;serverca = /etc/kojid/serverca.crt
+
+; scm types include: CVS, GIT, GIT+SSH, SVN, SVN+SSH
+scmtype = CVS
+
+; username for svn+ssh:// or git+ssh://
+scmusername = kojiscmusername
+
diff --git a/builder/kojid.init b/builder/kojid.init
index 0de1203..5487271 100755
--- a/builder/kojid.init
+++ b/builder/kojid.init
@@ -36,7 +36,7 @@ start() {
     [ "$KOJID_DEBUG" == "Y" ] && ARGS="$ARGS --debug"
     [ "$KOJID_VERBOSE" == "Y" ] && ARGS="$ARGS --verbose"
     # XXX Fix for make download-checks in kernel builds
-    # Remove once we're running the buildSRPMFromCVS task
+    # Remove once we're running the buildSRPMFromSCM task
     # as an unpriviledged user with their own environment
     export HOME="/root"
     daemon /usr/sbin/kojid $ARGS
diff --git a/cli/koji b/cli/koji
index c2e3b4b..8bda8d2 100755
--- a/cli/koji
+++ b/cli/koji
@@ -645,7 +645,7 @@ def handle_build(options, session, args):
                       help=_("Run the build at a lower priority"))
     (build_opts, args) = parser.parse_args(args)
     if len(args) != 2:
-        parser.error(_("Exactly two arguments (a build target and a CVS URL or srpm file) are required"))
+        parser.error(_("Exactly two arguments (a build target and a SCM URL or srpm file) are required"))
         assert False
     if build_opts.arch_override and not build_opts.scratch:
             parser.error(_("--arch_override is only allowed for --scratch builds"))
@@ -669,7 +669,8 @@ def handle_build(options, session, args):
     if build_opts.background:
         #relative to koji.PRIO_DEFAULT
         priority = 5
-    if not source.startswith('cvs://'):
+    # try to check that source is an SRPM
+    if '/' not in source and source.endswith('.src.rpm') and len(source.split('-')) >= 3:
         # only allow admins to perform non-scratch builds from srpm
         if not opts['scratch'] and not session.hasPerm('admin'):
             parser.error(_("builds from srpm must use --scratch"))
@@ -707,7 +708,7 @@ def handle_chain_build(options, session, args):
                       help=_("Run the build at a lower priority"))
     (build_opts, args) = parser.parse_args(args)
     if len(args) < 2:
-        parser.error(_("At least two arguments (a build target and a CVS URL) are required"))
+        parser.error(_("At least two arguments (a build target and a SCM URL) are required"))
         assert False
     activate_session(session)
     target = args[0]
@@ -740,13 +741,14 @@ def handle_chain_build(options, session, args):
             if build_level:
                 src_list.append(build_level)
                 build_level = []
-        elif src.startswith('cvs://'):
+        elif '://' in src:
+            # quick check that src might be a url
             build_level.append(src)
         elif '/' not in src and len(src.split('-')) >= 3:
             # quick check that it looks like a N-V-R
             build_level.append(src)
         else:
-            print _('"%s" is not a CVS URL or package N-V-R')
+            print _('"%s" is not a SCM URL or package N-V-R')
             return 1
     if build_level:
         src_list.append(build_level)
@@ -2428,11 +2430,11 @@ def _parseTaskParams(session, method, task_id):
 
     lines = []
 
-    if method == 'buildFromCVS':
-        lines.append("CVS URL: %s" % params[0])
+    if method == 'buildFromSCM':
+        lines.append("SCM URL: %s" % params[0])
         lines.append("Build Target: %s" % params[1])
-    elif method == 'buildSRPMFromCVS':
-        lines.append("CVS URL: %s" % params[0])
+    elif method == 'buildSRPMFromSCM':
+        lines.append("SCM URL: %s" % params[0])
     elif method == 'multiArchBuild':
         lines.append("SRPM: %s/work/%s" % (options.topdir, params[0]))
         lines.append("Build Target: %s" % params[1])
diff --git a/docs/HOWTO.html b/docs/HOWTO.html
diff --git a/hub/kojihub.py b/hub/kojihub.py
diff --git a/koji.spec b/koji.spec
diff --git a/koji/__init__.py b/koji/__init__.py
index d808126..89289df 100644
--- a/koji/__init__.py
+++ b/koji/__init__.py
@@ -1520,7 +1520,7 @@ def taskLabel(taskInfo):
             else:
                 source = os.path.basename(source)
             extra = '%s, %s' % (target, source)
-    elif method == 'buildSRPMFromCVS':
+    elif method == 'buildSRPMFromSCM':
         if taskInfo.has_key('request'):
             url = taskInfo['request'][0]
             url = url[url.rfind('/') + 1:]
diff --git a/www/kojiweb/includes/header.chtml b/www/kojiweb/includes/header.chtml
diff --git a/www/kojiweb/taskinfo.chtml b/www/kojiweb/taskinfo.chtml
index 76df4bc..f99f7e6 100644
--- a/www/kojiweb/taskinfo.chtml
+++ b/www/kojiweb/taskinfo.chtml
@@ -62,11 +62,11 @@
     <tr>
       <th>Parameters</th>
       <td>
-        #if $task.method == 'buildFromCVS'
-        <strong>CVS&nbsp;URL:</strong>&nbsp;$params[0]<br/>
+        #if $task.method == 'buildFromSCM'
+        <strong>SCM&nbsp;URL:</strong>&nbsp;$params[0]<br/>
         <strong>Build Target:</strong> <a href="buildtargetinfo?name=$params[1]">$params[1]</a>
-        #elif $task.method == 'buildSRPMFromCVS'
-        <strong>CVS URL:</strong> $params[0]
+        #elif $task.method == 'buildSRPMFromSCM'
+        <strong>SCM URL:</strong> $params[0]
         #elif $task.method == 'multiArchBuild'
         <strong>SRPM:</strong> $params[0]<br/>
         <strong>Build Target:</strong> <a href="buildtargetinfo?name=$params[1]">$params[1]</a><br/>
@@ -276,7 +276,7 @@ $cgi.escape($result.faultString.strip())
         <a href="getfile?taskID=$task.id&name=$urllib.quote($filename)">$filename</a><br/>
         #end for
         #if $task.state not in ($koji.TASK_STATES.CLOSED, $koji.TASK_STATES.CANCELED, $koji.TASK_STATES.FAILED) and \
-            $task.method in ('buildSRPMFromCVS', 'buildArch', 'createrepo')
+            $task.method in ('buildSRPMFromSCM', 'buildArch', 'createrepo')
         <br/>
         <a href="watchlogs?taskID=$task.id">Watch logs</a>
         #end if
diff --git a/www/kojiweb/tasks.chtml b/www/kojiweb/tasks.chtml
index 70de06c..c34e504 100644
--- a/www/kojiweb/tasks.chtml
+++ b/www/kojiweb/tasks.chtml
@@ -103,7 +103,7 @@ All
         <select name="method" class="filterlist" onchange="javascript: window.location = 'tasks?method=' + this.value + '$util.passthrough($self, 'owner', 'hostID', 'state', 'order')';">
           <option value="all" #if $method == 'all' then 'selected="selected"' else ''#>all</option>
           <option value="build" #if $method == 'build' then 'selected="selected"' else ''#>build</option>
-          <option value="buildSRPMFromCVS" #if $method == 'buildSRPMFromCVS' then 'selected="selected"' else ''#>buildSRPMFromCVS</option>
+          <option value="buildSRPMFromSCM" #if $method == 'buildSRPMFromSCM' then 'selected="selected"' else ''#>buildSRPMFromSCM</option>
           <option value="buildArch" #if $method == 'buildArch' then 'selected="selected"' else ''#>buildArch</option>
           <option value="buildNotification" #if $method == 'buildNotification' then 'selected="selected"' else ''#>buildNotification</option>
           <option value="tagBuild" #if $method == 'tagBuild' then 'selected="selected"' else ''#>tagBuild</option>
