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