1 |
#!/usr/bin/env python |
2 |
# |
3 |
# This is a CIA client script for Subversion repositories, written in python. |
4 |
# It generates commit messages using CIA's XML format, and can deliver them |
5 |
# using either XML-RPC or email. See below for usage and cuztomization |
6 |
# information. |
7 |
# |
8 |
# -------------------------------------------------------------------------- |
9 |
# |
10 |
# Copyright (c) 2004-2007, Micah Dowty |
11 |
# All rights reserved. |
12 |
# |
13 |
# Redistribution and use in source and binary forms, with or without |
14 |
# modification, are permitted provided that the following conditions are met: |
15 |
# |
16 |
# * Redistributions of source code must retain the above copyright notice, |
17 |
# this list of conditions and the following disclaimer. |
18 |
# * Redistributions in binary form must reproduce the above copyright |
19 |
# notice, this list of conditions and the following disclaimer in the |
20 |
# documentation and/or other materials provided with the distribution. |
21 |
# * The name of the author may not be used to endorse or promote products |
22 |
# derived from this software without specific prior written permission. |
23 |
# |
24 |
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
25 |
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
26 |
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
27 |
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE |
28 |
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
29 |
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
30 |
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
31 |
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
32 |
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
33 |
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
34 |
# POSSIBILITY OF SUCH DAMAGE. |
35 |
# |
36 |
# -------------------------------------------------------------------------- |
37 |
# |
38 |
# This script is cleaner and much more featureful than the shell |
39 |
# script version, but won't work on systems without Python. |
40 |
# |
41 |
# To use the CIA bot in your Subversion repository... |
42 |
# |
43 |
# 1. Customize the parameters below |
44 |
# |
45 |
# 2. This script should be called from your repository's post-commit |
46 |
# hook with the repository and revision as arguments. For example, |
47 |
# you could copy this script into your repository's "hooks" directory |
48 |
# and add something like the following to the "post-commit" script, |
49 |
# also in the repository's "hooks" directory: |
50 |
# |
51 |
# REPOS="$1" |
52 |
# REV="$2" |
53 |
# $REPOS/hooks/ciabot_svn.py "$REPOS" "$REV" & |
54 |
# |
55 |
# Or, if you have multiple project hosted, you can add each |
56 |
# project's name to the commandline in that project's post-commit |
57 |
# hook: |
58 |
# |
59 |
# $REPOS/hooks/ciabot_svn.py "$REPOS" "$REV" "ProjectName" & |
60 |
# |
61 |
############# There are some parameters for this script that you can customize: |
62 |
|
63 |
class config: |
64 |
# Replace this with your project's name, or always provide a project |
65 |
# name on the commandline. |
66 |
# |
67 |
# NOTE: This shouldn't be a long description of your project. Ideally |
68 |
# it is a short identifier with no spaces, punctuation, or |
69 |
# unnecessary capitalization. This will be used in URLs related |
70 |
# to your project, as an internal identifier, and in IRC messages. |
71 |
# If you want a longer name shown for your project on the web |
72 |
# interface, please use the "title" metadata key rather than |
73 |
# putting that here. |
74 |
# |
75 |
project = "Mageia" |
76 |
|
77 |
# Subversion's normal directory hierarchy is powerful enough that |
78 |
# it doesn't have special methods of specifying modules, tags, or |
79 |
# branches like CVS does. Most projects do use a naming |
80 |
# convention though that works similarly to CVS's modules, tags, |
81 |
# and branches. |
82 |
# |
83 |
# This is a list of regular expressions that are tested against |
84 |
# paths in the order specified. If a regex matches, the 'branch' |
85 |
# and 'module' groups are stored and the matching section of the |
86 |
# path is removed. |
87 |
# |
88 |
# Several common directory structure styles are below as defaults. |
89 |
# Uncomment the ones you're using, or add your own regexes. |
90 |
# Whitespace in the each regex are ignored. |
91 |
|
92 |
pathRegexes = [ |
93 |
# r"^ trunk/ (?P<module>[^/]+)/ ", |
94 |
# r"^ (branches|tags)/ (?P<branch>[^/]+)/ ", |
95 |
# r"^ (branches|tags)/ (?P<module>[^/]+)/ (?P<branch>[^/]+)/ ", |
96 |
] |
97 |
|
98 |
# If your repository is accessible over the web, put its base URL here |
99 |
# and 'uri' attributes will be given to all <file> elements. This means |
100 |
# that in CIA's online message viewer, each file in the tree will link |
101 |
# directly to the file in your repository. |
102 |
repositoryURI = None |
103 |
|
104 |
# If your repository is accessible over the web via a tool like ViewVC |
105 |
# that allows viewing information about a full revision, put a format string |
106 |
# for its URL here. You can specify various substitution keys in the Python |
107 |
# syntax: "%(project)s" is replaced by the project name, and likewise |
108 |
# "%(revision)s" and "%(author)s" are replaced by the revision / author. |
109 |
# The resulting URI is added to the data sent to CIA. After this, in CIA's |
110 |
# online message viewer, the commit will link directly to the corresponding |
111 |
# revision page. |
112 |
revisionURI = None |
113 |
# Example (works for ViewVC as used by SourceForge.net): |
114 |
#revisionURI = "https://svn.sourceforge.net/viewcvs.cgi/%(project)s?view=rev&rev=%(revision)s" |
115 |
|
116 |
# This can be the http:// URI of the CIA server to deliver commits over |
117 |
# XML-RPC, or it can be an email address to deliver using SMTP. The |
118 |
# default here should work for most people. If you need to use e-mail |
119 |
# instead, you can replace this with "cia@cia.navi.cx" |
120 |
server = "http://cia.navi.cx" |
121 |
|
122 |
# The SMTP server to use, only used if the CIA server above is an |
123 |
# email address. |
124 |
smtpServer = "localhost" |
125 |
|
126 |
# The 'from' address to use. If you're delivering commits via email, set |
127 |
# this to the address you would normally send email from on this host. |
128 |
fromAddress = "cia-user@localhost" |
129 |
|
130 |
# When nonzero, print the message to stdout instead of delivering it to CIA. |
131 |
debug = 0 |
132 |
|
133 |
|
134 |
############# Normally the rest of this won't need modification |
135 |
|
136 |
import sys, os, re, urllib, getopt |
137 |
|
138 |
class File: |
139 |
"""A file in a Subversion repository. According to our current |
140 |
configuration, this may have a module, branch, and URI in addition |
141 |
to a path.""" |
142 |
|
143 |
# Map svn's status letters to our action names |
144 |
actionMap = { |
145 |
'U': 'modify', |
146 |
'A': 'add', |
147 |
'D': 'remove', |
148 |
} |
149 |
|
150 |
def __init__(self, fullPath, status=None): |
151 |
self.fullPath = fullPath |
152 |
self.path = fullPath |
153 |
self.action = self.actionMap.get(status) |
154 |
|
155 |
def getURI(self, repo): |
156 |
"""Get the URI of this file, given the repository's URI. This |
157 |
encodes the full path and joins it to the given URI.""" |
158 |
quotedPath = urllib.quote(self.fullPath) |
159 |
if quotedPath[0] == '/': |
160 |
quotedPath = quotedPath[1:] |
161 |
if repo[-1] != '/': |
162 |
repo = repo + '/' |
163 |
return repo + quotedPath |
164 |
|
165 |
def makeTag(self, config): |
166 |
"""Return an XML tag for this file, using the given config""" |
167 |
attrs = {} |
168 |
|
169 |
if config.repositoryURI is not None: |
170 |
attrs['uri'] = self.getURI(config.repositoryURI) |
171 |
|
172 |
if self.action: |
173 |
attrs['action'] = self.action |
174 |
|
175 |
attrString = ''.join([' %s="%s"' % (key, escapeToXml(value,1)) |
176 |
for key, value in attrs.items()]) |
177 |
return "<file%s>%s</file>" % (attrString, escapeToXml(self.path)) |
178 |
|
179 |
|
180 |
class SvnClient: |
181 |
"""A CIA client for Subversion repositories. Uses svnlook to |
182 |
gather information""" |
183 |
name = 'Python Subversion client for CIA' |
184 |
version = '1.20' |
185 |
|
186 |
def __init__(self, repository, revision, config): |
187 |
self.repository = repository |
188 |
self.revision = revision |
189 |
self.config = config |
190 |
|
191 |
def deliver(self, message): |
192 |
if config.debug: |
193 |
print message |
194 |
else: |
195 |
server = self.config.server |
196 |
if server.startswith('http:') or server.startswith('https:'): |
197 |
# Deliver over XML-RPC |
198 |
import xmlrpclib |
199 |
xmlrpclib.ServerProxy(server).hub.deliver(message) |
200 |
else: |
201 |
# Deliver over email |
202 |
import smtplib |
203 |
smtp = smtplib.SMTP(self.config.smtpServer) |
204 |
smtp.sendmail(self.config.fromAddress, server, |
205 |
"From: %s\r\nTo: %s\r\n" |
206 |
"Subject: DeliverXML\r\n\r\n%s" % |
207 |
(self.config.fromAddress, server, message)) |
208 |
|
209 |
def main(self): |
210 |
self.collectData() |
211 |
self.deliver("<message>" + |
212 |
self.makeGeneratorTag() + |
213 |
self.makeSourceTag() + |
214 |
self.makeBodyTag() + |
215 |
"</message>") |
216 |
|
217 |
def makeAttrTags(self, *names): |
218 |
"""Given zero or more attribute names, generate XML elements for |
219 |
those attributes only if they exist and are non-None. |
220 |
""" |
221 |
s = '' |
222 |
for name in names: |
223 |
if hasattr(self, name): |
224 |
v = getattr(self, name) |
225 |
if v is not None: |
226 |
# Recent Pythons don't need this, but Python 2.1 |
227 |
# at least can't convert other types directly |
228 |
# to Unicode. We have to take an intermediate step. |
229 |
if type(v) not in (type(''), type(u'')): |
230 |
v = str(v) |
231 |
|
232 |
s += "<%s>%s</%s>" % (name, escapeToXml(v), name) |
233 |
return s |
234 |
|
235 |
def makeGeneratorTag(self): |
236 |
return "<generator>%s</generator>" % self.makeAttrTags( |
237 |
'name', |
238 |
'version', |
239 |
) |
240 |
|
241 |
def makeSourceTag(self): |
242 |
return "<source>%s</source>" % self.makeAttrTags( |
243 |
'project', |
244 |
'module', |
245 |
'branch', |
246 |
) |
247 |
|
248 |
def makeBodyTag(self): |
249 |
return "<body><commit>%s%s</commit></body>" % ( |
250 |
self.makeAttrTags( |
251 |
'revision', |
252 |
'author', |
253 |
'log', |
254 |
'diffLines', |
255 |
'url', |
256 |
), |
257 |
self.makeFileTags(), |
258 |
) |
259 |
|
260 |
def makeFileTags(self): |
261 |
"""Return XML tags for our file list""" |
262 |
return "<files>%s</files>" % ''.join([file.makeTag(self.config) |
263 |
for file in self.files]) |
264 |
|
265 |
def svnlook(self, command): |
266 |
"""Run the given svnlook command on our current repository and |
267 |
revision, returning all output""" |
268 |
# We have to set LC_ALL to force svnlook to give us UTF-8 output, |
269 |
# then we explicitly slurp that into a unicode object. |
270 |
return unicode(os.popen( |
271 |
'LC_ALL="en_US.UTF-8" svnlook %s -r "%s" "%s"' % |
272 |
(command, self.revision, self.repository)).read(), |
273 |
'utf-8', 'replace') |
274 |
|
275 |
def collectData(self): |
276 |
self.author = self.svnlook('author').strip() |
277 |
self.project = self.config.project |
278 |
self.log = self.svnlook('log') |
279 |
self.diffLines = len(self.svnlook('diff').split('\n')) |
280 |
self.files = self.collectFiles() |
281 |
if self.config.revisionURI is not None: |
282 |
self.url = self.config.revisionURI % self.__dict__ |
283 |
else: |
284 |
self.url = None |
285 |
|
286 |
def collectFiles(self): |
287 |
# Extract all the files from the output of 'svnlook changed' |
288 |
files = [] |
289 |
for line in self.svnlook('changed').split('\n'): |
290 |
path = line[2:].strip() |
291 |
if path: |
292 |
status = line[0] |
293 |
files.append(File(path, status)) |
294 |
|
295 |
# Try each of our several regexes. To be applied, the same |
296 |
# regex must mach every file under consideration and they must |
297 |
# all return the same results. If we find one matching regex, |
298 |
# or we try all regexes without a match, we're done. |
299 |
matchDict = None |
300 |
for regex in self.config.pathRegexes: |
301 |
matchDict = matchAgainstFiles(regex, files) |
302 |
if matchDict is not None: |
303 |
self.__dict__.update(matchDict) |
304 |
break |
305 |
|
306 |
return files |
307 |
|
308 |
|
309 |
def matchAgainstFiles(regex, files): |
310 |
"""Try matching a regex against all File objects in the provided list. |
311 |
If the regex returns the same matches for every file, the matches |
312 |
are returned in a dict and the matched portions are filtered out. |
313 |
If not, returns None. |
314 |
""" |
315 |
prevMatchDict = None |
316 |
compiled = re.compile(regex, re.VERBOSE) |
317 |
for f in files: |
318 |
|
319 |
match = compiled.match(f.fullPath) |
320 |
if not match: |
321 |
# Give up, it must match every file |
322 |
return None |
323 |
|
324 |
matchDict = match.groupdict() |
325 |
if prevMatchDict is not None and prevMatchDict != matchDict: |
326 |
# Give up, we got conflicting matches |
327 |
return None |
328 |
|
329 |
prevMatchDict = matchDict |
330 |
|
331 |
# If we got this far, the regex matched every file with |
332 |
# the same results. Now filter the matched portion out of |
333 |
# each file and store the matches we found. |
334 |
for f in files: |
335 |
f.path = compiled.sub('', f.fullPath) |
336 |
return prevMatchDict |
337 |
|
338 |
|
339 |
def escapeToXml(text, isAttrib=0): |
340 |
text = unicode(text) |
341 |
text = text.replace("&", "&") |
342 |
text = text.replace("<", "<") |
343 |
text = text.replace(">", ">") |
344 |
if isAttrib == 1: |
345 |
text = text.replace("'", "'") |
346 |
text = text.replace("\"", """) |
347 |
return text |
348 |
|
349 |
|
350 |
def usage(): |
351 |
"""Print a short usage description of this script and exit""" |
352 |
sys.stderr.write("Usage: %s [OPTIONS] REPOS-PATH REVISION [PROJECTNAME]\n" % |
353 |
sys.argv[0]) |
354 |
|
355 |
|
356 |
def version(): |
357 |
"""Print out the version of this script""" |
358 |
sys.stderr.write("%s %s\n" % (sys.argv[0], SvnClient.version)) |
359 |
|
360 |
|
361 |
def main(): |
362 |
try: |
363 |
options = [ "version" ] |
364 |
for key in config.__dict__: |
365 |
if not key.startswith("_"): |
366 |
options.append(key + "="); |
367 |
opts, args = getopt.getopt(sys.argv[1:], "", options) |
368 |
except getopt.GetoptError: |
369 |
usage() |
370 |
sys.exit(2) |
371 |
|
372 |
for o, a in opts: |
373 |
if o == "--version": |
374 |
version() |
375 |
sys.exit() |
376 |
else: |
377 |
# Everything else maps straight to a config key. Just have |
378 |
# to remove the "--" prefix from the option name. |
379 |
config.__dict__[o[2:]] = a |
380 |
|
381 |
# Print a usage message when not enough parameters are provided. |
382 |
if not len(args) in (2,3): |
383 |
sys.stderr.write("%s: incorrect number of arguments\n" % sys.argv[0]) |
384 |
usage(); |
385 |
sys.exit(2); |
386 |
|
387 |
# If a project name was provided, override the default project name. |
388 |
if len(args) == 3: |
389 |
config.project = args[2] |
390 |
|
391 |
# Go do the real work. |
392 |
SvnClient(args[0], args[1], config).main() |
393 |
|
394 |
|
395 |
if __name__ == "__main__": |
396 |
main() |
397 |
|
398 |
### The End ### |