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.4 with the 2.6 json library installed.
8 # usage: irkerhook.py [-V] [-n]
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.
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, the channel list includes the freenode #commits list
34 default_channels = "irc://chat.freenode.net/%(project)s,irc://chat.freenode.net/#commits"
37 # No user-serviceable parts below this line:
40 import os, sys, commands, socket, urllib, json
45 return "'" + s.replace("'","'\\''") + "'"
48 return commands.getstatusoutput(command)[1]
51 def __init__(self, extractor, commit):
60 self.__dict__.update(extractor.__dict__)
62 "Produce a notification string from this commit."
63 if self.urlprefix.lower() == "none":
66 urlprefix = urlprefixmap.get(self.urlprefix, self.urlprefix)
67 webview = (urlprefix % self.__dict__) + self.commit
69 if urllib.urlopen(webview).getcode() == 404:
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()
79 return self.template % self.__dict__
81 class GenericExtractor:
82 "Generic class for encapsulating data from a VCS."
84 numerics = ["maxchannels"]
85 def __init__(self, arguments):
86 self.arguments = arguments
89 # These aren't really repo data but they belong here anyway...
91 self.tinyifier = default_tinyifier
97 self.host = socket.getfqdn()
98 # Color highlighting is disabled by default.
100 self.bold = self.green = self.blue = ""
101 self.yellow = self.brown = self.reset = ""
102 def activate_color(self, style):
108 self.yellow = '\x037'
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):
123 for line in open(conf):
125 if line.startswith("#") or not line.strip():
127 elif line.count('=') != 1:
128 sys.stderr.write('"%s", line %d: missing = in config line\n' \
131 fields = line.split('=')
133 sys.stderr.write('"%s", line %d: too many fields in config line\n' \
136 variable = fields[0].strip()
137 value = fields[1].strip()
138 if value.lower() == "true":
140 elif value.lower() == "false":
142 # User cannot set maxchannels - only a command-line arg can do that.
143 if variable == "maxchannels":
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))
162 sys.stderr.write("irkerhook.py: no project name set!\n")
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)
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])
175 # VCS-dependent code begins here
177 class GitExtractor(GenericExtractor):
178 "Metadata extraction for the git version control system."
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.
200 bare = do("git config --bool --get core.bare")
201 if bare.lower() == "true":
204 keyfile = ".git/HEAD"
207 if os.path.exists(os.path.join(here, keyfile)):
208 self.project = os.path.basename(here)
211 sys.stderr.write("irkerhook.py: no git repo below root!\n")
213 here = os.path.dirname(here)
217 "Return a symbolic reference to the tip commit of the current branch."
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':
228 else: # self.revformat == 'describe'
229 commit.rev = do("git describe %s 2>/dev/null" % shellquote(commit.commit))
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]
247 class SvnExtractor(GenericExtractor):
248 "Metadata extraction for the svn version control system."
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"))
265 sys.stderr.write("irker: under svn, hook requires a commit argument.\n")
267 def commit_factory(self, commit_id):
269 commit = Commit(self, commit_id)
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")
276 def svnlook(self, info):
277 return do("svnlook %s %s --revision %s" % (shellquote(info), shellquote(self.repository), shellquote(self.id)))
279 class HgExtractor(GenericExtractor):
280 "Metadata extraction for the Mercurial version control system."
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
298 # Called from command line: create repo/ui objects
299 from mercurial import hg, ui as uimod
302 for tok in arguments:
303 if tok.startswith('--repository='):
306 ui.readconfig(os.path.join(repopath, '.hg', 'hgrc'), repopath)
307 self.repository = hg.repository(ui, repopath)
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 '')
321 self.urlprefix = self.urlprefix.rstrip('/') + '/rev'
322 # self.commit is appended to this by do_overrides
324 self.project = os.path.basename(self.repository.root.rstrip('/'))
327 "Return a symbolic reference to the tip commit of the current branch."
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])
346 def hg_hook(ui, repo, _hooktype, node=None, _url=None, **_kwds):
347 # To be called from a Mercurial "commit" or "incoming" hook. Example
350 # incoming.irker = python:/path/to/irkerhook.py:hg_hook
351 extractor = HgExtractor([(ui, repo)])
352 ship(extractor, node, False)
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]
359 # VCS-dependent code ends here
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:
373 privmsg = str(metadata)
375 # Anti-spamming guard.
376 channel_list = extractor.channels.split(",")
377 if extractor.maxchannels != 0:
378 channel_list = channel_list[:extractor.maxchannels]
381 message = json.dumps({"to":channel_list, "privmsg":privmsg})
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")
395 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
396 sock.sendto(message + "\n", (extractor.server or default_server, IRKER_PORT))
399 except socket.error, e:
400 sys.stderr.write("%s\n" % e)
402 if __name__ == "__main__":
406 for arg in sys.argv[1:]:
410 print "irkerhook.py: version", version
412 elif arg.startswith("--repository="):
413 repository = arg[13:]
414 elif not arg.startswith("--"):
417 # Figure out which extractor we should be using
418 for candidate in extractors:
419 if candidate.is_repository(repository):
423 sys.stderr.write("irkerhook: cannot identify a repository type.\n")
425 extractor = cls(sys.argv[1:])
429 commits = [extractor.head()]
430 for commit in commits:
431 ship(extractor, commit, not notify)