2 # Copyright (c) 2012 Eric S. Raymond <esr@thyrsus.com>
3 # Distributed under BSD terms.
5 # This script contains git porcelain and porcelain byproducts.
6 # Requires Python 2.6, or 2.5 with the simplejson library installed.
8 # usage: irkerhook.py [-V] [-n] [--variable=value...] [commit_id...]
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.
14 # See the irkerhook manual page in the distribution for a detailed
15 # explanation of how to configure this hook.
17 # The default location of the irker proxy, if the project configuration
18 # does not override it.
19 default_server = "localhost"
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="
26 # Map magic urlprefix values to actual URL prefixes.
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=",
33 # By default, ship to the freenode #commits list
34 default_channels = "irc://chat.freenode.net/#commits"
37 # No user-serviceable parts below this line:
42 import os, sys, commands, socket, urllib, subprocess, locale
43 from pipes import quote as shellquote
45 import simplejson as json # Faster, also makes us Python-2.5-compatible
50 return unicode(commands.getstatusoutput(command)[1], locale.getlocale()[1] or 'UTF-8').encode(locale.getlocale()[1] or 'UTF-8')
53 def __init__(self, extractor, commit):
63 self.__dict__.update(extractor.__dict__)
64 def __unicode__(self):
65 "Produce a notification string from this commit."
66 if self.urlprefix.lower() == "none":
69 urlprefix = urlprefixmap.get(self.urlprefix, self.urlprefix)
70 webview = (urlprefix % self.__dict__) + self.commit
72 if urllib.urlopen(webview).getcode() == 404:
74 if self.tinyifier and self.tinyifier.lower() != "none":
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()
80 self.url = self.url.decode('UTF-8')
89 return self.template % self.__dict__
91 class GenericExtractor:
92 "Generic class for encapsulating data from a VCS."
94 numerics = ["maxchannels"]
95 def __init__(self, arguments):
96 self.arguments = arguments
99 # These aren't really repo data but they belong here anyway...
101 self.tinyifier = default_tinyifier
106 self.urlprefix = None
107 self.host = socket.getfqdn()
109 self.filtercmd = None
110 # Color highlighting is disabled by default.
112 self.bold = self.green = self.blue = self.yellow = ""
113 self.brown = self.magenta = self.cyan = self.reset = ""
114 def activate_color(self, style):
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.
123 self.green = '\x0303'
126 self.yellow = '\x0307'
127 self.brown = '\x0305'
128 self.magenta = '\x0306'
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):
146 for line in open(conf):
148 if line.startswith("#") or not line.strip():
150 elif line.count('=') != 1:
151 sys.stderr.write('"%s", line %d: missing = in config line\n' \
154 fields = line.split('=')
156 sys.stderr.write('"%s", line %d: too many fields in config line\n' \
159 variable = fields[0].strip()
160 value = fields[1].strip()
161 if value.lower() == "true":
163 elif value.lower() == "false":
165 # User cannot set maxchannels - only a command-line arg can do that.
166 if variable == "maxchannels":
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))
185 sys.stderr.write("irkerhook.py: no project name set!\n")
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)
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])
198 # VCS-dependent code begins here
200 class GitExtractor(GenericExtractor):
201 "Metadata extraction for the git version control system."
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.
226 bare = do("git config --bool --get core.bare")
227 if bare.lower() == "true":
230 keyfile = ".git/HEAD"
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]
239 sys.stderr.write("irkerhook.py: no git repo below root!\n")
241 here = os.path.dirname(here)
245 "Return a symbolic reference to the tip commit of the current branch."
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':
256 else: # self.revformat == 'describe'
257 commit.rev = do("git describe %s 2>/dev/null" % shellquote(commit.commit))
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]
276 class SvnExtractor(GenericExtractor):
277 "Metadata extraction for the svn version control system."
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"))
294 sys.stderr.write("irker: under svn, hook requires a commit argument.\n")
296 def commit_factory(self, commit_id):
298 commit = Commit(self, commit_id)
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()
305 def svnlook(self, info):
306 return do("svnlook %s %s --revision %s" % (shellquote(info), shellquote(self.repository), shellquote(self.id)))
308 class HgExtractor(GenericExtractor):
309 "Metadata extraction for the Mercurial version control system."
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
327 # Called from command line: create repo/ui objects
328 from mercurial import hg, ui as uimod
331 for tok in arguments:
332 if tok.startswith('--repository='):
335 ui.readconfig(os.path.join(repopath, '.hg', 'hgrc'), repopath)
336 self.repository = hg.repository(ui, repopath)
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 '')
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')
356 self.project = os.path.basename(self.repository.root.rstrip('/'))
359 "Return a symbolic reference to the tip commit of the current branch."
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])
378 def hg_hook(ui, repo, **kwds):
379 # To be called from a Mercurial "commit" or "incoming" hook. Example
382 # incoming.irker = python:/path/to/irkerhook.py:hg_hook
383 extractor = HgExtractor([(ui, repo)])
384 ship(extractor, kwds['node'], False)
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]
391 # VCS-dependent code ends here
393 def ship(extractor, commit, debug):
394 "Ship a notification for the specified commit."
395 metadata = extractor.commit_factory(commit)
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()
403 metadata.__dict__.update(json.loads(data))
405 sys.stderr.write("irkerhook.py: could not decode JSON: %s\n" % data)
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])
416 metadata.files = "(%s files)" % (len(files),)
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:
428 privmsg = unicode(metadata)
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]
438 message = json.dumps({"to": channels, "privmsg": privmsg})
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")
452 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
453 sock.sendto(message + "\n", (extractor.server or default_server, IRKER_PORT))
456 except socket.error, e:
457 sys.stderr.write("%s\n" % e)
459 if __name__ == "__main__":
461 repository = os.getcwd()
463 for arg in sys.argv[1:]:
467 print "irkerhook.py: version", version
469 elif arg.startswith("--repository="):
470 repository = arg[13:]
471 elif not arg.startswith("--"):
474 # Figure out which extractor we should be using
475 for candidate in extractors:
476 if candidate.is_repository(repository):
480 sys.stderr.write("irkerhook: cannot identify a repository type.\n")
482 extractor = cls(sys.argv[1:])
486 commits = [extractor.head()]
487 for commit in commits:
488 ship(extractor, commit, not notify)