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]
50 class GenericExtractor:
51 "Generic class for encapsulating data from a VCS."
53 numerics = ["maxchannels"]
54 def __init__(self, arguments):
55 self.arguments = arguments
58 # These aren't really repo data but they belong here anyway...
60 self.tinyifier = default_tinyifier
66 self.host = socket.getfqdn()
67 # Per-commit data begins
72 # Color highlighting is disabled by default.
74 self.bold = self.green = self.blue = ""
75 self.yellow = self.brown = self.reset = ""
76 def activate_color(self, style):
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):
97 for line in open(conf):
99 if line.startswith("#") or not line.strip():
101 elif line.count('=') != 1:
102 sys.stderr.write('"%s", line %d: missing = in config line\n' \
105 fields = line.split('=')
107 sys.stderr.write('"%s", line %d: too many fields in config line\n' \
110 variable = fields[0].strip()
111 value = fields[1].strip()
112 if value.lower() == "true":
114 elif value.lower() == "false":
116 # User cannot set maxchannels - only a command-line arg can do that.
117 if variable == "maxchannels":
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))
136 sys.stderr.write("irkerhook.py: no project name set!\n")
139 self.repo = self.project.lower()
140 if not self.channels:
141 self.channels = default_channels % self.__dict__
142 if self.urlprefix.lower() == "none":
145 self.urlprefix = urlprefixmap.get(self.urlprefix, self.urlprefix)
146 prefix = self.urlprefix % self.__dict__
148 webview = prefix + self.commit
149 if urllib.urlopen(webview).getcode() == 404:
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()
159 if self.color and self.color.lower() != "none":
160 self.activate_color(self.color)
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.
179 bare = do("git config --bool --get core.bare")
180 if bare.lower() == "true":
183 keyfile = ".git/HEAD"
186 if os.path.exists(os.path.join(here, keyfile)):
187 self.project = os.path.basename(here)
190 sys.stderr.write("irkerhook.py: no git repo below root!\n")
192 here = os.path.dirname(here)
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':
202 else: # self.revformat == 'describe'
203 self.rev = do("git describe %s 2>/dev/null" % shellquote(self.commit))
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]
222 class SvnExtractor(GenericExtractor):
223 "Metadata extraction for the svn version control system."
224 def __init__(self, arguments):
225 GenericExtractor.__init__(self, arguments)
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.")
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"))
245 def svnlook(self, info):
246 return do("svnlook %s %s --revision %s" % (shellquote(info), shellquote(self.repository), shellquote(self.commit)))
248 if __name__ == "__main__":
252 (options, arguments) = getopt.getopt(sys.argv[1:], "nV")
253 except getopt.GetoptError, msg:
254 print "irkerhook.py: " + str(msg)
261 for (switch, val) in options:
265 print "irkerhook.py: version", version
268 # Gather info for repo type discrimination
269 for tok in arguments:
270 if tok.startswith("repository="):
271 repository = tok[11:]
273 # Determine the repository type. Default to git unless user has pointed
274 # us at a repo with identifiable internals.
276 if os.path.exists(os.path.join(repository, "format")):
279 # Someday we'll have extractors for several version-control systems
281 extractor = SvnExtractor(arguments)
283 extractor = GitExtractor(arguments)
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:
294 privmsg = extractor.template % extractor.__dict__
296 # Anti-spamming guard.
297 channel_list = extractor.channels.split(",")
298 if extractor.maxchannels != 0:
299 channel_list = channel_list[:extractor.maxchannels]
302 message = json.dumps({"to":channel_list, "privmsg":privmsg})
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")
316 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
317 sock.sendto(message + "\n", (extractor.server or default_server, IRKER_PORT))
320 except socket.error, e:
321 sys.stderr.write("%s\n" % e)