#!/usr/bin/env python
"""
irker-svnpoller - Polls a specified SVN commit and sends it to
                  an IRC channel using irkerd

Copyright (C) 2012 by Ignacio Riquelme Morelle <shadowm2006@gmail.com>

Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice is present in all copies.

THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.
"""

import argparse, json, os, pysvn, re, socket, sys

# Whether to send full commit messages, or just the subject
# lines.
COMMIT_SUBJECTS_ONLY = False

# Maximum number of non-empty commit message body lines to
# send to irkerd when using full commit messages. This value
# is only used when COMMIT_SUBJECTS_ONLY is set to False.
# If there are more non-empty lines than this value, "(Log
# message trimmed)" will be added in a new line at the end.
# When set to 0, it behaves as if COMMIT_SUBJECTS_ONLY were
# set to True.
MAX_COMMIT_BODY_LINES = 5

# Hostname to use when connecting to an irkerd instance.
IRKER_HOST = 'localhost'

# Port number to use when connecting to an irkerd instance.
IRKER_PORT = 6659

# Sleep 10 minutes inbetween polling
SLEEP_TIME = 60 * 10

# The commit notification template. You probably do not
# need to change this.
TEMPLATE = "%(bold)s%(project)s:%(bold)s %(green)s%(author)s%(reset)s * r%(bold)s%(revision)s%(bold)s /%(files)s%(bold)s:%(bold)s %(log)s"

# No user-serviceable parts below this line.

debug = False
simulate = False

class IrkerSVNSource:
    "Represents a SVN repository from which irker notifications are generated."
    def __init__(self, project, repository):
        self.project = project
        self.repository = repository
        self._client = pysvn.Client()
        self._template = TEMPLATE

    def get_head(self):
        revision = pysvn.Revision(pysvn.opt_revision_kind.head)
        log = self._client.log(self.repository, revision, revision)[0]
        return log['revision'].number

    def generate_notification(self, revision):
        "Generate a message for irkerd deliver from the given commit."
        if revision.upper() == 'HEAD':
            revobj = pysvn.Revision(pysvn.opt_revision_kind.head)
        else:
            revobj = pysvn.Revision(pysvn.opt_revision_kind.number, revision)
        log = self._client.log(self.repository, revobj, revobj, True)[0]
        if 'author' in log:
            author = log['author']
        else:
            author = '<Unknown>'
        if 'message' in log:
            if COMMIT_SUBJECTS_ONLY:
                message = log['message'].splitlines()[0]
            else:
                message = "\n" + log['message']
        else:
            message = '<Missing commit message>'
        msg = self._template % {
            'bold': '\x02',
            'green': '\x033',
            'blue': '\x032',
            'yellow': '\x037',
            'reset': '\x0F',
            'project': self.project,
            'branch': '', # unused for SVN
            'module': '', # unused for SVN
            'revision': log['revision'].number,
            'author': author,
            'log': message,
            'files': self._format_paths(log['changed_paths'])
        }
        return msg

    def _format_paths(self, paths_dict):
        "Format a commit's changed paths list for display in a similar "
        "fashion to that of CIA bots."
        prefix, relative_paths = self._consolidate_paths(paths_dict)
        filelist_str = ' '.join(relative_paths)
        if len(filelist_str) > 60:
            filelist_str = self._format_path_summary(relative_paths)
        if prefix.startswith('/'):
            prefix = prefix[1:]
        if filelist_str:
            return "%s (%s)" % (prefix, filelist_str)
        return prefix

    def _consolidate_paths(self, paths_dict):
        "Return the common prefix for a commit's changed paths and the "
        "relative paths for each affected file."
        paths = []
        for entry in paths_dict:
            paths.append(entry['path'])
        # Optimization: if we only have one file, don't waste CPU on any of the other
        # stuff we do to pretend to be CIA.
        if len(paths) == 1:
            return paths[0], []
        prefix = re.sub("[^/]*$", "", os.path.commonprefix(paths))
        relative_paths = []
        for path in paths:
            relative = path[len(prefix):].strip()
            if relative == '':
                relative = '.'
            relative_paths.append(relative)
        return prefix, relative_paths

    def _format_path_summary(self, relative_paths):
        "Return a changed paths count summary for display."
        dirs = {}
        for path in relative_paths:
            dirs[os.path.split(path)[0]] = True
        if len(dirs) <= 1:
            return "%d files" % len(relative_paths)
        return "%d files in %d dirs" % (len(relative_paths), len(dirs))


def deliver_to_irker(uri, message):
    "Delivers the given notification for a single channel to irkerd."
    lines = []
    for line in message.splitlines():
        line = line.strip()
        if line:
            if len(lines) > MAX_COMMIT_BODY_LINES + 1:
                if MAX_COMMIT_BODY_LINES != 0:
                    lines.append("(Log message trimmed.)")
                break
            lines.append(line)
    envelope = json.dumps({ "to": uri, "privmsg": "\n".join(lines) })
    if debug:
        print envelope
    if not simulate:
        (host, port) = (IRKER_HOST, IRKER_PORT)
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            print "Sending %s" % envelope
            sock.sendto(envelope + "\n", (host, port))
        finally:
            sock.close()

def send_revision(revision, uri):
    try:
        message = source.generate_notification(str(revision))
        deliver_to_irker(uri, message)
    except pysvn.ClientError, e:
        sys.stderr.write("pysvn.ClientError: %s\n" % e.message)

def poll(source, uri):
    import time
    now = source.get_head()
    while True:
        more = source.get_head()
        for revision in xrange(now + 1, more + 1):
            send_revision(revision, uri)
        now = more

        time.sleep(SLEEP_TIME)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('-d', action='store_true', dest='debug',
                        help="show extra debugging information")
    parser.add_argument('-n', action='store_true', dest='simulate',
                        help="simulate, don't send anything to irker")
    parser.add_argument('project_id')
    parser.add_argument('repository_URI')
    parser.add_argument('revision_or_HEAD')
    parser.add_argument('channel_URI', nargs='*')
    args = parser.parse_args()
    debug = args.debug
    simulate = args.simulate
    source = IrkerSVNSource(args.project_id, args.repository_URI)
    poll(source, args.channel_URI[0])
    #try:
    #    message = source.generate_notification(args.revision_or_HEAD)
    #    for uri in args.channel_URI:
    #        deliver_to_irker(uri, message)
    #except pysvn.ClientError, e:
    #    sys.stderr.write("pysvn.ClientError: %s\n" % e.message)

# kate: indent-mode normal; encoding utf-8; space-indent on;
