#! /usr/bin/python
# vim:set encoding=utf-8 tw=78 sw=4 sts=4 et:
#=============================================================================
#
#                        grit: really stupid BTS tool
#
#-----------------------------------------------------------------------------
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions
#  are met:
#
#  1. Redistributions of source code must retain the above copyright
#     notice, this list of conditions and the following disclaimer.
#  2. 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.
#  3. The name of the contributors may not be used to endorse or promote
#     products derived from this software without specific prior written
#     permission.
#
#  THE SOFTWARE IS PROVIDED BY THE 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 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.
#-----------------------------------------------------------------------------
#
#  Copyright © 2007:
#     Pierre Habouzit <madcoder@madism.org>
#
#=============================================================================

import errno, os, os.path, popen2, sys
import atexit, getopt, inspect, tempfile
import mailbox, email

NAME="grit"

def fatal(prefix, cmd, exit = 1):
    if prefix: cmd = "%s %s" % (prefix, cmd)
    if sys.stdout.isatty() and exit:
        print "\033[31;1m%s\033[0m" % cmd
    else:
        print cmd
    sys.exit(exit)

def die(*args): fatal("fatal:", " ".join(args))

def trydo(f, *args):
    try:    f(*args)
    except: pass

def trace(level, *args):
    if level <= bts.verbosity:
        pfx = ["", "", "+ ", "++ ", "+++ "]
        msg = pfx[level] + " ".join(args)
        if sys.stdout.isatty() and level > 1:
            print "\033[33m%s\033[0m" % msg
        else:
            print msg

def doit(cmd, *args, **kwargs):
    def quote(s):
        if s in ('>', '>>', '2>', '|'): return s
        return "'%s'" % s
    args = [quote(s.replace('\\', '\\\\').replace("'", "\\'")) for s in args]
    cmd += " " + " ".join(args)
    trace(2, cmd)
    if "silent" in kwargs and kwargs["silent"]:
        cmd += " 2> /dev/null"
    child = popen2.Popen3(cmd)
    if "input" in kwargs:
        child.tochild.write(kwargs["input"])
    child.tochild.close()
    r = child.fromchild.readlines()
    child.fromchild.close()
    if child.wait():
        raise RunetimeError
    return r

class pipe:
    def __init__(self, *cmd, **kwopts):
        fds = [os.pipe(), os.pipe(), os.pipe]
        self.pid = os.fork()
        if self.pid == 0: # child
            os.dup2(fds[0][0], sys.stdin.fileno())
            os.dup2(fds[1][1], sys.stdout.fileno())
            os.dup2(fds[2][1], sys.stderr.fileno())
            for fd1, fd2 in fds:
                os.close(fd1)
                os.close(fd2)
            try:
                os.execvp(cmd[0], cmd)
            finally:
                os._exit(1)
        os.close(fds[0][1])
        os.close(fds[1][0])
        os.close(fds[2][0])
        self.tochild   = os.fdopen(fds[0][0], 'w')
        self.fromchild = os.fdopen(fds[1][1], 'r')
        self.childerr  = os.fdopen(fds[2][1], 'r')

class git:
    version = None
    GIT_DIR = None
    TOP_DIR = None

    @classmethod
    def init(cls):
        try:
            cls.version = cls.run("--version")[0][12:].rstrip()
        except:
            die("Could not find the \"git\" command")
        try:
            cls.GIT_DIR = os.path.abspath(cls.get("rev-parse", "--git-dir"))
            trace(2, "GIT_DIR:", cls.GIT_DIR)
            os.environ["GIT_DIR"] = cls.GIT_DIR
        except:
            die("Not a git repository...")
        try:
            os.chdir(cls.get("rev-parse", "--show-cdup") or ".")
            cls.TOP_DIR = os.getcwd()
            trace(2, "TOP_DIR:", cls.TOP_DIR)
        except:
            die("Could not find top git directory")

    @classmethod
    def run(cls, *args, **kwargs): return doit("git", *args, **kwargs)

    @classmethod
    def get(cls, *args, **kwargs):
        return cls.run(*args, **kwargs)[0].rstrip()

    @classmethod
    def object_exists(cls, rev):
        try:
            return git.get("rev-parse", "--verify", rev, silent=True)
        except:
            return None

    @classmethod
    def commit(cls, files, branch, log, **kwopts):
        msg = log.rstrip() + "\n"
        if type(files) != type([]):
            files = [files]
        git.run("update-index", "--add", *files)
        sha = cls.get("write-tree")
        if kwopts.get("newbranch", None):
            sha = cls.get("commit-tree", sha, input=msg)
            cls.run("update-ref", branch, sha, '')
        else:
            sha = cls.get("commit-tree", sha, "-p", branch, input=msg)
            cls.run("update-ref", branch, sha)

class cmd_opts:
    def __init__(self, short = "", long = [], strict = 1):
        if inspect.isfunction(short):
            die("%s uses @cmd_opts() decorator without ()" % short.__name__)
        self.short  = "h" + short
        self.long   = ["help"] + long
        self.strict = strict

    def usage(self, x = 1):
        usage  = NAME + " [options] " + self.func.__name__[4:]
        for opt in self.long:
            if opt[-1] == '=':
                usage += " [--%s...]" % opt
            else:
                usage += " [--%s]" % opt

        for i in xrange(1, self.func.func_code.co_argcount):
            usage += " <%s>" % self.func.func_code.co_varnames[i]
        if self.func.func_code.co_flags & 4:
            usage += " ..."
        fatal("usage:", usage, x)

    def __call__(self, func, *args):
        self.func = func

        def wrapper(cls, *a):
            trace(2, "Entering command", func.__name__[4:])
            try:
                opts, args = getopt.getopt(a, self.short, self.long)
            except getopt.GetoptError:
                self.usage()

            opts = dict(opts)
            if "-h" in opts or "--help" in opts:
                self.usage(0)

            if not func.func_code.co_flags & 4: # no *args
                if len(args) != func.func_code.co_argcount - 1:
                    self.usage()
            elif len(args) < func.func_code.co_argcount - 1:
                self.usage()
            func(cls, *args, **opts)
        wrapper.__doc__ = func.__doc__
        return wrapper

class bts:
    """
    this class holds the configuration and the commands.

    every bts class variable not starting with a _ can be overridden using
    git-config
    """

    verbosity = 1
    branch    = "bts"
    _branch   = None
    _bugsdir  = None
    _workdir  = None
    _index    = None

    @classmethod
    def usage(cls, x = 1):
        print "usage:", NAME + " [--branch <branch>] <command>"
        print ""
        print NAME + " commands are:"
        for m in dir(cls):
            if m.startswith("cmd_"):
                print "   %-12s  %s" % (m[4:], getattr(cls, m).__doc__)
        sys.exit(x)

    @classmethod
    def init(cls):
        for k in dir(cls):
            if k[0] == '_': continue
            try:
                v = git.get("config", "--get", NAME + "." + variable)
                setattr(cls, k, v)
            except: pass

        try:
            opts, args = getopt.getopt(sys.argv[1:], "b:hv",
                    ["branch=", "help"])
        except getopt.GetoptError: cls.usage()

        for o, a in opts:
            if o in ('-b', '--branch'):
                cls.branch = a
            elif o in ('-h', '--help'):
                cls.usage(0)
            elif o in ('-v'):
                cls.verbosity += 1

        if len(args) < 1: cls.usage()
        git.init()

        cls._branch = "refs/heads/" + cls.branch
        cls._bugsdir = os.path.join(git.GIT_DIR, NAME)
        if not os.path.isdir(cls._bugsdir):
            try:    os.mkdir(cls._bugsdir)
            except: die("Unable to create $GIT_DIR/" + NAME)
        cls._workdir = os.path.join(cls._bugsdir, cls.branch)
        if not os.path.isdir(cls._workdir):
            try:    os.mkdir(cls._workdir)
            except: die("Unable to create $GIT_DIR/%s/%s directory"
                        % (NAME, cls.branch))
        os.chdir(cls._workdir)

        cls._index = os.path.join(cls._bugsdir, cls.branch) + '.index'
        os.environ['GIT_INDEX_FILE'] = cls._index
        trace(2, "GIT_INDEX_FILE:", cls._index)
        if not git.object_exists(cls._branch):
            trydo(os.unlink, cls._index)
        try:
            git.run("ls-tree", "-r", "--full-name", cls._branch, "|",
                    "git", "update-index", "--index-info", silent=True)
        except:
            die("unable to refresh index file")
        return args

    @classmethod
    def run(cls, cmd, *args):
        fun = getattr(cls, "cmd_" + cmd, None)
        if not fun: die(cmd.replace('-', '_'), "is not a valid command")
        if fun(*args):
            die("Command", cmd, "returned non 0 value")

    @classmethod
    def bug_exists(cls, bugsha):
        rev = "%s:%s/%s" % (cls._branch, bugsha[:2], bugsha[2:])
        return git.object_exists(rev) is not None

    @classmethod
    def bug_find(cls, sha):
        if len(sha) < 4:
            die("Not enough digits given, we need at least 4")
        if len(sha) > 40:
            die("Not a sha: more than 40 digits given")
        if len(sha) == 40 and cls.bug_exists(sha): return sha
        lines = git.run("ls-tree", "-r", "--full-name", "--name-only",
                        cls._branch, sha[:2])
        lines = [s.replace('/', '').rstrip() for s in lines
                 if s.replace('/', '').startswith(sha)]
        if not len(lines): die("No bug found with id", sha)
        if len(lines) == 1:
            return lines[0]
        die("Many bugs found with short id", sha + ":\n" + "\n".join(lines))

    @classmethod
    @cmd_opts()
    def cmd_init(cls, **kwopts):
        """initialize a grit branch"""
        try:
            f = file("README", 'a')
            f.write('\n'.join([
                "this branch is a Bug Tracking branch",
                "",
                "*DO NOT* edit files if you don't know what you are doing",
                "please use %s instead" % NAME
            ]))
            f.close()
            git.commit("README", cls._branch,
                    "initialize BTS branch", newbranch=True)
        except:
            die("unable to create", cls.branch, "branch (exists already ?)")
        trace(1, "BTS branch", cls.branch, "created")

    @classmethod
    @cmd_opts()
    def cmd_batch(cls):
        """run a batch of commands"""
        for l in sys.stdin.readlines():
            cls.run(*l.split(" "))

    @classmethod
    @cmd_opts("a", ["all"])
    def cmd_clean(cls, **kwopts):
        """clean grits caches"""
        if "-a" in kwopts or "--all" in kwopts:
            doit("rm -rf", cls._bugsdir)
            trace(1, NAME + " caches and indexes removed")
            return
        else:
            doit("sh -c", "rm -rf " + cls._bugsdir + "/*/")
            trace(1, NAME + " caches cleaned")

    @classmethod
    @cmd_opts("f:", ["file="])
    def cmd_add(cls, commitish, *cishs, **kwopts):
        """add a new bug report"""
        at_exit = []
        fname = kwopts.get('-f', kwopts.get('--file', None))
        if fname:
            f = file(fname, 'rb')
        else:
            try:
                fd, fname = tempfile.mkstemp()
                atexit.register(os.unlink, fname)
                f = os.fdopen(fd, 'ab')
                f.writelines(sys.stdin.readlines())
                f.seek(0)
            except:
                die("unable to create temporary file")
        try:
            msg = email.message_from_file(f)
        except:
            die("Bug does not looks like a valid mail")
        if not msg.get("Subject", None):
            die("Bug has no subject")
        try:
            bugid = git.get("hash-object", fname)
            if cls.bug_exists(bugid):
                die("Bug already present in the database, or sha1 COLLISION")
            try:
                os.mkdir(bugid[:2])
            except OSError, e:
                if e.errno != errno.EEXIST: raise
            bug = file(os.path.join(bugid[:2], bugid[2:]), 'wb')
            bug.write(msg.__str__())
            bug.close()
            f.close()
            git.commit(bug.name, cls._branch, msg['Subject'])
        except:
            die("Unable to submit bug")
        trace(1, "Bug %s submitted" % bugid)

    @classmethod
    @cmd_opts("", ["full"])
    def cmd_bugs(cls, **kwopts):
        """list bugs in the spool"""
        files = git.run("ls-tree", "-r", "--full-name", "--name-only",
                        cls._branch)
        nb = 8
        if "--full" in kwopts: nb = 40
        for s in files:
            if len(s) != 42 or s[2] != '/': continue
            print s.replace('/', '')[:nb]

    @classmethod
    @cmd_opts("", ["stdout"])
    def cmd_mbox(cls, bugid, **kwopts):
        """get a bug report mbox"""
        bugid = cls.bug_find(bugid)
        bug = os.path.join(bugid[:2], bugid[2:])
        git.run("checkout-index", "-f", "--", bug)
        fname = os.path.join(cls._workdir, bug)
        if "--stdout" in kwopts:
            sys.stdout.writelines(file(fname, 'rb').readlines())
        else:
            print fname

if __name__ == "__main__":
    args = bts.init()
    while True and len(args):
        try:
            comma = args.index(',')
            bts.run(*args[:comma])
            args = args[comma + 1:]
        except:
            bts.run(*args)
            break
