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