Newer
Older
#!/usr/bin/env python
# Copyright (c) 2012 Eric S. Raymond <esr@thyrsus.com>
# 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.
# usage: irkerhook.py [-V] [-n] [--variable=value...] [commit_id...]
# 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.
# The default location of the irker proxy, if the project configuration
# does not override it.
# The default service used to turn your web-view URL into a tinyurl so it
# will take up less space on the IRC notification line.
default_tinyifier = "http://tinyurl.com/api-create.php?url="
# Map magic urlprefix values to actual URL prefixes.
urlprefixmap = {
"viewcvs": "http://%(host)s/viewcvs/%(repo)s?view=revision&revision=",
"gitweb": "http://%(host)s/cgi-bin/gitweb.cgi?p=%(repo)s;a=commit;h=",
"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"
#
# No user-serviceable parts below this line:
#
import os, sys, commands, socket, urllib, json
def shellquote(s):
return "'" + s.replace("'","'\\''") + "'"
def do(command):
return commands.getstatusoutput(command)[1]
class Commit:
def __init__(self, extractor, commit):
"Per-commit data."
self.commit = commit
self.branch = None
self.rev = None
self.author = None
self.files = None
self.logmsg = None
self.url = None
self.__dict__.update(extractor.__dict__)
def __str__(self):
"Produce a notification string from this commit."
if self.urlprefix.lower() == "none":
self.url = ""
else:
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:
self.url = webview
except IOError:
self.url = ""
return self.template % self.__dict__
class GenericExtractor:
"Generic class for encapsulating data from a VCS."
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.tcp = True
self.tinyifier = default_tinyifier
self.server = None
self.channels = None
self.maxchannels = 0
self.template = None
self.urlprefix = None
self.host = socket.getfqdn()
self.bold = self.green = self.blue = ""
self.yellow = self.brown = self.reset = ""
if style == 'mIRC':
self.bold = '\x02'
self.green = '\x033'
self.blue = '\x032'
self.yellow = '\x037'
self.brown = '\x035'
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;'
def load_preferences(self, conf):
"Load preferences from a file in the repository root."
if not os.path.exists(conf):
return
ln = 0
for line in open(conf):
ln += 1
if line.startswith("#") or not line.strip():
continue
elif line.count('=') != 1:
sys.stderr.write('"%s", line %d: missing = in config line\n' \
% (conf, ln))
continue
fields = line.split('=')
if len(fields) != 2:
sys.stderr.write('"%s", line %d: too many fields in config line\n' \
% (conf, ln))
continue
variable = fields[0].strip()
value = fields[1].strip()
if value.lower() == "true":
value = True
elif value.lower() == "false":
value = False
# User cannot set maxchannels - only a command-line arg can do that.
if variable == "maxchannels":
setattr(self, variable, value)
def do_overrides(self):
"Make command-line overrides possible."
for tok in self.arguments:
for key in self.__dict__:
if tok.startswith("--" + key + "="):
val = tok[len(key)+3:]
setattr(self, key, val)
for (key, val) in self.__dict__.items():
if key in GenericExtractor.booleans:
if type(val) == type("") and val.lower() == "true":
elif type(val) == type("") and val.lower() == "false":
elif key in GenericExtractor.numerics:
setattr(self, key, int(val))
if not self.project:
sys.stderr.write("irkerhook.py: no project name set!\n")
raise SystemExit, 1
if not self.repo:
self.repo = self.project.lower()
if not self.channels:
self.channels = default_channels % self.__dict__
if self.color and self.color.lower() != "none":
self.activate_color(self.color)
"Test for existence of a list of paths."
return all([os.path.exists(os.path.join(dirname, x)) for x in paths])
# VCS-dependent code begins here
class GitExtractor(GenericExtractor):
"Metadata extraction for the git version control system."
# 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.project = do("git config --get irker.project")
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.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.color = do("git config --get irker.color")
self.urlprefix = do("git config --get irker.urlprefix") or "gitweb"
# These are git-specific
self.refname = do("git symbolic-ref HEAD 2>/dev/null")
# The project variable defaults to the name of the repository toplevel.
bare = do("git config --bool --get core.bare")
if bare.lower() == "true":
keyfile = "HEAD"
else:
keyfile = ".git/HEAD"
here = os.getcwd()
while True:
if os.path.exists(os.path.join(here, keyfile)):
self.project = os.path.basename(here)
break
elif here == '/':
sys.stderr.write("irkerhook.py: no git repo below root!\n")
sys.exit(1)
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)
commit.branch = os.path.basename(self.refname)
# Compute a description for the revision
if self.revformat == 'raw':
commit.rev = commit.commit
elif self.revformat == 'short':
commit.rev = ''
else: # self.revformat == 'describe'
commit.rev = do("git describe %s 2>/dev/null" % shellquote(commit.commit))
if not commit.rev:
commit.rev = commit.commit[:12]
# 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
# 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("|")
# 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]
return commit
class SvnExtractor(GenericExtractor):
"Metadata extraction for the svn version control system."
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
if tok.startswith("--repository="):
self.repository = tok[13:]
self.project = os.path.basename(self.repository)
self.template = '%(bold)s%(project)s%(reset)s: %(green)s%(author)s%(reset)s %(repo)s * %(bold)s%(rev)s%(reset)s / %(bold)s%(files)s%(reset)s: %(logmsg)s %(brown)s%(url)s%(reset)s'
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.files = self.svnlook("dirs-changed").strip().replace("\n", " ")
commit.logmsg = self.svnlook("log")
return commit
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
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.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.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
if not self.project:
self.project = os.path.basename(self.repository.root.rstrip('/'))
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
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.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" or "incoming" hook. Example
# configuration:
# [hooks]
# incoming.irker = python:/path/to/irkerhook.py:hg_hook
extractor = HgExtractor([(ui, repo)])
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)
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# 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 debug:
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)
if __name__ == "__main__":
notify = True
Eric S. Raymond
committed
repository = os.getcwd()
commits = []
for arg in sys.argv[1:]:
if arg == '-n':
notify = False
sys.exit(0)
elif arg.startswith("--repository="):
repository = arg[13:]
elif not arg.startswith("--"):
commits.append(arg)
# Figure out which extractor we should be using
for candidate in extractors:
if candidate.is_repository(repository):
cls = candidate
break
else:
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()]
ship(extractor, commit, not notify)