# 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]
+# usage: irkerhook.py [-V] [-n] [--variable=value...] [commit_id...]
#
-# This script is meant to be run in a post-commit hook. Try it with
-# -n to see the notification dumped to stdout and verify that it looks
-# sane. With -V this script dumps its version and exits.
+# This script is meant to be run in an update or post-commit hook.
+# Try it with -n to see the notification dumped to stdout and verify
+# that it looks sane. With -V this script dumps its version and exits.
#
# See the irkerhook manual page in the distribution for a detailed
# explanation of how to configure this hook.
"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:
#
-import os, sys, commands, socket, urllib, json
+version = "2.6"
-version = "1.4"
-
-def shellquote(s):
- return "'" + s.replace("'","'\\''") + "'"
+import os, sys, commands, socket, urllib, subprocess, locale, datetime
+from pipes import quote as shellquote
+try:
+ 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):
self.commit = commit
self.branch = None
self.rev = None
+ self.mail = None
self.author = None
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 = ""
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
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):
setattr(self, key, True)
elif type(val) == type("") and val.lower() == "false":
setattr(self, key, False)
- elif key in GenericExtractor.numerics:
- setattr(self, key, int(val))
+ 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
+ raise SystemExit(1)
if not self.repo:
self.repo = self.project.lower()
if not self.channels:
if self.color and self.color.lower() != "none":
self.activate_color(self.color)
+def has(dirname, paths):
+ "Test for existence of a list of 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
+
class GitExtractor(GenericExtractor):
"Metadata extraction for the git version control system."
+ @staticmethod
+ def is_repository(dirname):
+ # Must detect both ordinary and bare repositories
+ return has(dirname, [".git"]) or \
+ has(dirname, ["HEAD", "refs", "objects"])
def __init__(self, arguments):
GenericExtractor.__init__(self, arguments)
# Get all global config variables
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")
self.revformat = do("git config --get irker.revformat")
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")
here = os.path.dirname(here)
# Get overrides
self.do_overrides()
+ def head(self):
+ "Return a symbolic reference to the tip commit of the current branch."
+ return "HEAD"
def commit_factory(self, commit_id):
"Make a Commit object holding data for a specified commit ID."
commit = Commit(self, commit_id)
# 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.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.author.replace("<", "").split("@")[0].split()[-1]
+ 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):
"Metadata extraction for the svn version control system."
+ @staticmethod
+ def is_repository(dirname):
+ return has(dirname, ["format", "hooks", "locks"])
def __init__(self, arguments):
GenericExtractor.__init__(self, arguments)
# Some things we need to have before metadata queries will work
- self.repository = None
+ self.repository = '.'
for tok in arguments:
if tok.startswith("--repository="):
self.repository = tok[13:]
self.urlprefix = "viewcvs"
self.load_preferences(os.path.join(self.repository, "irker.conf"))
self.do_overrides()
+ def head(self):
+ sys.stderr.write("irker: under svn, hook requires a commit argument.\n")
+ raise SystemExit(1)
def commit_factory(self, commit_id):
self.id = commit_id
commit = Commit(self, commit_id)
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)))
+class HgExtractor(GenericExtractor):
+ "Metadata extraction for the Mercurial version control system."
+ @staticmethod
+ def is_repository(directory):
+ return has(directory, [".hg"])
+ def __init__(self, arguments):
+ # This fiddling with arguments is necessary since the Mercurial hook can
+ # be run in two different ways: either directly via Python (in which
+ # case hg should be pointed to the hg_hook function below) or as a
+ # script (in which case the normal __main__ block at the end of this
+ # file is exercised). In the first case, we already get repository and
+ # ui objects from Mercurial, in the second case, we have to create them
+ # from the root path.
+ self.repository = None
+ if arguments and type(arguments[0]) == type(()):
+ # Called from hg_hook function
+ ui, self.repository = arguments[0]
+ arguments = [] # Should not be processed further by do_overrides
+ else:
+ # Called from command line: create repo/ui objects
+ from mercurial import hg, ui as uimod
+
+ repopath = '.'
+ for tok in arguments:
+ if tok.startswith('--repository='):
+ repopath = tok[13:]
+ ui = uimod.ui()
+ ui.readconfig(os.path.join(repopath, '.hg', 'hgrc'), repopath)
+ self.repository = hg.repository(ui, repopath)
+
+ GenericExtractor.__init__(self, arguments)
+ # Extract global values from the hg configuration file(s)
+ self.project = ui.config('irker', 'project')
+ 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.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()
+ def head(self):
+ "Return a symbolic reference to the tip commit of the current branch."
+ return "-1"
+ def commit_factory(self, commit_id):
+ "Make a Commit object holding data for a specified commit ID."
+ from mercurial.node import short
+ from mercurial.templatefilters import person
+ node = self.repository.lookup(commit_id)
+ commit = Commit(self, short(node))
+ # Extract commit-specific values from a "context" object
+ ctx = self.repository.changectx(node)
+ 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())
+ commit.files = ' '.join(st[0] + st[1] + st[2])
+ return commit
+
+def hg_hook(ui, repo, **kwds):
+ # 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)])
+ 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
+# reliable indicators. So test for Subversion last.
+extractors = [GitExtractor, HgExtractor, SvnExtractor]
+
+# VCS-dependent code ends here
+
+def ship(extractor, commit, debug):
+ "Ship a notification for the specified commit."
+ metadata = extractor.commit_factory(commit)
+
+ # This is where we apply filtering
+ if extractor.filtercmd:
+ cmd = '%s %s' % (shellquote(extractor.filtercmd),
+ shellquote(json.dumps(metadata.__dict__)))
+ data = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).stdout.read()
+ try:
+ metadata.__dict__.update(json.loads(data))
+ except ValueError:
+ sys.stderr.write("irkerhook.py: could not decode JSON: %s\n" % data)
+ 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 = unicode(metadata)
+ if len(privmsg) > 510:
+ metadata.files = ""
+ 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
+ # administrators can lock in that setting.
+ channels = metadata.channels.split(",")
+ if extractor.maxchannels != 0:
+ channels = channels[:extractor.maxchannels]
+
+ # Ready to ship.
+ message = json.dumps({"to": channels, "privmsg": privmsg})
+ if debug:
+ print message
+ elif channels:
+ try:
+ 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))
+ sock.sendall(message + "\n")
+ finally:
+ sock.close()
+ else:
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.sendto(message + "\n", (extractor.server or default_server, IRKER_PORT))
+ finally:
+ sock.close()
+ except socket.error, e:
+ sys.stderr.write("%s\n" % e)
+
if __name__ == "__main__":
notify = True
- repository = None
- refname = None
+ repository = os.getcwd()
commits = []
for arg in sys.argv[1:]:
if arg == '-n':
elif arg == '-V':
print "irkerhook.py: version", version
sys.exit(0)
- elif arg.startswith("--refname="):
- refname = arg[10:]
elif arg.startswith("--repository="):
repository = arg[13:]
elif not arg.startswith("--"):
commits.append(arg)
- # Determine the repository type. Default to git unless user has pointed
- # us at a repo with identifiable internals.
- vcs = "git"
- if repository and os.path.exists(os.path.join(repository, "format")):
- vcs = "svn"
-
- # Someday we'll have extractors for several version-control systems
- if vcs == "svn":
- if repository is None or not commits:
- sys.stderr.write("irkerhook: svn requires a repository and a commit.")
- sys.exit(1)
- extractor = SvnExtractor(sys.argv[1:])
+ # Figure out which extractor we should be using
+ for candidate in extractors:
+ if candidate.is_repository(repository):
+ cls = candidate
+ break
else:
- extractor = GitExtractor(sys.argv[1:])
- if not commits:
- commits = [do("git rev-parse HEAD")]
+ sys.stderr.write("irkerhook: cannot identify a repository type.\n")
+ raise SystemExit(1)
+ extractor = cls(sys.argv[1:])
+ # And apply it.
+ if not commits:
+ commits = [extractor.head()]
for commit in commits:
- metadata = extractor.commit_factory(commit)
-
- # 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)
- if len(privmsg) > 510:
- metadata.files = ""
- privmsg = str(metadata)
-
- # Anti-spamming guard.
- channel_list = extractor.channels.split(",")
- if extractor.maxchannels != 0:
- channel_list = channel_list[:extractor.maxchannels]
-
- # Ready to ship.
- message = json.dumps({"to":channel_list, "privmsg":privmsg})
- if not notify:
- print message
- else:
- try:
- if extractor.tcp:
- try:
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.connect((extractor.server or default_server, IRKER_PORT))
- sock.sendall(message + "\n")
- finally:
- sock.close()
- else:
- try:
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- sock.sendto(message + "\n", (extractor.server or default_server, IRKER_PORT))
- finally:
- sock.close()
- except socket.error, e:
- sys.stderr.write("%s\n" % e)
+ ship(extractor, commit, not notify)
#End