Version bump for release.
[irker.git] / irkerhook.py
index a02c5c52da2cbb0bd77c72fcb8d244d6c88997fd..1ee62feb7328cde9d29623b237c362152470f981 100755 (executable)
@@ -3,7 +3,7 @@
 # Distributed under BSD terms.
 #
 # This script contains git porcelain and porcelain byproducts.
-# Requires Python 2.6, or 2.4 with the 2.6 json library installed.
+# Requires Python 2.6, or 2.5 with the simplejson library installed.
 #
 # usage: irkerhook.py [-V] [-n] [--variable=value...] [commit_id...]
 #
@@ -30,24 +30,24 @@ urlprefixmap = {
     "cgit": "http://%(host)s/cgi-bin/cgit.cgi/%(repo)s/commit/?id=",
     }
 
-# By default, the channel list includes the freenode #commits list 
-default_channels = "irc://chat.freenode.net/%(project)s,irc://chat.freenode.net/#commits"
+# By default, ship to the freenode #commits list
+default_channels = "irc://chat.freenode.net/#commits"
 
 #
 # No user-serviceable parts below this line:
 #
 
-version = "1.7"
+version = "2.9"
 
-import os, sys, commands, socket, urllib, subprocess
+import os, sys, commands, socket, urllib, subprocess, locale, datetime, re
 from pipes import quote as shellquote
 try:
-    import simplejson as json  # Faster, also makes us Python-2.4-compatible
+    import simplejson as json  # Faster, also makes us Python-2.5-compatible
 except ImportError:
     import json
 
 def do(command):
-    return commands.getstatusoutput(command)[1]
+    return unicode(commands.getstatusoutput(command)[1], locale.getlocale()[1] or 'UTF-8').encode(locale.getlocale()[1] or 'UTF-8')
 
 class Commit:
     def __init__(self, extractor, commit):
@@ -60,36 +60,48 @@ class Commit:
         self.files = None
         self.logmsg = None
         self.url = None
+        self.author_date = None
+        self.commit_date = None
         self.__dict__.update(extractor.__dict__)
-    def __str__(self):
+    def __unicode__(self):
         "Produce a notification string from this commit."
         if self.urlprefix.lower() == "none":
             self.url = ""
         else:
-            urlprefix = urlprefixmap.get(self.urlprefix, self.urlprefix) 
+            urlprefix = urlprefixmap.get(self.urlprefix, self.urlprefix)
             webview = (urlprefix % self.__dict__) + self.commit
             try:
                 if urllib.urlopen(webview).getcode() == 404:
                     raise IOError
-                try:
-                    # Didn't get a retrieval error or 404 on the web
-                    # view, so try to tinyify a reference to it.
-                    self.url = open(urllib.urlretrieve(self.tinyifier + webview)[0]).read()
-                except IOError:
+                if self.tinyifier and self.tinyifier.lower() != "none":
+                    try:
+                        # Didn't get a retrieval error or 404 on the web
+                        # view, so try to tinyify a reference to it.
+                        self.url = open(urllib.urlretrieve(self.tinyifier + webview)[0]).read()
+                        try:
+                            self.url = self.url.decode('UTF-8')
+                        except UnicodeError:
+                            pass
+                    except IOError:
+                        self.url = webview
+                else:
                     self.url = webview
             except IOError:
                 self.url = ""
-        return self.template % self.__dict__
+        res = self.template % self.__dict__
+        return unicode(res, 'UTF-8') if not isinstance(res, unicode) else res
 
 class GenericExtractor:
     "Generic class for encapsulating data from a VCS."
     booleans = ["tcp"]
     numerics = ["maxchannels"]
+    strings = ["email"]
     def __init__(self, arguments):
         self.arguments = arguments
         self.project = None
         self.repo = None
         # These aren't really repo data but they belong here anyway...
+        self.email = None
         self.tcp = True
         self.tinyifier = default_tinyifier
         self.server = None
@@ -98,27 +110,39 @@ class GenericExtractor:
         self.template = None
         self.urlprefix = None
         self.host = socket.getfqdn()
+        self.cialike = None
         self.filtercmd = None
         # Color highlighting is disabled by default.
         self.color = None
-        self.bold = self.green = self.blue = ""
-        self.yellow = self.brown = self.reset = ""
+        self.bold = self.green = self.blue = self.yellow = ""
+        self.brown = self.magenta = self.cyan = self.reset = ""
     def activate_color(self, style):
         "IRC color codes."
         if style == 'mIRC':
+            # mIRC colors are mapped as closely to the ANSI colors as
+            # possible.  However, bright colors (green, blue, red,
+            # yellow) have been made their dark counterparts since
+            # ChatZilla does not properly darken mIRC colors in the
+            # Light Motif color scheme.
             self.bold = '\x02'
-            self.green = '\x033'
-            self.blue = '\x032'
-            self.yellow = '\x037'
-            self.brown = '\x035'
+            self.green = '\x0303'
+            self.blue = '\x0302'
+            self.red = '\x0305'
+            self.yellow = '\x0307'
+            self.brown = '\x0305'
+            self.magenta = '\x0306'
+            self.cyan = '\x0310'
             self.reset = '\x0F'
         if style == 'ANSI':
-            self.bold = '\x1b[1m;'
-            self.green = '\x1b[1;2m;'
-            self.blue = '\x1b[1;4m;'
-            self.yellow = '\x1b[1;3m;'
-            self.brown = '\x1b[3m;'
-            self.reset = '\x1b[0m;'
+            self.bold = '\x1b[1m'
+            self.green = '\x1b[1;32m'
+            self.blue = '\x1b[1;34m'
+            self.red = '\x1b[1;31m'
+            self.yellow = '\x1b[1;33m'
+            self.brown = '\x1b[33m'
+            self.magenta = '\x1b[35m'
+            self.cyan = '\x1b[36m'
+            self.reset = '\x1b[0m'
     def load_preferences(self, conf):
         "Load preferences from a file in the repository root."
         if not os.path.exists(conf):
@@ -162,6 +186,8 @@ class GenericExtractor:
                     setattr(self, key, False)
             elif key in GenericExtractor.numerics:
                 setattr(self, key, int(val))
+            elif key in GenericExtractor.strings:
+                setattr(self, key, val)
         if not self.project:
             sys.stderr.write("irkerhook.py: no project name set!\n")
             raise SystemExit(1)
@@ -174,7 +200,11 @@ class GenericExtractor:
 
 def has(dirname, paths):
     "Test for existence of a list of paths."
-    return all([os.path.exists(os.path.join(dirname, x)) for x in paths])
+    # all() is a python2.5 construct
+    for exists in [os.path.exists(os.path.join(dirname, x)) for x in paths]:
+        if not exists:
+            return False
+    return True
 
 # VCS-dependent code begins here
 
@@ -192,10 +222,13 @@ class GitExtractor(GenericExtractor):
         self.repo = do("git config --get irker.repo")
         self.server = do("git config --get irker.server")
         self.channels = do("git config --get irker.channels")
+        self.email = do("git config --get irker.email")
         self.tcp = do("git config --bool --get irker.tcp")
         self.template = '%(bold)s%(project)s:%(reset)s %(green)s%(author)s%(reset)s %(repo)s:%(yellow)s%(branch)s%(reset)s * %(bold)s%(rev)s%(reset)s / %(bold)s%(files)s%(reset)s: %(logmsg)s %(brown)s%(url)s%(reset)s'
+        self.tinyifier = do("git config --get irker.tinyifier") or default_tinyifier
         self.color = do("git config --get irker.color")
         self.urlprefix = do("git config --get irker.urlprefix") or "gitweb"
+        self.cialike = do("git config --get irker.cialike")
         self.filtercmd = do("git config --get irker.filtercmd")
         # These are git-specific
         self.refname = do("git symbolic-ref HEAD 2>/dev/null")
@@ -211,6 +244,8 @@ class GitExtractor(GenericExtractor):
             while True:
                 if os.path.exists(os.path.join(here, keyfile)):
                     self.project = os.path.basename(here)
+                    if self.project.endswith('.git'):
+                        self.project = self.project[0:-4]
                     break
                 elif here == '/':
                     sys.stderr.write("irkerhook.py: no git repo below root!\n")
@@ -224,7 +259,7 @@ class GitExtractor(GenericExtractor):
     def commit_factory(self, commit_id):
         "Make a Commit object holding data for a specified commit ID."
         commit = Commit(self, commit_id)
-        commit.branch = os.path.basename(self.refname)
+        commit.branch = re.sub(r"^refs/[^/]*/", "", self.refname)
         # Compute a description for the revision
         if self.revformat == 'raw':
             commit.rev = commit.commit
@@ -237,17 +272,18 @@ class GitExtractor(GenericExtractor):
         # Extract the meta-information for the commit
         commit.files = do("git diff-tree -r --name-only " + shellquote(commit.commit))
         commit.files = " ".join(commit.files.strip().split("\n")[1:])
-        # Design choice: for git we ship only the first line, which is
+        # Design choice: for git we ship only the first message line, which is
         # conventionally supposed to be a summary of the commit.  Under
         # other VCSes a different choice may be appropriate.
-        metainfo = do("git log -1 '--pretty=format:%an <%ae>|%s' " + shellquote(commit.commit))
-        (commit.author, commit.logmsg) = metainfo.split("|")
-        commit.mail = commit.author.split()[-1].strip("<>")
+        commit.author_name, commit.mail, commit.logmsg = \
+            do("git log -1 '--pretty=format:%an%n%ae%n%s' " + shellquote(commit.commit)).split("\n")
         # This discards the part of the author's address after @.
         # Might be be nice to ship the full email address, if not
         # for spammers' address harvesters - getting this wrong
         # would make the freenode #commits channel into harvester heaven.
         commit.author = commit.mail.split("@")[0]
+        commit.author_date, commit.commit_date = \
+            do("git log -1 '--pretty=format:%ai|%ci' " + shellquote(commit.commit)).split("|")
         return commit
 
 class SvnExtractor(GenericExtractor):
@@ -276,8 +312,9 @@ class SvnExtractor(GenericExtractor):
         commit.branch = ""
         commit.rev = "r%s" % self.id
         commit.author = self.svnlook("author")
+        commit.commit_date = self.svnlook("date").partition('(')[0]
         commit.files = self.svnlook("dirs-changed").strip().replace("\n", " ")
-        commit.logmsg = self.svnlook("log")
+        commit.logmsg = self.svnlook("log").strip()
         return commit
     def svnlook(self, info):
         return do("svnlook %s %s --revision %s" % (shellquote(info), shellquote(self.repository), shellquote(self.id)))
@@ -318,14 +355,18 @@ class HgExtractor(GenericExtractor):
         self.repo = ui.config('irker', 'repo')
         self.server = ui.config('irker', 'server')
         self.channels = ui.config('irker', 'channels')
+        self.email = ui.config('irker', 'email')
         self.tcp = str(ui.configbool('irker', 'tcp'))  # converted to bool again in do_overrides
         self.template = '%(bold)s%(project)s:%(reset)s %(green)s%(author)s%(reset)s %(repo)s:%(yellow)s%(branch)s%(reset)s * %(bold)s%(rev)s%(reset)s / %(bold)s%(files)s%(reset)s: %(logmsg)s %(brown)s%(url)s%(reset)s'
+        self.tinyifier = ui.config('irker', 'tinyifier') or default_tinyifier
         self.color = ui.config('irker', 'color')
         self.urlprefix = (ui.config('irker', 'urlprefix') or
                           ui.config('web', 'baseurl') or '')
         if self.urlprefix:
-            self.urlprefix = self.urlprefix.rstrip('/') + '/rev'
             # self.commit is appended to this by do_overrides
+            self.urlprefix = self.urlprefix.rstrip('/') + '/rev/'
+        self.cialike = ui.config('irker', 'cialike')
+        self.filtercmd = ui.config('irker', 'filtercmd')
         if not self.project:
             self.project = os.path.basename(self.repository.root.rstrip('/'))
         self.do_overrides()
@@ -343,6 +384,8 @@ class HgExtractor(GenericExtractor):
         commit.rev = '%d:%s' % (ctx.rev(), commit.commit)
         commit.branch = ctx.branch()
         commit.author = person(ctx.user())
+        commit.author_date = \
+            datetime.datetime.fromtimestamp(ctx.date()[0]).strftime('%Y-%m-%d %H:%M:%S')
         commit.logmsg = ctx.description()
         # Extract changed files from status against first parent
         st = self.repository.status(ctx.p1().node(), ctx.node())
@@ -350,12 +393,20 @@ class HgExtractor(GenericExtractor):
         return commit
 
 def hg_hook(ui, repo, **kwds):
-    # To be called from a Mercurial "commit" or "incoming" hook.  Example
-    # configuration:
+    # To be called from a Mercurial "commit", "incoming" or "changegroup" hook.
+    # Example configuration:
     # [hooks]
     # incoming.irker = python:/path/to/irkerhook.py:hg_hook
     extractor = HgExtractor([(ui, repo)])
-    ship(extractor, kwds['node'], False)
+    start = repo[kwds['node']].rev()
+    end = len(repo)
+    if start != end:
+        # changegroup with multiple commits, so we generate a notification
+        # for each one
+        for rev in range(start, end):
+            ship(extractor, rev, False)
+    else:
+        ship(extractor, kwds['node'], False)
 
 # The files we use to identify a Subversion repo might occur as content
 # in a git or hg repo, but the special subdirectories for those are more
@@ -377,18 +428,29 @@ def ship(extractor, commit, debug):
             metadata.__dict__.update(json.loads(data))
         except ValueError:
             sys.stderr.write("irkerhook.py: could not decode JSON: %s\n" % data)
-            raise SystemExit, 1
+            raise SystemExit(1)
 
+    # Rewrite the file list if too long. The objective here is only
+    # to be easier on the eyes.
+    if extractor.cialike \
+           and extractor.cialike.lower() != "none" \
+           and len(metadata.files) > int(extractor.cialike):
+        files = metadata.files.split()
+        dirs = set([d.rpartition('/')[0] for d in files])
+        if len(dirs) == 1:
+            metadata.files = "(%s files)" % (len(files),)
+        else:
+            metadata.files = "(%s files in %s dirs)" % (len(files), len(dirs))
     # Message reduction.  The assumption here is that IRC can't handle
     # lines more than 510 characters long. If we exceed that length, we
     # try knocking out the file list, on the theory that for notification
     # purposes the commit text is more important.  If it's still too long
     # there's nothing much can be done other than ship it expecting the IRC
     # server to truncate.
-    privmsg = str(metadata)
+    privmsg = unicode(metadata)
     if len(privmsg) > 510:
         metadata.files = ""
-        privmsg = str(metadata)
+        privmsg = unicode(metadata)
 
     # Anti-spamming guard.  It's deliberate that we get maxchannels not from
     # the user-filtered metadata but from the extractor data - means repo
@@ -403,7 +465,22 @@ def ship(extractor, commit, debug):
         print message
     elif channels:
         try:
-            if extractor.tcp:
+            if extractor.email:
+                # We can't really figure out what our SF username is without
+                # exploring our environment. The mail pipeline doesn't care
+                # about who sent the mail, other than being from sourceforge.
+                # A better way might be to simply call mail(1)
+                sender = "irker@users.sourceforge.net"
+                msg = """From: %(sender)s
+Subject: irker json
+
+%(message)s""" % {"sender":sender, "message":message}
+                import smtplib
+                smtp = smtplib.SMTP()
+                smtp.connect()
+                smtp.sendmail(sender, extractor.email, msg)
+                smtp.quit()
+            elif extractor.tcp:
                 try:
                     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                     sock.connect((extractor.server or default_server, IRKER_PORT))