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