908f6b63ce5d8379588b374f42eeff5a01a5734c
[irker.git] / irkerhook.py
1 #!/usr/bin/env python
2 # Copyright (c) 2012 Eric S. Raymond <esr@thyrsus.com>
3 # Distributed under BSD terms.
4 #
5 # This script contains git porcelain and porcelain byproducts.
6 # Requires Python 2.6, or 2.5 with the simplejson library installed.
7 #
8 # usage: irkerhook.py [-V] [-n] [--variable=value...] [commit_id...]
9 #
10 # This script is meant to be run in an update or post-commit hook.
11 # Try it with -n to see the notification dumped to stdout and verify
12 # that it looks sane. With -V this script dumps its version and exits.
13 #
14 # See the irkerhook manual page in the distribution for a detailed
15 # explanation of how to configure this hook.
16
17 # The default location of the irker proxy, if the project configuration
18 # does not override it.
19 default_server = "localhost"
20 IRKER_PORT = 6659
21
22 # The default service used to turn your web-view URL into a tinyurl so it
23 # will take up less space on the IRC notification line.
24 default_tinyifier = "http://tinyurl.com/api-create.php?url="
25
26 # Map magic urlprefix values to actual URL prefixes.
27 urlprefixmap = {
28     "viewcvs": "http://%(host)s/viewcvs/%(repo)s?view=revision&revision=",
29     "gitweb": "http://%(host)s/cgi-bin/gitweb.cgi?p=%(repo)s;a=commit;h=",
30     "cgit": "http://%(host)s/cgi-bin/cgit.cgi/%(repo)s/commit/?id=",
31     }
32
33 # By default, ship to the freenode #commits list 
34 default_channels = "irc://chat.freenode.net/#commits"
35
36 #
37 # No user-serviceable parts below this line:
38 #
39
40 version = "1.16"
41
42 import os, sys, commands, socket, urllib, subprocess, locale
43 from pipes import quote as shellquote
44 try:
45     import simplejson as json   # Faster, also makes us Python-2.5-compatible
46 except ImportError:
47     import json
48
49 def do(command):
50     return unicode(commands.getstatusoutput(command)[1], locale.getlocale()[1] or 'UTF-8').encode(locale.getlocale()[1] or 'UTF-8')
51
52 class Commit:
53     def __init__(self, extractor, commit):
54         "Per-commit data."
55         self.commit = commit
56         self.branch = None
57         self.rev = None
58         self.mail = None
59         self.author = None
60         self.files = None
61         self.logmsg = None
62         self.url = None
63         self.__dict__.update(extractor.__dict__)
64     def __unicode__(self):
65         "Produce a notification string from this commit."
66         if self.urlprefix.lower() == "none":
67             self.url = ""
68         else:
69             urlprefix = urlprefixmap.get(self.urlprefix, self.urlprefix) 
70             webview = (urlprefix % self.__dict__) + self.commit
71             try:
72                 if urllib.urlopen(webview).getcode() == 404:
73                     raise IOError
74                 if self.tinyifier and self.tinyifier.lower() != "none":
75                     try:
76                         # Didn't get a retrieval error or 404 on the web
77                         # view, so try to tinyify a reference to it.
78                         self.url = open(urllib.urlretrieve(self.tinyifier + webview)[0]).read()
79                         try:
80                             self.url = self.url.decode('UTF-8')
81                         except UnicodeError:
82                             pass
83                     except IOError:
84                         self.url = webview
85                 else:
86                     self.url = webview
87             except IOError:
88                 self.url = ""
89         return self.template % self.__dict__
90
91 class GenericExtractor:
92     "Generic class for encapsulating data from a VCS."
93     booleans = ["tcp"]
94     numerics = ["maxchannels"]
95     def __init__(self, arguments):
96         self.arguments = arguments
97         self.project = None
98         self.repo = None
99         # These aren't really repo data but they belong here anyway...
100         self.tcp = True
101         self.tinyifier = default_tinyifier
102         self.server = None
103         self.channels = None
104         self.maxchannels = 0
105         self.template = None
106         self.urlprefix = None
107         self.host = socket.getfqdn()
108         self.cialike = None
109         self.filtercmd = None
110         # Color highlighting is disabled by default.
111         self.color = None
112         self.bold = self.green = self.blue = self.yellow = ""
113         self.brown = self.magenta = self.cyan = self.reset = ""
114     def activate_color(self, style):
115         "IRC color codes."
116         if style == 'mIRC':
117             # mIRC colors are mapped as closely to the ANSI colors as
118             # possible.  However, bright colors (green, blue, red,
119             # yellow) have been made their dark counterparts since
120             # ChatZilla does not properly darken mIRC colors in the
121             # Light Motif color scheme.
122             self.bold = '\x02'
123             self.green = '\x0303'
124             self.blue = '\x0302'
125             self.red = '\x0305'
126             self.yellow = '\x0307'
127             self.brown = '\x0305'
128             self.magenta = '\x0306'
129             self.cyan = '\x0310'
130             self.reset = '\x0F'
131         if style == 'ANSI':
132             self.bold = '\x1b[1m'
133             self.green = '\x1b[1;32m'
134             self.blue = '\x1b[1;34m'
135             self.red =  '\x1b[1;31m'
136             self.yellow = '\x1b[1;33m'
137             self.brown = '\x1b[33m'
138             self.magenta = '\x1b[35m'
139             self.cyan = '\x1b[36m'
140             self.reset = '\x1b[0m'
141     def load_preferences(self, conf):
142         "Load preferences from a file in the repository root."
143         if not os.path.exists(conf):
144             return
145         ln = 0
146         for line in open(conf):
147             ln += 1
148             if line.startswith("#") or not line.strip():
149                 continue
150             elif line.count('=') != 1:
151                 sys.stderr.write('"%s", line %d: missing = in config line\n' \
152                                  % (conf, ln))
153                 continue
154             fields = line.split('=')
155             if len(fields) != 2:
156                 sys.stderr.write('"%s", line %d: too many fields in config line\n' \
157                                  % (conf, ln))
158                 continue
159             variable = fields[0].strip()
160             value = fields[1].strip()
161             if value.lower() == "true":
162                 value = True
163             elif value.lower() == "false":
164                 value = False
165             # User cannot set maxchannels - only a command-line arg can do that.
166             if variable == "maxchannels":
167                 return
168             setattr(self, variable, value)
169     def do_overrides(self):
170         "Make command-line overrides possible."
171         for tok in self.arguments:
172             for key in self.__dict__:
173                 if tok.startswith("--" + key + "="):
174                     val = tok[len(key)+3:]
175                     setattr(self, key, val)
176         for (key, val) in self.__dict__.items():
177             if key in GenericExtractor.booleans:
178                 if type(val) == type("") and val.lower() == "true":
179                     setattr(self, key, True)
180                 elif type(val) == type("") and val.lower() == "false":
181                     setattr(self, key, False)
182             elif key in GenericExtractor.numerics:
183                 setattr(self, key, int(val))
184         if not self.project:
185             sys.stderr.write("irkerhook.py: no project name set!\n")
186             raise SystemExit(1)
187         if not self.repo:
188             self.repo = self.project.lower()
189         if not self.channels:
190             self.channels = default_channels % self.__dict__
191         if self.color and self.color.lower() != "none":
192             self.activate_color(self.color)
193
194 def has(dirname, paths):
195     "Test for existence of a list of paths."
196     return all([os.path.exists(os.path.join(dirname, x)) for x in paths])
197
198 # VCS-dependent code begins here
199
200 class GitExtractor(GenericExtractor):
201     "Metadata extraction for the git version control system."
202     @staticmethod
203     def is_repository(dirname):
204         # Must detect both ordinary and bare repositories
205         return has(dirname, [".git"]) or \
206                has(dirname, ["HEAD", "refs", "objects"])
207     def __init__(self, arguments):
208         GenericExtractor.__init__(self, arguments)
209         # Get all global config variables
210         self.project = do("git config --get irker.project")
211         self.repo = do("git config --get irker.repo")
212         self.server = do("git config --get irker.server")
213         self.channels = do("git config --get irker.channels")
214         self.tcp = do("git config --bool --get irker.tcp")
215         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'
216         self.tinyifier = do("git config --get irker.tinyifier") or default_tinyifier
217         self.color = do("git config --get irker.color")
218         self.urlprefix = do("git config --get irker.urlprefix") or "gitweb"
219         self.cialike = do("git config --get irker.cialike")
220         self.filtercmd = do("git config --get irker.filtercmd")
221         # These are git-specific
222         self.refname = do("git symbolic-ref HEAD 2>/dev/null")
223         self.revformat = do("git config --get irker.revformat")
224         # The project variable defaults to the name of the repository toplevel.
225         if not self.project:
226             bare = do("git config --bool --get core.bare")
227             if bare.lower() == "true":
228                 keyfile = "HEAD"
229             else:
230                 keyfile = ".git/HEAD"
231             here = os.getcwd()
232             while True:
233                 if os.path.exists(os.path.join(here, keyfile)):
234                     self.project = os.path.basename(here)
235                     if self.project.endswith('.git'):
236                         self.project = self.project[0:-4]
237                     break
238                 elif here == '/':
239                     sys.stderr.write("irkerhook.py: no git repo below root!\n")
240                     sys.exit(1)
241                 here = os.path.dirname(here)
242         # Get overrides
243         self.do_overrides()
244     def head(self):
245         "Return a symbolic reference to the tip commit of the current branch."
246         return "HEAD"
247     def commit_factory(self, commit_id):
248         "Make a Commit object holding data for a specified commit ID."
249         commit = Commit(self, commit_id)
250         commit.branch = os.path.basename(self.refname)
251         # Compute a description for the revision
252         if self.revformat == 'raw':
253             commit.rev = commit.commit
254         elif self.revformat == 'short':
255             commit.rev = ''
256         else: # self.revformat == 'describe'
257             commit.rev = do("git describe %s 2>/dev/null" % shellquote(commit.commit))
258         if not commit.rev:
259             commit.rev = commit.commit[:12]
260         # Extract the meta-information for the commit
261         commit.files = do("git diff-tree -r --name-only " + shellquote(commit.commit))
262         commit.files = " ".join(commit.files.strip().split("\n")[1:])
263         # Design choice: for git we ship only the first line, which is
264         # conventionally supposed to be a summary of the commit.  Under
265         # other VCSes a different choice may be appropriate.
266         metainfo = do("git log -1 '--pretty=format:%an <%ae>|%s' " + shellquote(commit.commit))
267         (commit.author, _, commit.logmsg) = metainfo.partition("|")
268         commit.mail = commit.author.split()[-1].strip("<>")
269         # This discards the part of the author's address after @.
270         # Might be be nice to ship the full email address, if not
271         # for spammers' address harvesters - getting this wrong
272         # would make the freenode #commits channel into harvester heaven.
273         commit.author = commit.mail.split("@")[0]
274         return commit
275
276 class SvnExtractor(GenericExtractor):
277     "Metadata extraction for the svn version control system."
278     @staticmethod
279     def is_repository(dirname):
280         return has(dirname, ["format", "hooks", "locks"])
281     def __init__(self, arguments):
282         GenericExtractor.__init__(self, arguments)
283         # Some things we need to have before metadata queries will work
284         self.repository = '.'
285         for tok in arguments:
286             if tok.startswith("--repository="):
287                 self.repository = tok[13:]
288         self.project = os.path.basename(self.repository)
289         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'
290         self.urlprefix = "viewcvs"
291         self.load_preferences(os.path.join(self.repository, "irker.conf"))
292         self.do_overrides()
293     def head(self):
294         sys.stderr.write("irker: under svn, hook requires a commit argument.\n")
295         raise SystemExit(1)
296     def commit_factory(self, commit_id):
297         self.id = commit_id
298         commit = Commit(self, commit_id)
299         commit.branch = ""
300         commit.rev = "r%s" % self.id
301         commit.author = self.svnlook("author")
302         commit.files = self.svnlook("dirs-changed").strip().replace("\n", " ")
303         commit.logmsg = self.svnlook("log").strip()
304         return commit
305     def svnlook(self, info):
306         return do("svnlook %s %s --revision %s" % (shellquote(info), shellquote(self.repository), shellquote(self.id)))
307
308 class HgExtractor(GenericExtractor):
309     "Metadata extraction for the Mercurial version control system."
310     @staticmethod
311     def is_repository(directory):
312         return has(directory, [".hg"])
313     def __init__(self, arguments):
314         # This fiddling with arguments is necessary since the Mercurial hook can
315         # be run in two different ways: either directly via Python (in which
316         # case hg should be pointed to the hg_hook function below) or as a
317         # script (in which case the normal __main__ block at the end of this
318         # file is exercised).  In the first case, we already get repository and
319         # ui objects from Mercurial, in the second case, we have to create them
320         # from the root path.
321         self.repository = None
322         if arguments and type(arguments[0]) == type(()):
323             # Called from hg_hook function
324             ui, self.repository = arguments[0]
325             arguments = []  # Should not be processed further by do_overrides
326         else:
327             # Called from command line: create repo/ui objects
328             from mercurial import hg, ui as uimod
329
330             repopath = '.'
331             for tok in arguments:
332                 if tok.startswith('--repository='):
333                     repopath = tok[13:]
334             ui = uimod.ui()
335             ui.readconfig(os.path.join(repopath, '.hg', 'hgrc'), repopath)
336             self.repository = hg.repository(ui, repopath)
337
338         GenericExtractor.__init__(self, arguments)
339         # Extract global values from the hg configuration file(s)
340         self.project = ui.config('irker', 'project')
341         self.repo = ui.config('irker', 'repo')
342         self.server = ui.config('irker', 'server')
343         self.channels = ui.config('irker', 'channels')
344         self.tcp = str(ui.configbool('irker', 'tcp'))  # converted to bool again in do_overrides
345         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'
346         self.tinyifier = ui.config('irker', 'tinyifier') or default_tinyifier
347         self.color = ui.config('irker', 'color')
348         self.urlprefix = (ui.config('irker', 'urlprefix') or
349                           ui.config('web', 'baseurl') or '')
350         if self.urlprefix:
351             # self.commit is appended to this by do_overrides
352             self.urlprefix = self.urlprefix.rstrip('/') + '/rev/'
353         self.cialike = ui.config('irker', 'cialike')
354         self.filtercmd = ui.config('irker', 'filtercmd')
355         if not self.project:
356             self.project = os.path.basename(self.repository.root.rstrip('/'))
357         self.do_overrides()
358     def head(self):
359         "Return a symbolic reference to the tip commit of the current branch."
360         return "-1"
361     def commit_factory(self, commit_id):
362         "Make a Commit object holding data for a specified commit ID."
363         from mercurial.node import short
364         from mercurial.templatefilters import person
365         node = self.repository.lookup(commit_id)
366         commit = Commit(self, short(node))
367         # Extract commit-specific values from a "context" object
368         ctx = self.repository.changectx(node)
369         commit.rev = '%d:%s' % (ctx.rev(), commit.commit)
370         commit.branch = ctx.branch()
371         commit.author = person(ctx.user())
372         commit.logmsg = ctx.description()
373         # Extract changed files from status against first parent
374         st = self.repository.status(ctx.p1().node(), ctx.node())
375         commit.files = ' '.join(st[0] + st[1] + st[2])
376         return commit
377
378 def hg_hook(ui, repo, **kwds):
379     # To be called from a Mercurial "commit" or "incoming" hook.  Example
380     # configuration:
381     # [hooks]
382     # incoming.irker = python:/path/to/irkerhook.py:hg_hook
383     extractor = HgExtractor([(ui, repo)])
384     ship(extractor, kwds['node'], False)
385
386 # The files we use to identify a Subversion repo might occur as content
387 # in a git or hg repo, but the special subdirectories for those are more
388 # reliable indicators.  So test for Subversion last.
389 extractors = [GitExtractor, HgExtractor, SvnExtractor]
390
391 # VCS-dependent code ends here
392
393 def ship(extractor, commit, debug):
394     "Ship a notification for the specified commit."
395     metadata = extractor.commit_factory(commit)
396
397     # This is where we apply filtering
398     if extractor.filtercmd:
399         cmd = '%s %s' % (shellquote(extractor.filtercmd),
400                          shellquote(json.dumps(metadata.__dict__)))
401         data = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).stdout.read()
402         try:
403             metadata.__dict__.update(json.loads(data))
404         except ValueError:
405             sys.stderr.write("irkerhook.py: could not decode JSON: %s\n" % data)
406             raise SystemExit, 1
407
408     # Rewrite the file list if too long. The objective here is only
409     # to be easier on the eyes.
410     if extractor.cialike \
411            and extractor.cialike.lower() != "none" \
412            and len(metadata.files) > int(extractor.cialike):
413         files = metadata.files.split()
414         dirs = set([d.rpartition('/')[0] for d in files])
415         if len(dirs) == 1:
416             metadata.files = "(%s files)" % (len(files),)
417         else:
418             metadata.files = "(%s files in %s dirs)" % (len(files), len(dirs))
419     # Message reduction.  The assumption here is that IRC can't handle
420     # lines more than 510 characters long. If we exceed that length, we
421     # try knocking out the file list, on the theory that for notification
422     # purposes the commit text is more important.  If it's still too long
423     # there's nothing much can be done other than ship it expecting the IRC
424     # server to truncate.
425     privmsg = unicode(metadata)
426     if len(privmsg) > 510:
427         metadata.files = ""
428         privmsg = unicode(metadata)
429
430     # Anti-spamming guard.  It's deliberate that we get maxchannels not from
431     # the user-filtered metadata but from the extractor data - means repo
432     # administrators can lock in that setting.
433     channels = metadata.channels.split(",")
434     if extractor.maxchannels != 0:
435         channels = channels[:extractor.maxchannels]
436
437     # Ready to ship.
438     message = json.dumps({"to": channels, "privmsg": privmsg})
439     if debug:
440         print message
441     elif channels:
442         try:
443             if extractor.tcp:
444                 try:
445                     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
446                     sock.connect((extractor.server or default_server, IRKER_PORT))
447                     sock.sendall(message + "\n")
448                 finally:
449                     sock.close()
450             else:
451                 try:
452                     sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
453                     sock.sendto(message + "\n", (extractor.server or default_server, IRKER_PORT))
454                 finally:
455                     sock.close()
456         except socket.error, e:
457             sys.stderr.write("%s\n" % e)
458
459 if __name__ == "__main__":
460     notify = True
461     repository = os.getcwd()
462     commits = []
463     for arg in sys.argv[1:]:
464         if arg == '-n':
465             notify = False
466         elif arg == '-V':
467             print "irkerhook.py: version", version
468             sys.exit(0)
469         elif arg.startswith("--repository="):
470             repository = arg[13:]
471         elif not arg.startswith("--"):
472             commits.append(arg)
473
474     # Figure out which extractor we should be using
475     for candidate in extractors:
476         if candidate.is_repository(repository):
477             cls = candidate
478             break
479     else:
480         sys.stderr.write("irkerhook: cannot identify a repository type.\n")
481         raise SystemExit(1)
482     extractor = cls(sys.argv[1:])
483
484     # And apply it.
485     if not commits:
486         commits = [extractor.head()]
487     for commit in commits:
488         ship(extractor, commit, not notify)
489
490 #End