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