3aa2160e1737f718676dc719dfea26d538376a51
[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.3"
43
44 def shellquote(s):
45     return "'" + s.replace("'","'\\''") + "'"
46
47 def do(command):
48     return commands.getstatusoutput(command)[1]
49
50 class GenericExtractor:
51     "Generic class for encapsulating data from a VCS."
52     booleans = ["tcp"]
53     numerics = ["maxchannels"]
54     def __init__(self, arguments):
55         self.arguments = arguments
56         self.project = None
57         self.repo = None
58         # These aren't really repo data but they belong here anyway...
59         self.tcp = True
60         self.tinyifier = default_tinyifier
61         self.server = None
62         self.channels = None
63         self.maxchannels = 0
64         self.template = None
65         self.urlprefix = None
66         self.host = socket.getfqdn()
67         # Per-commit data begins
68         self.author = None
69         self.files = None
70         self.logmsg = None
71         self.rev = None
72         # Color highlighting is disabled by default.
73         self.color = None
74         self.bold = self.green = self.blue = ""
75         self.yellow = self.brown = self.reset = ""
76     def activate_color(self, style):
77         "IRC color codes."
78         if style == 'mIRC':
79             self.bold = '\x02'
80             self.green = '\x033'
81             self.blue = '\x032'
82             self.yellow = '\x037'
83             self.brown = '\x035'
84             self.reset = '\x0F'
85         if style == 'ANSI':
86             self.bold = '\x1b[1m;'
87             self.green = '\x1b[1;2m;'
88             self.blue = '\x1b[1;4m;'
89             self.yellow = '\x1b[1;3m;'
90             self.brown = '\x1b[3m;'
91             self.reset = '\x1b[0m;'
92     def load_preferences(self, conf):
93         "Load preferences from a file in the repository root."
94         if not os.path.exists(conf):
95             return
96         ln = 0
97         for line in open(conf):
98             ln += 1
99             if line.startswith("#") or not line.strip():
100                 continue
101             elif line.count('=') != 1:
102                 sys.stderr.write('"%s", line %d: missing = in config line\n' \
103                                  % (conf, ln))
104                 continue
105             fields = line.split('=')
106             if len(fields) != 2:
107                 sys.stderr.write('"%s", line %d: too many fields in config line\n' \
108                                  % (conf, ln))
109                 continue
110             variable = fields[0].strip()
111             value = fields[1].strip()
112             if value.lower() == "true":
113                 value = True
114             elif value.lower() == "false":
115                 value = False
116             # User cannot set maxchannels - only a command-line arg can do that.
117             if variable == "maxchannels":
118                 return
119             setattr(self, variable, value)
120     def do_overrides(self):
121         "Make command-line overrides possible."
122         for tok in self.arguments:
123             for key in self.__dict__:
124                 if tok.startswith(key + "="):
125                     val = tok[len(key)+1:]
126                     setattr(self, key, val)
127         for (key, val) in self.__dict__.items():
128             if key in GenericExtractor.booleans:
129                 if val.lower() == "true":
130                     setattr(self, key, True)
131                 elif val.lower() == "false":
132                     setattr(self, key, False)
133                 elif key in GenericExtractor.numerics:
134                     setattr(self, key, int(val))
135         if not self.project:
136             sys.stderr.write("irkerhook.py: no project name set!\n")
137             raise SystemExit, 1
138         if not self.repo:
139             self.repo = self.project.lower()
140         if not self.channels:
141             self.channels = default_channels % self.__dict__
142         if self.urlprefix.lower() == "none":
143             self.url = ""
144         else:
145             self.urlprefix = urlprefixmap.get(self.urlprefix, self.urlprefix) 
146             prefix = self.urlprefix % self.__dict__
147             try:
148                 webview = prefix + self.commit
149                 if urllib.urlopen(webview).getcode() == 404:
150                     raise IOError
151                 try:
152                     # Didn't get a retrieval error or 404 on the web
153                     # view, so try to tinyify a reference to it.
154                     self.url = open(urllib.urlretrieve(self.tinyifier + webview)[0]).read()
155                 except IOError:
156                     self.url = webview
157             except IOError:
158                 self.url = ""
159         if self.color and self.color.lower() != "none":
160             self.activate_color(self.color)
161
162 class GitExtractor(GenericExtractor):
163     "Metadata extraction for the git version control system."
164     def __init__(self, arguments):
165         GenericExtractor.__init__(self, arguments)
166         # Get all global config variables
167         self.project = do("git config --get irker.project")
168         self.repo = do("git config --get irker.repo")
169         self.server = do("git config --get irker.server")
170         self.channels = do("git config --get irker.channels")
171         self.tcp = do("git config --bool --get irker.tcp")
172         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'
173         self.color = do("git config --get irker.color")
174         self.urlprefix = do("git config --get irker.urlprefix") or "gitweb"
175         # This one is git-specific
176         self.revformat = do("git config --get irker.revformat")
177         # The project variable defaults to the name of the repository toplevel.
178         if not self.project:
179             bare = do("git config --bool --get core.bare")
180             if bare.lower() == "true":
181                 keyfile = "HEAD"
182             else:
183                 keyfile = ".git/HEAD"
184             here = os.getcwd()
185             while True:
186                 if os.path.exists(os.path.join(here, keyfile)):
187                     self.project = os.path.basename(here)
188                     break
189                 elif here == '/':
190                     sys.stderr.write("irkerhook.py: no git repo below root!\n")
191                     sys.exit(1)
192                 here = os.path.dirname(here)
193         # Revision level
194         self.refname = do("git symbolic-ref HEAD 2>/dev/null")
195         self.commit = do("git rev-parse HEAD")
196         self.branch = os.path.basename(self.refname)
197         # Compute a description for the revision
198         if self.revformat == 'raw':
199             self.rev = self.commit
200         elif self.revformat == 'short':
201             self.rev = ''
202         else: # self.revformat == 'describe'
203             self.rev = do("git describe %s 2>/dev/null" % shellquote(self.commit))
204         if not self.rev:
205             self.rev = self.commit[:12]
206         # Extract the meta-information for the commit
207         self.files = do("git diff-tree -r --name-only " + shellquote(self.commit))
208         self.files = " ".join(self.files.strip().split("\n")[1:])
209         # Design choice: for git we ship only the first line, which is
210         # conventionally supposed to be a summary of the commit.  Under
211         # other VCSes a different choice may be appropriate.
212         metainfo = do("git log -1 '--pretty=format:%an <%ae>|%s' " + shellquote(self.commit))
213         (self.author, self.logmsg) = metainfo.split("|")
214         # This discards the part of the author's address after @.
215         # Might be be nice to ship the full email address, if not
216         # for spammers' address harvesters - getting this wrong
217         # would make the freenode #commits channel into harvester heaven.
218         self.author = self.author.replace("<", "").split("@")[0].split()[-1]
219         # Get overrides
220         self.do_overrides()
221
222 class SvnExtractor(GenericExtractor):
223     "Metadata extraction for the svn version control system."
224     def __init__(self, arguments):
225         GenericExtractor.__init__(self, arguments)
226         self.commit = None
227         # Some things we need to have before metadata queries will work
228         for tok in arguments:
229             if tok.startswith("repository="):
230                 self.repository = tok[11:]
231             elif tok.startswith("commit="):
232                 self.commit = tok[7:]
233         if self.commit is None or self.repository is None:
234             sys.stderr.write("irkerhook: svn requires 'repository' and 'commit' variables.")
235             sys.exit(1)
236         self.project = os.path.basename(self.repository)
237         self.author = self.svnlook("author")
238         self.files = self.svnlook("dirs-changed").strip().replace("\n", " ")
239         self.logmsg = self.svnlook("log")
240         self.rev = "r%s" % self.commit
241         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'
242         self.urlprefix = "viewcvs"
243         self.load_preferences(os.path.join(self.repository, "irker.conf"))
244         self.do_overrides()
245     def svnlook(self, info):
246         return do("svnlook %s %s --revision %s" % (shellquote(info), shellquote(self.repository), shellquote(self.commit)))
247
248 if __name__ == "__main__":
249     import getopt
250
251     try:
252         (options, arguments) = getopt.getopt(sys.argv[1:], "nV")
253     except getopt.GetoptError, msg:
254         print "irkerhook.py: " + str(msg)
255         raise SystemExit, 1
256
257     notify = True
258     channels = ""
259     commit = ""
260     repository = ""
261     for (switch, val) in options:
262         if switch == '-n':
263             notify = False
264         elif switch == '-V':
265             print "irkerhook.py: version", version
266             sys.exit(0)
267
268     # Gather info for repo type discrimination
269     for tok in arguments:
270         if tok.startswith("repository="):
271             repository = tok[11:]
272
273     # Determine the repository type. Default to git unless user has pointed
274     # us at a repo with identifiable internals.
275     vcs = "git"
276     if os.path.exists(os.path.join(repository, "format")):
277         vcs = "svn"
278
279     # Someday we'll have extractors for several version-control systems
280     if vcs == "svn":
281         extractor = SvnExtractor(arguments)
282     else:
283         extractor = GitExtractor(arguments)
284
285     # Message reduction.  The assumption here is that IRC can't handle
286     # lines more than 510 characters long. If we exceed that length, we
287     # try knocking out the file list, on the theory that for notification
288     # purposes the commit text is more important.  If it's still too long
289     # there's nothing much can be done other than ship it expecting the IRC
290     # server to truncate.
291     privmsg = extractor.template % extractor.__dict__
292     if len(privmsg) > 510:
293         extractor.files = ""
294         privmsg = extractor.template % extractor.__dict__
295
296     # Anti-spamming guard.
297     channel_list = extractor.channels.split(",")
298     if extractor.maxchannels != 0:
299         channel_list = channel_list[:extractor.maxchannels]
300
301     # Ready to ship.
302     message = json.dumps({"to":channel_list, "privmsg":privmsg})
303     if not notify:
304         print message
305     else:
306         try:
307             if extractor.tcp:
308                 try:
309                     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
310                     sock.connect((extractor.server or default_server, IRKER_PORT))
311                     sock.sendall(message + "\n")
312                 finally:
313                     sock.close()
314             else:
315                 try:
316                     sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
317                     sock.sendto(message + "\n", (extractor.server or default_server, IRKER_PORT))
318                 finally:
319                     sock.close()
320         except socket.error, e:
321             sys.stderr.write("%s\n" % e)
322
323 #End