X-Git-Url: http://git.tremily.us/?a=blobdiff_plain;ds=sidebyside;f=irkerhook.py;h=d17248a59552e8cb098b439f49ebfc9a1693d3ac;hb=9a53ff42a920e015b819f762d916604289f15502;hp=6e0a86665b87927e2eb0de5e87acb8a1d0d766f0;hpb=61e13f0183a9f529f7acd68669d57d59bd8078c3;p=irker.git diff --git a/irkerhook.py b/irkerhook.py index 6e0a866..d17248a 100755 --- a/irkerhook.py +++ b/irkerhook.py @@ -3,13 +3,13 @@ # 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. @@ -30,22 +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: # -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): @@ -53,12 +55,15 @@ class 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 = "" @@ -68,25 +73,35 @@ class 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 @@ -95,29 +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 = "" - def head(self): - "Return a symbolic reference to the tip commit of the current branch." - return "HEAD" + 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): @@ -161,9 +186,11 @@ 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 + raise SystemExit(1) if not self.repo: self.repo = self.project.lower() if not self.channels: @@ -171,8 +198,23 @@ class GenericExtractor: 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 @@ -180,10 +222,14 @@ 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") self.revformat = do("git config --get irker.revformat") @@ -198,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") @@ -205,6 +253,9 @@ class GitExtractor(GenericExtractor): 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) @@ -221,24 +272,29 @@ 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.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:] @@ -247,20 +303,27 @@ class SvnExtractor(GenericExtractor): 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 @@ -292,17 +355,24 @@ 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() + 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 @@ -314,49 +384,103 @@ 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()) commit.files = ' '.join(st[0] + st[1] + st[2]) return commit - def head(self): - "Return a symbolic reference to the tip commit of the current branch." - return "-1" -def hg_hook(ui, repo, hooktype, node=None, url=None, **_kwds): - # To be called from a Mercurial "commit" or "incoming" hook. Example - # configuration: +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)]) - ship(extractor, 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 +# 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 = str(metadata) + privmsg = unicode(metadata) if len(privmsg) > 510: metadata.files = "" - privmsg = str(metadata) + privmsg = unicode(metadata) - # Anti-spamming guard. - channel_list = extractor.channels.split(",") + # 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: - channel_list = channel_list[:extractor.maxchannels] + channels = channels[:extractor.maxchannels] # Ready to ship. - message = json.dumps({"to":channel_list, "privmsg":privmsg}) + message = json.dumps({"to": channels, "privmsg": privmsg}) if debug: print message - else: + 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)) @@ -374,8 +498,7 @@ def ship(extractor, commit, debug): if __name__ == "__main__": notify = True - repository = "." - refname = None + repository = os.getcwd() commits = [] for arg in sys.argv[1:]: if arg == '-n': @@ -383,31 +506,24 @@ if __name__ == "__main__": 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, ".hg")): - vcs = "hg" - elif 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": - extractor = SvnExtractor(sys.argv[1:]) - elif vcs == "hg": - extractor = HgExtractor(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:]) + 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: ship(extractor, commit, not notify)