1
2
3
4
5
6
7 from twisted.python import log
8 from twisted.internet import defer, reactor, utils
9 from twisted.internet.task import LoopingCall
10
11 from buildbot import util
12 from buildbot.changes import base
13 from buildbot.changes.changes import Change
14
15 import xml.dom.minidom
16 import os, urllib
17
19 if condition:
20 return True
21 raise AssertionError(msg)
22
23
24
27
29
30
31 pieces = path.split('/')
32 if pieces[0] == 'trunk':
33 return (None, '/'.join(pieces[1:]))
34 elif pieces[0] == 'branches':
35 return ('/'.join(pieces[0:2]), '/'.join(pieces[2:]))
36 else:
37 return None
38
39
40 -class SVNPoller(base.ChangeSource, util.ComparableMixin):
41 """This source will poll a Subversion repository for changes and submit
42 them to the change master."""
43
44 compare_attrs = ["svnurl", "split_file_function",
45 "svnuser", "svnpasswd",
46 "pollinterval", "histmax",
47 "svnbin", "category"]
48
49 parent = None
50 last_change = None
51 loop = None
52 working = False
53
54 - def __init__(self, svnurl, split_file=None,
55 svnuser=None, svnpasswd=None,
56 pollinterval=10*60, histmax=100,
57 svnbin='svn', revlinktmpl='', category=None):
58 """
59 @type svnurl: string
60 @param svnurl: the SVN URL that describes the repository and
61 subdirectory to watch. If this ChangeSource should
62 only pay attention to a single branch, this should
63 point at the repository for that branch, like
64 svn://svn.twistedmatrix.com/svn/Twisted/trunk . If it
65 should follow multiple branches, point it at the
66 repository directory that contains all the branches
67 like svn://svn.twistedmatrix.com/svn/Twisted and also
68 provide a branch-determining function.
69
70 Each file in the repository has a SVN URL in the form
71 (SVNURL)/(BRANCH)/(FILEPATH), where (BRANCH) could be
72 empty or not, depending upon your branch-determining
73 function. Only files that start with (SVNURL)/(BRANCH)
74 will be monitored. The Change objects that are sent to
75 the Schedulers will see (FILEPATH) for each modified
76 file.
77
78 @type split_file: callable or None
79 @param split_file: a function that is called with a string of the
80 form (BRANCH)/(FILEPATH) and should return a tuple
81 (BRANCH, FILEPATH). This function should match
82 your repository's branch-naming policy. Each
83 changed file has a fully-qualified URL that can be
84 split into a prefix (which equals the value of the
85 'svnurl' argument) and a suffix; it is this suffix
86 which is passed to the split_file function.
87
88 If the function returns None, the file is ignored.
89 Use this to indicate that the file is not a part
90 of this project.
91
92 For example, if your repository puts the trunk in
93 trunk/... and branches are in places like
94 branches/1.5/..., your split_file function could
95 look like the following (this function is
96 available as svnpoller.split_file_branches)::
97
98 pieces = path.split('/')
99 if pieces[0] == 'trunk':
100 return (None, '/'.join(pieces[1:]))
101 elif pieces[0] == 'branches':
102 return ('/'.join(pieces[0:2]),
103 '/'.join(pieces[2:]))
104 else:
105 return None
106
107 If instead your repository layout puts the trunk
108 for ProjectA in trunk/ProjectA/... and the 1.5
109 branch in branches/1.5/ProjectA/..., your
110 split_file function could look like::
111
112 pieces = path.split('/')
113 if pieces[0] == 'trunk':
114 branch = None
115 pieces.pop(0) # remove 'trunk'
116 elif pieces[0] == 'branches':
117 pieces.pop(0) # remove 'branches'
118 # grab branch name
119 branch = 'branches/' + pieces.pop(0)
120 else:
121 return None # something weird
122 projectname = pieces.pop(0)
123 if projectname != 'ProjectA':
124 return None # wrong project
125 return (branch, '/'.join(pieces))
126
127 The default of split_file= is None, which
128 indicates that no splitting should be done. This
129 is equivalent to the following function::
130
131 return (None, path)
132
133 If you wish, you can override the split_file
134 method with the same sort of function instead of
135 passing in a split_file= argument.
136
137
138 @type svnuser: string
139 @param svnuser: If set, the --username option will be added to
140 the 'svn log' command. You may need this to get
141 access to a private repository.
142 @type svnpasswd: string
143 @param svnpasswd: If set, the --password option will be added.
144
145 @type pollinterval: int
146 @param pollinterval: interval in seconds between polls. The default
147 is 600 seconds (10 minutes). Smaller values
148 decrease the latency between the time a change
149 is recorded and the time the buildbot notices
150 it, but it also increases the system load.
151
152 @type histmax: int
153 @param histmax: maximum number of changes to look back through.
154 The default is 100. Smaller values decrease
155 system load, but if more than histmax changes
156 are recorded between polls, the extra ones will
157 be silently lost.
158
159 @type svnbin: string
160 @param svnbin: path to svn binary, defaults to just 'svn'. Use
161 this if your subversion command lives in an
162 unusual location.
163
164 @type revlinktmpl: string
165 @param revlinktmpl: A format string to use for hyperlinks to revision
166 information. For example, setting this to
167 "http://reposerver/websvn/revision.php?rev=%s"
168 would create suitable links on the build pages
169 to information in websvn on each revision.
170
171 @type category: string
172 @param category: A single category associated with the changes that
173 could be used by schedulers watch for branches of a
174 certain name AND category.
175 """
176
177 if svnurl.endswith("/"):
178 svnurl = svnurl[:-1]
179 self.svnurl = svnurl
180 self.split_file_function = split_file or split_file_alwaystrunk
181 self.svnuser = svnuser
182 self.svnpasswd = svnpasswd
183
184 self.revlinktmpl = revlinktmpl
185
186 self.environ = os.environ.copy()
187
188
189 self.svnbin = svnbin
190 self.pollinterval = pollinterval
191 self.histmax = histmax
192 self._prefix = None
193 self.overrun_counter = 0
194 self.loop = LoopingCall(self.checksvn)
195 self.category = category
196
198
199
200 f = getattr(self, "split_file_function")
201 return f(path)
202
210
215
217 return "SVNPoller watching %s" % self.svnurl
218
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251 if self.working:
252 log.msg("SVNPoller(%s) overrun: timer fired but the previous "
253 "poll had not yet finished." % self.svnurl)
254 self.overrun_counter += 1
255 return defer.succeed(None)
256 self.working = True
257
258 log.msg("SVNPoller polling")
259 if not self._prefix:
260
261
262
263 d = self.get_root()
264 d.addCallback(self.determine_prefix)
265 else:
266 d = defer.succeed(self._prefix)
267
268 d.addCallback(self.get_logs)
269 d.addCallback(self.parse_logs)
270 d.addCallback(self.get_new_logentries)
271 d.addCallback(self.create_changes)
272 d.addCallback(self.submit_changes)
273 d.addCallbacks(self.finished_ok, self.finished_failure)
274 return d
275
280
282 args = ["info", "--xml", "--non-interactive", self.svnurl]
283 if self.svnuser:
284 args.extend(["--username=%s" % self.svnuser])
285 if self.svnpasswd:
286 args.extend(["--password=%s" % self.svnpasswd])
287 d = self.getProcessOutput(args)
288 return d
289
291 try:
292 doc = xml.dom.minidom.parseString(output)
293 except xml.parsers.expat.ExpatError:
294 log.msg("SVNPoller._determine_prefix_2: ExpatError in '%s'"
295 % output)
296 raise
297 rootnodes = doc.getElementsByTagName("root")
298 if not rootnodes:
299
300
301 self._prefix = ""
302 return self._prefix
303 rootnode = rootnodes[0]
304 root = "".join([c.data for c in rootnode.childNodes])
305
306 _assert(self.svnurl.startswith(root),
307 "svnurl='%s' doesn't start with <root>='%s'" %
308 (self.svnurl, root))
309 self._prefix = self.svnurl[len(root):]
310 if self._prefix.startswith("/"):
311 self._prefix = self._prefix[1:]
312 log.msg("SVNPoller: svnurl=%s, root=%s, so prefix=%s" %
313 (self.svnurl, root, self._prefix))
314 return self._prefix
315
316 - def get_logs(self, ignored_prefix=None):
317 args = []
318 args.extend(["log", "--xml", "--verbose", "--non-interactive"])
319 if self.svnuser:
320 args.extend(["--username=%s" % self.svnuser])
321 if self.svnpasswd:
322 args.extend(["--password=%s" % self.svnpasswd])
323 args.extend(["--limit=%d" % (self.histmax), self.svnurl])
324 d = self.getProcessOutput(args)
325 return d
326
328
329 try:
330 doc = xml.dom.minidom.parseString(output)
331 except xml.parsers.expat.ExpatError:
332 log.msg("SVNPoller.parse_logs: ExpatError in '%s'" % output)
333 raise
334 logentries = doc.getElementsByTagName("logentry")
335 return logentries
336
337
339
340
341
342 if not logentries:
343
344 return (None, [])
345
346 mostRecent = int(logentries[0].getAttribute("revision"))
347
348 if last_change is None:
349
350
351
352 log.msg('svnPoller: starting at change %s' % mostRecent)
353 return (mostRecent, [])
354
355 if last_change == mostRecent:
356
357 log.msg('svnPoller: _process_changes last %s mostRecent %s' % (
358 last_change, mostRecent))
359 return (mostRecent, [])
360
361 new_logentries = []
362 for el in logentries:
363 if last_change == int(el.getAttribute("revision")):
364 break
365 new_logentries.append(el)
366 new_logentries.reverse()
367 return (mostRecent, new_logentries)
368
370 last_change = self.last_change
371 (new_last_change,
372 new_logentries) = self._filter_new_logentries(logentries,
373 self.last_change)
374 self.last_change = new_last_change
375 log.msg('svnPoller: _process_changes %s .. %s' %
376 (last_change, new_last_change))
377 return new_logentries
378
379
380 - def _get_text(self, element, tag_name):
381 try:
382 child_nodes = element.getElementsByTagName(tag_name)[0].childNodes
383 text = "".join([t.data for t in child_nodes])
384 except:
385 text = "<unknown>"
386 return text
387
398
400 changes = []
401
402 for el in new_logentries:
403 revision = str(el.getAttribute("revision"))
404
405 revlink=''
406
407 if self.revlinktmpl:
408 if revision:
409 revlink = self.revlinktmpl % urllib.quote_plus(revision)
410
411 log.msg("Adding change revision %s" % (revision,))
412
413
414 author = self._get_text(el, "author")
415 comments = self._get_text(el, "msg")
416
417
418
419
420
421
422
423
424 branches = {}
425 pathlist = el.getElementsByTagName("paths")[0]
426 for p in pathlist.getElementsByTagName("path"):
427 action = p.getAttribute("action")
428 path = "".join([t.data for t in p.childNodes])
429
430
431
432
433 path = path.encode("ascii")
434 if path.startswith("/"):
435 path = path[1:]
436 where = self._transform_path(path)
437
438
439
440 if where:
441 branch, filename = where
442 if not branch in branches:
443 branches[branch] = { 'files': []}
444 branches[branch]['files'].append(filename)
445
446 if not branches[branch].has_key('action'):
447 branches[branch]['action'] = action
448
449 for branch in branches.keys():
450 action = branches[branch]['action']
451 files = branches[branch]['files']
452 number_of_files_changed = len(files)
453
454 if action == u'D' and number_of_files_changed == 1 and files[0] == '':
455 log.msg("Ignoring deletion of branch '%s'" % branch)
456 else:
457 c = Change(who=author,
458 files=files,
459 comments=comments,
460 revision=revision,
461 branch=branch,
462 revlink=revlink,
463 category=self.category,
464 repository=self.svnurl)
465 changes.append(c)
466
467 return changes
468
472
474 log.msg("SVNPoller finished polling %s" % res)
475 assert self.working
476 self.working = False
477 return res
478
480 log.msg("SVNPoller failed %s" % f)
481 assert self.working
482 self.working = False
483 return None
484