1
2 import urlparse, urllib, time, re
3 import os, cgi, sys, locale
4 import jinja2
5 from zope.interface import Interface
6 from twisted.web import resource, static
7 from twisted.python import log
8 from buildbot.status import builder
9 from buildbot.status.builder import SUCCESS, WARNINGS, FAILURE, SKIPPED, EXCEPTION, RETRY
10 from buildbot import version, util
11 from buildbot.process.properties import Properties
12
14 """I represent a box in the top row of the waterfall display: the one
15 which shows the status of the last build for each builder."""
17 """Return a Box instance, which can produce a <td> cell.
18 """
19
21 """I represent the 'current activity' box, just above the builder name."""
23 """Return a Box instance, which can produce a <td> cell.
24 """
25
26 -class IBox(Interface):
27 """I represent a box in the waterfall display."""
29 """Return a Box instance, which wraps an Event and can produce a <td>
30 cell.
31 """
32
35
36 css_classes = {SUCCESS: "success",
37 WARNINGS: "warnings",
38 FAILURE: "failure",
39 SKIPPED: "skipped",
40 EXCEPTION: "exception",
41 RETRY: "retry",
42 None: "",
43 }
44
45
47 """
48 Fetch custom build properties from the HTTP request of a "Force build" or
49 "Resubmit build" HTML form.
50 Check the names for valid strings, and return None if a problem is found.
51 Return a new Properties object containing each property found in req.
52 """
53 properties = Properties()
54 for i in (1,2,3):
55 pname = req.args.get("property%dname" % i, [""])[0]
56 pvalue = req.args.get("property%dvalue" % i, [""])[0]
57 if pname and pvalue:
58 if not re.match(r'^[\w\.\-\/\~:]*$', pname) \
59 or not re.match(r'^[\w\.\-\/\~:]*$', pvalue):
60 log.msg("bad property name='%s', value='%s'" % (pname, pvalue))
61 return None
62 properties.setProperty(pname, pvalue, "Force Build Form")
63 return properties
64
66 """
67 Return the class to use for a finished build or buildstep,
68 based on the result.
69 """
70
71 result = b.getResults()
72
73 if isinstance(b, builder.BuildStatus):
74 result = b.getResults()
75 elif isinstance(b, builder.BuildStepStatus):
76 result = b.getResults()[0]
77
78 if isinstance(result, tuple):
79 result = result[0]
80 else:
81 raise TypeError, "%r is not a BuildStatus or BuildStepStatus" % b
82
83 if result == None:
84
85 return "running"
86 return builder.Results[result]
87
89
90
91
92
93 if request.prepath:
94 segs = len(request.prepath) - 1
95 else:
96 segs = 0
97 root = "../" * segs
98 return root
99
102
104 return (path_to_root(request) +
105 "builders/" +
106 urllib.quote(builderstatus.getName(), safe=''))
107
111
115
117 return (path_to_root(request) +
118 "buildslaves/" +
119 urllib.quote(slave.getName(), safe=''))
120
124
126
127
128
129 spacer = False
130 - def __init__(self, text=[], class_=None, urlbase=None,
131 **parms):
132 self.text = text
133 self.class_ = class_
134 self.urlbase = urlbase
135 self.show_idle = 0
136 if parms.has_key('show_idle'):
137 del parms['show_idle']
138 self.show_idle = 1
139
140 self.parms = parms
141
142
143
144 - def td(self, **props):
145 props.update(self.parms)
146 text = self.text
147 if not text and self.show_idle:
148 text = ["[idle]"]
149 props['class'] = self.class_
150 props['text'] = text;
151 return props
152
153
154 -class ContextMixin(object):
155 - def getContext(self, request):
156 status = self.getStatus(request)
157 rootpath = path_to_root(request)
158 locale_enc = locale.getdefaultlocale()[1]
159 if locale_enc is not None:
160 locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc)
161 else:
162 locale_tz = unicode(time.tzname[time.localtime()[-1]])
163 return dict(project_url = status.getProjectURL(),
164 project_name = status.getProjectName(),
165 stylesheet = rootpath + 'default.css',
166 path_to_root = rootpath,
167 version = version,
168 time = time.strftime("%a %d %b %Y %H:%M:%S",
169 time.localtime(util.now())),
170 tz = locale_tz,
171 metatags = [],
172 title = self.getTitle(request),
173 welcomeurl = rootpath,
174 authz = self.getAuthz(request),
175 )
176
177 - def getStatus(self, request):
178 return request.site.buildbot_service.getStatus()
179
180 - def getTitle(self, request):
182
184
185 contentType = "text/html; charset=utf-8"
186 title = "Buildbot"
187 addSlash = False
188
190 if self.addSlash and path == "" and len(request.postpath) == 0:
191 return self
192 return resource.Resource.getChild(self, path, request)
193
195
196
197
198
199
200 if hasattr(request, "channel"):
201
202 request.site.buildbot_service.registerChannel(request.channel)
203
204
205
206
207
208 if False and self.addSlash and request.prepath[-1] != '':
209
210
211
212
213
214
215
216
217
218
219
220 url = request.prePathURL()
221 scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
222 new_url = request.prepath[-1] + "/"
223 if query:
224 new_url += "?" + query
225 request.redirect(new_url)
226 return ''
227
228 ctx = self.getContext(request)
229
230 data = self.content(request, ctx)
231 if isinstance(data, unicode):
232 data = data.encode("utf-8")
233 request.setHeader("content-type", self.contentType)
234 if request.method == "HEAD":
235 request.setHeader("content-length", len(data))
236 return ''
237 return data
238
240 return request.site.buildbot_service.authz
241
243 return request.site.buildbot_service.master
244
245
251 - def content(self, request, cxt):
252 cxt['content'] = self.bodyHTML
253 cxt['title'] = self.title
254 template = request.site.buildbot_service.templates.get_template("empty.html")
255 return template.render(**cxt)
256
257
258
259 have_DirectoryLister = False
260 if hasattr(static, 'DirectoryLister'):
261 have_DirectoryLister = True
263 """This variant of the static.DirectoryLister uses a template
264 for rendering."""
265
266 title = 'BuildBot'
267
269 cxt = self.getContext(request)
270
271 if self.dirs is None:
272 directory = os.listdir(self.path)
273 directory.sort()
274 else:
275 directory = self.dirs
276
277 dirs, files = self._getFilesAndDirectories(directory)
278
279 cxt['path'] = cgi.escape(urllib.unquote(request.uri))
280 cxt['directories'] = dirs
281 cxt['files'] = files
282 template = request.site.buildbot_service.templates.get_template("directory.html")
283 data = template.render(**cxt)
284 if isinstance(data, unicode):
285 data = data.encode("utf-8")
286 return data
287
289 """This class adds support for templated directory
290 views."""
291
293 if have_DirectoryLister:
294 return DirectoryLister(self.path,
295 self.listNames(),
296 self.contentTypes,
297 self.contentEncodings,
298 self.defaultType)
299 else:
300 return static.Data("""
301 Directory Listings require Twisted-9.0.0 or later
302 """, "text/plain")
303
304
305 MINUTE = 60
306 HOUR = 60*MINUTE
307 DAY = 24*HOUR
308 WEEK = 7*DAY
309 MONTH = 30*DAY
310
312 if int(num) == 1:
313 return "%d %s" % (num, word)
314 else:
315 return "%d %s" % (num, words)
316
318 if age <= 90:
319 return "%s ago" % plural("second", "seconds", age)
320 if age < 90*MINUTE:
321 return "about %s ago" % plural("minute", "minutes", age / MINUTE)
322 if age < DAY:
323 return "about %s ago" % plural("hour", "hours", age / HOUR)
324 if age < 2*WEEK:
325 return "about %s ago" % plural("day", "days", age / DAY)
326 if age < 2*MONTH:
327 return "about %s ago" % plural("week", "weeks", age / WEEK)
328 return "a long time ago"
329
330
332 LINE_TIME_FORMAT = "%b %d %H:%M"
333
335 '''
336 Collect the data needed for each line display
337 '''
338 builder_name = build.getBuilder().getName()
339 results = build.getResults()
340 text = build.getText()
341 try:
342 rev = build.getProperty("got_revision")
343 if rev is None:
344 rev = "??"
345 except KeyError:
346 rev = "??"
347 rev = str(rev)
348 css_class = css_classes.get(results, "")
349
350 if type(text) == list:
351 text = " ".join(text)
352
353 values = {'class': css_class,
354 'builder_name': builder_name,
355 'buildnum': build.getNumber(),
356 'results': css_class,
357 'text': " ".join(build.getText()),
358 'buildurl': path_to_build(req, build),
359 'builderurl': path_to_builder(req, build.getBuilder()),
360 'rev': rev,
361 'time': time.strftime(self.LINE_TIME_FORMAT,
362 time.localtime(build.getTimes()[0])),
363 'text': text,
364 'include_builder': include_builder
365 }
366 return values
367
369
370
371
372
373
374 if "trunk" in branches:
375 return branches + [None]
376 return branches
377
378
379
380
381 -def createJinjaEnv(revlink=None, changecommentlink=None,
382 repositories=None, projects=None):
383 ''' Create a jinja environment changecommentlink is used to
384 render HTML in the WebStatus and for mail changes
385
386 @type changecommentlink: C{None}, tuple (2 or 3 strings), dict (string -> 2- or 3-tuple) or callable
387 @param changecommentlink: see changelinkfilter()
388
389 @type revlink: C{None}, format-string, dict (repository -> format string) or callable
390 @param revlink: see revlinkfilter()
391
392 @type repositories: C{None} or dict (string -> url)
393 @param repositories: an (optinal) mapping from repository identifiers
394 (as given by Change sources) to URLs. Is used to create a link
395 on every place where a repository is listed in the WebStatus.
396
397 @type projects: C{None} or dict (string -> url)
398 @param projects: similar to repositories, but for projects.
399 '''
400
401
402 assert not hasattr(sys, "frozen"), 'Frozen config not supported with jinja (yet)'
403
404 default_loader = jinja2.PackageLoader('buildbot.status.web', 'templates')
405 root = os.path.join(os.getcwd(), 'templates')
406 loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(root),
407 default_loader])
408 env = jinja2.Environment(loader=loader,
409 extensions=['jinja2.ext.i18n'],
410 trim_blocks=True,
411 undefined=AlmostStrictUndefined)
412
413 env.install_null_translations()
414
415 env.filters.update(dict(
416 urlencode = urllib.quote,
417 email = emailfilter,
418 user = userfilter,
419 shortrev = shortrevfilter(revlink, env),
420 revlink = revlinkfilter(revlink, env),
421 changecomment = changelinkfilter(changecommentlink),
422 repolink = dictlinkfilter(repositories),
423 projectlink = dictlinkfilter(projects)
424 ))
425
426 return env
427
429 ''' Escape & obfuscate e-mail addresses
430
431 replacing @ with <span style="display:none> reportedly works well against web-spiders
432 and the next level is to use rot-13 (or something) and decode in javascript '''
433
434 user = jinja2.escape(value)
435 obfuscator = jinja2.Markup('<span style="display:none">ohnoyoudont</span>@')
436 output = user.replace('@', obfuscator)
437 return output
438
439
441 ''' Hide e-mail address from user name when viewing changes
442
443 We still include the (obfuscated) e-mail so that we can show
444 it on mouse-over or similar etc
445 '''
446 r = re.compile('(.*) +<(.*)>')
447 m = r.search(value)
448 if m:
449 user = jinja2.escape(m.group(1))
450 email = emailfilter(m.group(2))
451 return jinja2.Markup('<div class="user">%s<div class="email">%s</div></div>' % (user, email))
452 else:
453 return emailfilter(value)
454
456 '''Helper function that returns suitable macros and functions
457 for building revision links depending on replacement mechanism
458 '''
459
460 assert not replace or callable(replace) or isinstance(replace, dict) or \
461 isinstance(replace, str) or isinstance(replace, unicode)
462
463 if not replace:
464 return lambda rev, repo: None
465 else:
466 if callable(replace):
467 return lambda rev, repo: replace(rev, repo)
468 elif isinstance(replace, dict):
469 def filter(rev, repo):
470 url = replace.get(repo)
471 if url:
472 return url % urllib.quote(rev)
473 else:
474 return None
475
476 return filter
477 else:
478 return lambda rev, repo: replace % urllib.quote(rev)
479
480 assert False, '_replace has a bad type, but we should never get here'
481
482
484 '''return macros for use with revision links, depending
485 on whether revlinks are configured or not'''
486
487 macros = templates.get_template("revmacros.html").module
488
489 if not replace:
490 id = macros.id
491 short = macros.shorten
492 else:
493 id = macros.id_replace
494 short = macros.shorten_replace
495
496 return (id, short)
497
498
500 ''' Returns a function which shortens the revisison string
501 to 12-chars (chosen as this is the Mercurial short-id length)
502 and add link if replacement string is set.
503
504 (The full id is still visible in HTML, for mouse-over events etc.)
505
506 @param replace: see revlinkfilter()
507 @param templates: a jinja2 environment
508 '''
509
510 url_f = _revlinkcfg(replace, templates)
511
512 def filter(rev, repo):
513 if not rev:
514 return u''
515
516 id_html, short_html = _revlinkmacros(replace, templates)
517 rev = unicode(rev)
518 url = url_f(rev, repo)
519 rev = jinja2.escape(rev)
520 shortrev = rev[:12]
521
522 if shortrev == rev:
523 if url:
524 return id_html(rev=rev, url=url)
525 else:
526 return rev
527 else:
528 if url:
529 return short_html(short=shortrev, rev=rev, url=url)
530 else:
531 return shortrev + '...'
532
533 return filter
534
535
537 ''' Returns a function which adds an url link to a
538 revision identifiers.
539
540 Takes same params as shortrevfilter()
541
542 @param replace: either a python format string with an %s,
543 or a dict mapping repositories to format strings,
544 or a callable taking (revision, repository) arguments
545 and return an URL (or None, if no URL is available),
546 or None, in which case revisions do not get decorated
547 with links
548
549 @param templates: a jinja2 environment
550 '''
551
552 url_f = _revlinkcfg(replace, templates)
553
554 def filter(rev, repo):
555 if not rev:
556 return u''
557
558 rev = unicode(rev)
559 url = url_f(rev, repo)
560 if url:
561 id_html, _ = _revlinkmacros(replace, templates)
562 return id_html(rev=rev, url=url)
563 else:
564 return jinja2.escape(rev)
565
566 return filter
567
568
570 ''' Returns function that does regex search/replace in
571 comments to add links to bug ids and similar.
572
573 @param changelink:
574 Either C{None}
575 or: a tuple (2 or 3 elements)
576 1. a regex to match what we look for
577 2. an url with regex refs (\g<0>, \1, \2, etc) that becomes the 'href' attribute
578 3. (optional) an title string with regex ref regex
579 or: a dict mapping projects to above tuples
580 (no links will be added if the project isn't found)
581 or: a callable taking (changehtml, project) args
582 (where the changetext is HTML escaped in the
583 form of a jinja2.Markup instance) and
584 returning another jinja2.Markup instance with
585 the same change text plus any HTML tags added to it.
586 '''
587
588 assert not changelink or isinstance(changelink, dict) or \
589 isinstance(changelink, tuple) or callable(changelink)
590
591 def replace_from_tuple(t):
592 search, url_replace = t[:2]
593 if len(t) == 3:
594 title_replace = ' title="%s"' % t[2]
595 else:
596 title_replace = ''
597
598 search_re = re.compile(search)
599 link_replace_re = jinja2.Markup(r'<a href="%s"%s>\g<0></a>' % (url_replace, title_replace))
600
601 def filter(text, project):
602 text = jinja2.escape(text)
603 html = search_re.sub(link_replace_re, text)
604 return html
605
606 return filter
607
608 if not changelink:
609 return lambda text, project: jinja2.escape(text)
610
611 elif isinstance(changelink, dict):
612 def dict_filter(text, project):
613
614
615
616 t = changelink.get(project)
617 if t:
618 return replace_from_tuple(t)(text, project)
619 else:
620 return jinja2.escape(text)
621
622 return dict_filter
623
624 elif isinstance(changelink, tuple):
625 return replace_from_tuple(changelink)
626
627 elif callable(changelink):
628 def callable_filter(text, project):
629 text = jinja2.escape(text)
630 return changelink(text, project)
631
632 return callable_filter
633
634 assert False, 'changelink has unsupported type, but that is checked before'
635
636
638 '''A filter that encloses the given value in a link tag
639 given that the value exists in the dictionary'''
640
641 assert not links or callable(links) or isinstance(links, dict)
642
643 if not links:
644 return jinja2.escape
645
646 def filter(key):
647 if callable(links):
648 url = links(key)
649 else:
650 url = links.get(key)
651
652 safe_key = jinja2.escape(key)
653
654 if url:
655 return jinja2.Markup(r'<a href="%s">%s</a>' % (url, safe_key))
656 else:
657 return safe_key
658
659 return filter
660
662 ''' An undefined that allows boolean testing but
663 fails properly on every other use.
664
665 Much better than the default Undefined, but not
666 fully as strict as StrictUndefined '''
669