#!/usr/bin/env python
#
# This is a CIA client script for Subversion repositories, written in python.
# It generates commit messages using CIA's XML format, and can deliver them
# using either XML-RPC or email. See below for usage and cuztomization
# information.
#
# --------------------------------------------------------------------------
#
# Copyright (c) 2004-2007, Micah Dowty
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#   * Redistributions of source code must retain the above copyright notice,
#     this list of conditions and the following disclaimer.
#   * Redistributions in binary form must reproduce the above copyright
#     notice, this list of conditions and the following disclaimer in the
#     documentation and/or other materials provided with the distribution.
#   * The name of the author may not be used to endorse or promote products
#     derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# --------------------------------------------------------------------------
#
# This script is cleaner and much more featureful than the shell
# script version, but won't work on systems without Python.
#
# To use the CIA bot in your Subversion repository...
#
# 1. Customize the parameters below
#
# 2. This script should be called from your repository's post-commit
#    hook with the repository and revision as arguments. For example,
#    you could copy this script into your repository's "hooks" directory
#    and add something like the following to the "post-commit" script,
#    also in the repository's "hooks" directory:
#
#      REPOS="$1"
#      REV="$2"
#      $REPOS/hooks/ciabot_svn.py "$REPOS" "$REV" &
#
#    Or, if you have multiple project hosted, you can add each
#    project's name to the commandline in that project's post-commit
#    hook:
#
#      $REPOS/hooks/ciabot_svn.py "$REPOS" "$REV" "ProjectName" &
#
############# There are some parameters for this script that you can customize:

class config:
    # Replace this with your project's name, or always provide a project
    # name on the commandline.
    #
    # NOTE: This shouldn't be a long description of your project. Ideally
    #       it is a short identifier with no spaces, punctuation, or
    #       unnecessary capitalization. This will be used in URLs related
    #       to your project, as an internal identifier, and in IRC messages.
    #       If you want a longer name shown for your project on the web
    #       interface, please use the "title" metadata key rather than
    #       putting that here.
    #
    project = "VisIt"

    # Subversion's normal directory hierarchy is powerful enough that
    # it doesn't have special methods of specifying modules, tags, or
    # branches like CVS does.  Most projects do use a naming
    # convention though that works similarly to CVS's modules, tags,
    # and branches.
    #
    # This is a list of regular expressions that are tested against
    # paths in the order specified. If a regex matches, the 'branch'
    # and 'module' groups are stored and the matching section of the
    # path is removed.
    #
    # Several common directory structure styles are below as defaults.
    # Uncomment the ones you're using, or add your own regexes.
    # Whitespace in the each regex are ignored.

    pathRegexes = [
       r"^ trunk/           (?P<module>[^/]+)/ ",
       r"^ (branches|tags)/ (?P<branch>[^/]+)/ ",
#       r"^ (branches|tags)/ (?P<module>[^/]+)/ (?P<branch>[^/]+)/ ",
    ]

    # If your repository is accessible over the web, put its base URL here
    # and 'uri' attributes will be given to all <file> elements. This means
    # that in CIA's online message viewer, each file in the tree will link
    # directly to the file in your repository.
    repositoryURI = "http://visit.ilight.com/svn/visit/"

    # If your repository is accessible over the web via a tool like ViewVC 
    # that allows viewing information about a full revision, put a format string
    # for its URL here. You can specify various substitution keys in the Python
    # syntax: "%(project)s" is replaced by the project name, and likewise
    # "%(revision)s" and "%(author)s" are replaced by the revision / author.
    # The resulting URI is added to the data sent to CIA. After this, in CIA's
    # online message viewer, the commit will link directly to the corresponding
    # revision page.
    revisionURI = None
    # Example (works for ViewVC as used by SourceForge.net):
    #revisionURI = "https://svn.sourceforge.net/viewcvs.cgi/%(project)s?view=rev&rev=%(revision)s"

    # This can be the http:// URI of the CIA server to deliver commits over
    # XML-RPC, or it can be an email address to deliver using SMTP. The
    # default here should work for most people. If you need to use e-mail
    # instead, you can replace this with "cia@cia.navi.cx"
    server = "http://cia.navi.cx"

    # The SMTP server to use, only used if the CIA server above is an
    # email address.
    smtpServer = "localhost"

    # The 'from' address to use. If you're delivering commits via email, set
    # this to the address you would normally send email from on this host.
    fromAddress = "fogal1@localhost"

    # When nonzero, print the message to stdout instead of delivering it to CIA.
    debug = 0


############# Normally the rest of this won't need modification

import sys, os, re, urllib, getopt

class File:
    """A file in a Subversion repository. According to our current
    configuration, this may have a module, branch, and URI in addition
    to a path."""

    # Map svn's status letters to our action names
    actionMap = {
        'U': 'modify',
        'A': 'add',
        'D': 'remove',
        }

    def __init__(self, fullPath, status=None):
        self.fullPath = fullPath
        self.path = fullPath
        self.action = self.actionMap.get(status)

    def getURI(self, repo):
        """Get the URI of this file, given the repository's URI. This
        encodes the full path and joins it to the given URI."""
        quotedPath = urllib.quote(self.fullPath)
        if quotedPath[0] == '/':
            quotedPath = quotedPath[1:]
        if repo[-1] != '/':
            repo = repo + '/'
        return repo + quotedPath

    def makeTag(self, config):
        """Return an XML tag for this file, using the given config"""
        attrs = {}

        if config.repositoryURI is not None:
            attrs['uri'] = self.getURI(config.repositoryURI)

        if self.action:
            attrs['action'] = self.action

        attrString = ''.join([' %s="%s"' % (key, escapeToXml(value,1))
                              for key, value in attrs.items()])
        return "<file%s>%s</file>" % (attrString, escapeToXml(self.path))


class SvnClient:
    """A CIA client for Subversion repositories. Uses svnlook to
    gather information"""
    name = 'Python Subversion client for CIA'
    version = '1.20'

    def __init__(self, repository, revision, config):
        self.repository = repository
        self.revision = revision
        self.config = config

    def deliver(self, message):
        if config.debug:
            print message
        else:
            server = self.config.server
            if server.startswith('http:') or server.startswith('https:'):
                # Deliver over XML-RPC
                import xmlrpclib
                xmlrpclib.ServerProxy(server).hub.deliver(message)
            else:
                # Deliver over email
                import smtplib
                smtp = smtplib.SMTP(self.config.smtpServer)
                smtp.sendmail(self.config.fromAddress, server,
                              "From: %s\r\nTo: %s\r\n"
                              "Subject: DeliverXML\r\n\r\n%s" %
                              (self.config.fromAddress, server, message))

    def main(self):
        self.collectData()
        self.deliver("<message>" +
                     self.makeGeneratorTag() +
                     self.makeSourceTag() +
                     self.makeBodyTag() +
                     "</message>")

    def makeAttrTags(self, *names):
        """Given zero or more attribute names, generate XML elements for
           those attributes only if they exist and are non-None.
           """
        s = ''
        for name in names:
            if hasattr(self, name):
                v = getattr(self, name)
                if v is not None:
                    # Recent Pythons don't need this, but Python 2.1
                    # at least can't convert other types directly
                    # to Unicode. We have to take an intermediate step.
                    if type(v) not in (type(''), type(u'')):
                        v = str(v)
                    
                    s += "<%s>%s</%s>" % (name, escapeToXml(v), name)
        return s

    def makeGeneratorTag(self):
        return "<generator>%s</generator>" % self.makeAttrTags(
            'name',
            'version',
            )

    def makeSourceTag(self):
        return "<source>%s</source>" % self.makeAttrTags(
            'project',
            'module',
            'branch',
            )

    def makeBodyTag(self):
        return "<body><commit>%s%s</commit></body>" % (
            self.makeAttrTags(
            'revision',
            'author',
            'log',
            'diffLines',
            'url',
            ),
            self.makeFileTags(),
            )

    def makeFileTags(self):
        """Return XML tags for our file list"""
        return "<files>%s</files>" % ''.join([file.makeTag(self.config)
                                              for file in self.files])

    def svnlook(self, command):
        """Run the given svnlook command on our current repository and
        revision, returning all output"""
        # We have to set LC_ALL to force svnlook to give us UTF-8 output,
        # then we explicitly slurp that into a unicode object.
        return unicode(os.popen(
            'LC_ALL="en_US.UTF-8" svnlook %s -r "%s" "%s"' %
            (command, self.revision, self.repository)).read(),
            'utf-8', 'replace')

    def collectData(self):
        self.author = self.svnlook('author').strip()
        self.project = self.config.project
        self.log = self.svnlook('log')
        self.diffLines = len(self.svnlook('diff').split('\n'))
        self.files = self.collectFiles()
        if self.config.revisionURI is not None:
            self.url = self.config.revisionURI % self.__dict__
        else:
            self.url = None

    def collectFiles(self):
        # Extract all the files from the output of 'svnlook changed'
        files = []
        for line in self.svnlook('changed').split('\n'):
            path = line[2:].strip()
            if path:
                status = line[0]
                files.append(File(path, status))

        # Try each of our several regexes. To be applied, the same
        # regex must mach every file under consideration and they must
        # all return the same results. If we find one matching regex,
        # or we try all regexes without a match, we're done.
        matchDict = None
        for regex in self.config.pathRegexes:
            matchDict = matchAgainstFiles(regex, files)
            if matchDict is not None:
                self.__dict__.update(matchDict)
                break

        return files


def matchAgainstFiles(regex, files):
    """Try matching a regex against all File objects in the provided list.
       If the regex returns the same matches for every file, the matches
       are returned in a dict and the matched portions are filtered out.
       If not, returns None.
       """
    prevMatchDict = None
    compiled = re.compile(regex, re.VERBOSE)
    for f in files:

        match = compiled.match(f.fullPath)
        if not match:
            # Give up, it must match every file
            return None

        matchDict = match.groupdict()
        if prevMatchDict is not None and prevMatchDict != matchDict:
            # Give up, we got conflicting matches
            return None

        prevMatchDict = matchDict

    # If we got this far, the regex matched every file with
    # the same results.  Now filter the matched portion out of
    # each file and store the matches we found.
    for f in files:
        f.path = compiled.sub('', f.fullPath)
    return prevMatchDict


def escapeToXml(text, isAttrib=0):
    text = unicode(text)
    text = text.replace("&", "&amp;")
    text = text.replace("<", "&lt;")
    text = text.replace(">", "&gt;")
    if isAttrib == 1:
        text = text.replace("'", "&apos;")
        text = text.replace("\"", "&quot;")
    return text


def usage():
    """Print a short usage description of this script and exit"""
    sys.stderr.write("Usage: %s [OPTIONS] REPOS-PATH REVISION [PROJECTNAME]\n" %
                      sys.argv[0])


def version():
    """Print out the version of this script"""
    sys.stderr.write("%s %s\n" % (sys.argv[0], SvnClient.version))


def main():
    try:
        options = [ "version" ]
        for key in config.__dict__:
            if not key.startswith("_"):
                options.append(key + "=");
        opts, args = getopt.getopt(sys.argv[1:], "", options)
    except getopt.GetoptError:
        usage()
        sys.exit(2)
    
    for o, a in opts:
        if o == "--version":
            version()
            sys.exit()
        else:
            # Everything else maps straight to a config key. Just have
            # to remove the "--" prefix from the option name.
            config.__dict__[o[2:]] = a

    # Print a usage message when not enough parameters are provided.
    if not len(args) in (2,3):
        sys.stderr.write("%s: incorrect number of arguments\n" % sys.argv[0])
        usage();
        sys.exit(2);

    # If a project name was provided, override the default project name.
    if len(args) == 3:
        config.project = args[2]

    # Go do the real work.
    SvnClient(args[0], args[1], config).main()


if __name__ == "__main__":
    main()

### The End ###
