1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 import urlparse, urllib, time, re
18 import os, cgi, sys, locale
19 import jinja2
20 from zope.interface import Interface
21 from twisted.internet import defer
22 from twisted.web import resource, static, server
23 from twisted.python import log
24 from buildbot.status import builder, buildstep, build
25 from buildbot.status.results import SUCCESS, WARNINGS, FAILURE, SKIPPED
26 from buildbot.status.results import EXCEPTION, RETRY
27 from buildbot import version, util
28 from buildbot.process.properties import Properties
29
31 """I represent a box in the top row of the waterfall display: the one
32 which shows the status of the last build for each builder."""
34 """Return a Box instance, which can produce a <td> cell.
35 """
36
38 """I represent the 'current activity' box, just above the builder name."""
40 """Return a Box instance, which can produce a <td> cell.
41 """
42
43 -class IBox(Interface):
44 """I represent a box in the waterfall display."""
46 """Return a Box instance, which wraps an Event and can produce a <td>
47 cell.
48 """
49
52
53 css_classes = {SUCCESS: "success",
54 WARNINGS: "warnings",
55 FAILURE: "failure",
56 SKIPPED: "skipped",
57 EXCEPTION: "exception",
58 RETRY: "retry",
59 None: "",
60 }
61
62
64 """
65 Fetch custom build properties from the HTTP request of a "Force build" or
66 "Resubmit build" HTML form.
67 Check the names for valid strings, and return None if a problem is found.
68 Return a new Properties object containing each property found in req.
69 """
70 master = req.site.buildbot_service.master
71 pname_validate = master.config.validation['property_name']
72 pval_validate = master.config.validation['property_value']
73 properties = Properties()
74 i = 1
75 while True:
76 pname = req.args.get("property%dname" % i, [""])[0]
77 pvalue = req.args.get("property%dvalue" % i, [""])[0]
78 if not pname:
79 break
80 if not pname_validate.match(pname) \
81 or not pval_validate.match(pvalue):
82 log.msg("bad property name='%s', value='%s'" % (pname, pvalue))
83 return None
84 properties.setProperty(pname, pvalue, "Force Build Form")
85 i = i + 1
86
87 return properties
88
90 """
91 Return the class to use for a finished build or buildstep,
92 based on the result.
93 """
94
95 result = b.getResults()
96 if isinstance(b, build.BuildStatus):
97 result = b.getResults()
98 elif isinstance(b, buildstep.BuildStepStatus):
99 result = b.getResults()[0]
100
101 if isinstance(result, tuple):
102 result = result[0]
103 else:
104 raise TypeError, "%r is not a BuildStatus or BuildStepStatus" % b
105
106 if result == None:
107
108 return "running"
109 return builder.Results[result]
110
112
113
114
115
116 if request.prepath:
117 segs = len(request.prepath) - 1
118 else:
119 segs = 0
120 root = "../" * segs
121 return root
122
125
127 return (path_to_root(request) +
128 "builders/" +
129 urllib.quote(builderstatus.getName(), safe=''))
130
134
138
143
147
149
150
151
152 spacer = False
153 - def __init__(self, text=[], class_=None, urlbase=None,
154 **parms):
155 self.text = text
156 self.class_ = class_
157 self.urlbase = urlbase
158 self.show_idle = 0
159 if parms.has_key('show_idle'):
160 del parms['show_idle']
161 self.show_idle = 1
162
163 self.parms = parms
164
165
166
167 - def td(self, **props):
168 props.update(self.parms)
169 text = self.text
170 if not text and self.show_idle:
171 text = ["[idle]"]
172 props['class'] = self.class_
173 props['text'] = text;
174 return props
175
176
179 return request.site.buildbot_service.getStatus()
180
181 - def getPageTitle(self, request):
182 return self.pageTitle
183
185 return request.site.buildbot_service.authz
186
188 return request.site.buildbot_service.master
189
190
191 -class ContextMixin(AccessorMixin):
192 - def getContext(self, request):
193 status = self.getStatus(request)
194 rootpath = path_to_root(request)
195 locale_enc = locale.getdefaultlocale()[1]
196 if locale_enc is not None:
197 locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc)
198 else:
199 locale_tz = unicode(time.tzname[time.localtime()[-1]])
200 return dict(title_url = status.getTitleURL(),
201 title = status.getTitle(),
202 stylesheet = rootpath + 'default.css',
203 path_to_root = rootpath,
204 version = version,
205 time = time.strftime("%a %d %b %Y %H:%M:%S",
206 time.localtime(util.now())),
207 tz = locale_tz,
208 metatags = [],
209 pageTitle = self.getPageTitle(request),
210 welcomeurl = rootpath,
211 authz = self.getAuthz(request),
212 )
213
214
216 """A resource that performs some action, then redirects to a new URL."""
217
218 isLeaf = 1
219
222
230
232 d = defer.maybeDeferred(lambda : self.performAction(request))
233 def redirect(url):
234 request.redirect(url)
235 request.write("see <a href='%s'>%s</a>" % (url,url))
236 try:
237 request.finish()
238 except RuntimeError:
239
240
241 log.msg("http client disconnected before results were sent")
242 d.addCallback(redirect)
243
244 def fail(f):
245 request.processingFailed(f)
246 return None
247 d.addErrback(fail)
248 return server.NOT_DONE_YET
249
251
252 contentType = "text/html; charset=utf-8"
253 pageTitle = "Buildbot"
254 addSlash = False
255
257 if self.addSlash and path == "" and len(request.postpath) == 0:
258 return self
259 return resource.Resource.getChild(self, path, request)
260
261
262 - def content(self, req, context):
263 """
264 Generate content using the standard layout and the result of the C{body}
265 method.
266
267 This is suitable for the case where a resource just wants to generate
268 the body of a page. It depends on another method, C{body}, being
269 defined to accept the request object and return a C{str}. C{render}
270 will call this method and to generate the response body.
271 """
272 body = self.body(req)
273 context['content'] = body
274 template = req.site.buildbot_service.templates.get_template(
275 "empty.html")
276 return template.render(**context)
277
278
280
281
282
283
284
285 if hasattr(request, "channel"):
286
287 request.site.buildbot_service.registerChannel(request.channel)
288
289
290
291
292
293 if False and self.addSlash and request.prepath[-1] != '':
294
295
296
297
298
299
300
301
302
303
304
305 url = request.prePathURL()
306 scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
307 new_url = request.prepath[-1] + "/"
308 if query:
309 new_url += "?" + query
310 request.redirect(new_url)
311 return ''
312
313 ctx = self.getContext(request)
314
315 d = defer.maybeDeferred(lambda : self.content(request, ctx))
316 def handle(data):
317 if isinstance(data, unicode):
318 data = data.encode("utf-8")
319 request.setHeader("content-type", self.contentType)
320 if request.method == "HEAD":
321 request.setHeader("content-length", len(data))
322 return ''
323 return data
324 d.addCallback(handle)
325 def ok(data):
326 request.write(data)
327 try:
328 request.finish()
329 except RuntimeError:
330
331
332 log.msg("http client disconnected before results were sent")
333 def fail(f):
334 request.processingFailed(f)
335 return None
336 d.addCallbacks(ok, fail)
337 return server.NOT_DONE_YET
338
344 - def content(self, request, cxt):
345 cxt['content'] = self.bodyHTML
346 cxt['pageTitle'] = self.pageTitle
347 template = request.site.buildbot_service.templates.get_template("empty.html")
348 return template.render(**cxt)
349
350
351
352 have_DirectoryLister = False
353 if hasattr(static, 'DirectoryLister'):
354 have_DirectoryLister = True
356 """This variant of the static.DirectoryLister uses a template
357 for rendering."""
358
359 pageTitle = 'BuildBot'
360
362 cxt = self.getContext(request)
363
364 if self.dirs is None:
365 directory = os.listdir(self.path)
366 directory.sort()
367 else:
368 directory = self.dirs
369
370 dirs, files = self._getFilesAndDirectories(directory)
371
372 cxt['path'] = cgi.escape(urllib.unquote(request.uri))
373 cxt['directories'] = dirs
374 cxt['files'] = files
375 template = request.site.buildbot_service.templates.get_template("directory.html")
376 data = template.render(**cxt)
377 if isinstance(data, unicode):
378 data = data.encode("utf-8")
379 return data
380
382 """This class adds support for templated directory
383 views."""
384
386 if have_DirectoryLister:
387 return DirectoryLister(self.path,
388 self.listNames(),
389 self.contentTypes,
390 self.contentEncodings,
391 self.defaultType)
392 else:
393 return static.Data("""
394 Directory Listings require Twisted-9.0.0 or later
395 """, "text/plain")
396
397
398 MINUTE = 60
399 HOUR = 60*MINUTE
400 DAY = 24*HOUR
401 WEEK = 7*DAY
402 MONTH = 30*DAY
403
405 if int(num) == 1:
406 return "%d %s" % (num, word)
407 else:
408 return "%d %s" % (num, words)
409
411 if age <= 90:
412 return "%s ago" % plural("second", "seconds", age)
413 if age < 90*MINUTE:
414 return "about %s ago" % plural("minute", "minutes", age / MINUTE)
415 if age < DAY:
416 return "about %s ago" % plural("hour", "hours", age / HOUR)
417 if age < 2*WEEK:
418 return "about %s ago" % plural("day", "days", age / DAY)
419 if age < 2*MONTH:
420 return "about %s ago" % plural("week", "weeks", age / WEEK)
421 return "a long time ago"
422
423
425 LINE_TIME_FORMAT = "%b %d %H:%M"
426
428 '''
429 Collect the data needed for each line display
430 '''
431 builder_name = build.getBuilder().getName()
432 results = build.getResults()
433 text = build.getText()
434 try:
435 rev = build.getProperty("got_revision")
436 if rev is None:
437 rev = "??"
438 except KeyError:
439 rev = "??"
440 rev = str(rev)
441 css_class = css_classes.get(results, "")
442 repo = build.getSourceStamp().repository
443
444 if type(text) == list:
445 text = " ".join(text)
446
447 values = {'class': css_class,
448 'builder_name': builder_name,
449 'buildnum': build.getNumber(),
450 'results': css_class,
451 'text': " ".join(build.getText()),
452 'buildurl': path_to_build(req, build),
453 'builderurl': path_to_builder(req, build.getBuilder()),
454 'rev': rev,
455 'rev_repo' : repo,
456 'time': time.strftime(self.LINE_TIME_FORMAT,
457 time.localtime(build.getTimes()[0])),
458 'text': text,
459 'include_builder': include_builder
460 }
461 return values
462
464
465
466
467
468
469 if "trunk" in branches:
470 return branches + [None]
471 return branches
472
473
474
475
476 -def createJinjaEnv(revlink=None, changecommentlink=None,
477 repositories=None, projects=None):
478 ''' Create a jinja environment changecommentlink is used to
479 render HTML in the WebStatus and for mail changes
480
481 @type changecommentlink: C{None}, tuple (2 or 3 strings), dict (string -> 2- or 3-tuple) or callable
482 @param changecommentlink: see changelinkfilter()
483
484 @type revlink: C{None}, format-string, dict (repository -> format string) or callable
485 @param revlink: see revlinkfilter()
486
487 @type repositories: C{None} or dict (string -> url)
488 @param repositories: an (optinal) mapping from repository identifiers
489 (as given by Change sources) to URLs. Is used to create a link
490 on every place where a repository is listed in the WebStatus.
491
492 @type projects: C{None} or dict (string -> url)
493 @param projects: similar to repositories, but for projects.
494 '''
495
496
497 assert not hasattr(sys, "frozen"), 'Frozen config not supported with jinja (yet)'
498
499 default_loader = jinja2.PackageLoader('buildbot.status.web', 'templates')
500 root = os.path.join(os.getcwd(), 'templates')
501 loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(root),
502 default_loader])
503 env = jinja2.Environment(loader=loader,
504 extensions=['jinja2.ext.i18n'],
505 trim_blocks=True,
506 undefined=AlmostStrictUndefined)
507
508 env.install_null_translations()
509
510 env.filters.update(dict(
511 urlencode = urllib.quote,
512 email = emailfilter,
513 user = userfilter,
514 shortrev = shortrevfilter(revlink, env),
515 revlink = revlinkfilter(revlink, env),
516 changecomment = changelinkfilter(changecommentlink),
517 repolink = dictlinkfilter(repositories),
518 projectlink = dictlinkfilter(projects)
519 ))
520
521 return env
522
524 ''' Escape & obfuscate e-mail addresses
525
526 replacing @ with <span style="display:none> reportedly works well against web-spiders
527 and the next level is to use rot-13 (or something) and decode in javascript '''
528
529 user = jinja2.escape(value)
530 obfuscator = jinja2.Markup('<span style="display:none">ohnoyoudont</span>@')
531 output = user.replace('@', obfuscator)
532 return output
533
534
536 ''' Hide e-mail address from user name when viewing changes
537
538 We still include the (obfuscated) e-mail so that we can show
539 it on mouse-over or similar etc
540 '''
541 r = re.compile('(.*) +<(.*)>')
542 m = r.search(value)
543 if m:
544 user = jinja2.escape(m.group(1))
545 email = emailfilter(m.group(2))
546 return jinja2.Markup('<div class="user">%s<div class="email">%s</div></div>' % (user, email))
547 else:
548 return emailfilter(value)
549
551 '''Helper function that returns suitable macros and functions
552 for building revision links depending on replacement mechanism
553 '''
554
555 assert not replace or callable(replace) or isinstance(replace, dict) or \
556 isinstance(replace, str) or isinstance(replace, unicode)
557
558 if not replace:
559 return lambda rev, repo: None
560 else:
561 if callable(replace):
562 return lambda rev, repo: replace(rev, repo)
563 elif isinstance(replace, dict):
564 def filter(rev, repo):
565 url = replace.get(repo)
566 if url:
567 return url % urllib.quote(rev)
568 else:
569 return None
570
571 return filter
572 else:
573 return lambda rev, repo: replace % urllib.quote(rev)
574
575 assert False, '_replace has a bad type, but we should never get here'
576
577
579 '''return macros for use with revision links, depending
580 on whether revlinks are configured or not'''
581
582 macros = templates.get_template("revmacros.html").module
583
584 if not replace:
585 id = macros.id
586 short = macros.shorten
587 else:
588 id = macros.id_replace
589 short = macros.shorten_replace
590
591 return (id, short)
592
593
595 ''' Returns a function which shortens the revisison string
596 to 12-chars (chosen as this is the Mercurial short-id length)
597 and add link if replacement string is set.
598
599 (The full id is still visible in HTML, for mouse-over events etc.)
600
601 @param replace: see revlinkfilter()
602 @param templates: a jinja2 environment
603 '''
604
605 url_f = _revlinkcfg(replace, templates)
606
607 def filter(rev, repo):
608 if not rev:
609 return u''
610
611 id_html, short_html = _revlinkmacros(replace, templates)
612 rev = unicode(rev)
613 url = url_f(rev, repo)
614 rev = jinja2.escape(rev)
615 shortrev = rev[:12]
616
617 if shortrev == rev:
618 if url:
619 return id_html(rev=rev, url=url)
620 else:
621 return rev
622 else:
623 if url:
624 return short_html(short=shortrev, rev=rev, url=url)
625 else:
626 return shortrev + '...'
627
628 return filter
629
630
632 ''' Returns a function which adds an url link to a
633 revision identifiers.
634
635 Takes same params as shortrevfilter()
636
637 @param replace: either a python format string with an %s,
638 or a dict mapping repositories to format strings,
639 or a callable taking (revision, repository) arguments
640 and return an URL (or None, if no URL is available),
641 or None, in which case revisions do not get decorated
642 with links
643
644 @param templates: a jinja2 environment
645 '''
646
647 url_f = _revlinkcfg(replace, templates)
648
649 def filter(rev, repo):
650 if not rev:
651 return u''
652
653 rev = unicode(rev)
654 url = url_f(rev, repo)
655 if url:
656 id_html, _ = _revlinkmacros(replace, templates)
657 return id_html(rev=rev, url=url)
658 else:
659 return jinja2.escape(rev)
660
661 return filter
662
663
665 ''' Returns function that does regex search/replace in
666 comments to add links to bug ids and similar.
667
668 @param changelink:
669 Either C{None}
670 or: a tuple (2 or 3 elements)
671 1. a regex to match what we look for
672 2. an url with regex refs (\g<0>, \1, \2, etc) that becomes the 'href' attribute
673 3. (optional) an title string with regex ref regex
674 or: a dict mapping projects to above tuples
675 (no links will be added if the project isn't found)
676 or: a callable taking (changehtml, project) args
677 (where the changetext is HTML escaped in the
678 form of a jinja2.Markup instance) and
679 returning another jinja2.Markup instance with
680 the same change text plus any HTML tags added to it.
681 '''
682
683 assert not changelink or isinstance(changelink, dict) or \
684 isinstance(changelink, tuple) or callable(changelink)
685
686 def replace_from_tuple(t):
687 search, url_replace = t[:2]
688 if len(t) == 3:
689 title_replace = t[2]
690 else:
691 title_replace = ''
692
693 search_re = re.compile(search)
694
695 def replacement_unmatched(text):
696 return jinja2.escape(text)
697 def replacement_matched(mo):
698
699 url = jinja2.escape(mo.expand(url_replace))
700 title = jinja2.escape(mo.expand(title_replace))
701 body = jinja2.escape(mo.group())
702 if title:
703 return '<a href="%s" title="%s">%s</a>' % (url, title, body)
704 else:
705 return '<a href="%s">%s</a>' % (url, body)
706
707 def filter(text, project):
708
709
710
711
712 html = []
713 last_idx = 0
714 for mo in search_re.finditer(text):
715 html.append(replacement_unmatched(text[last_idx:mo.start()]))
716 html.append(replacement_matched(mo))
717 last_idx = mo.end()
718 html.append(replacement_unmatched(text[last_idx:]))
719 return jinja2.Markup(''.join(html))
720
721 return filter
722
723 if not changelink:
724 return lambda text, project: jinja2.escape(text)
725
726 elif isinstance(changelink, dict):
727 def dict_filter(text, project):
728
729
730
731 t = changelink.get(project)
732 if t:
733 return replace_from_tuple(t)(text, project)
734 else:
735 return cgi.escape(text)
736
737 return dict_filter
738
739 elif isinstance(changelink, tuple):
740 return replace_from_tuple(changelink)
741
742 elif callable(changelink):
743 def callable_filter(text, project):
744 text = jinja2.escape(text)
745 return changelink(text, project)
746
747 return callable_filter
748
749 assert False, 'changelink has unsupported type, but that is checked before'
750
751
753 '''A filter that encloses the given value in a link tag
754 given that the value exists in the dictionary'''
755
756 assert not links or callable(links) or isinstance(links, dict)
757
758 if not links:
759 return jinja2.escape
760
761 def filter(key):
762 if callable(links):
763 url = links(key)
764 else:
765 url = links.get(key)
766
767 safe_key = jinja2.escape(key)
768
769 if url:
770 return jinja2.Markup(r'<a href="%s">%s</a>' % (url, safe_key))
771 else:
772 return safe_key
773
774 return filter
775
777 ''' An undefined that allows boolean testing but
778 fails properly on every other use.
779
780 Much better than the default Undefined, but not
781 fully as strict as StrictUndefined '''
784