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
128
130 return (path_to_root(request) +
131 "builders/" +
132 urllib.quote(builderstatus.getName(), safe=''))
133
137
141
146
150
152
153
154
155 spacer = False
156 - def __init__(self, text=[], class_=None, urlbase=None,
157 **parms):
158 self.text = text
159 self.class_ = class_
160 self.urlbase = urlbase
161 self.show_idle = 0
162 if parms.has_key('show_idle'):
163 del parms['show_idle']
164 self.show_idle = 1
165
166 self.parms = parms
167
168
169
170 - def td(self, **props):
171 props.update(self.parms)
172 text = self.text
173 if not text and self.show_idle:
174 text = ["[idle]"]
175 props['class'] = self.class_
176 props['text'] = text;
177 return props
178
179
182 return request.site.buildbot_service.getStatus()
183
184 - def getPageTitle(self, request):
185 return self.pageTitle
186
188 return request.site.buildbot_service.authz
189
191 return request.site.buildbot_service.master
192
193
194 -class ContextMixin(AccessorMixin):
195 - def getContext(self, request):
196 status = self.getStatus(request)
197 rootpath = path_to_root(request)
198 locale_enc = locale.getdefaultlocale()[1]
199 if locale_enc is not None:
200 locale_tz = unicode(time.tzname[time.localtime()[-1]], locale_enc)
201 else:
202 locale_tz = unicode(time.tzname[time.localtime()[-1]])
203 return dict(title_url = status.getTitleURL(),
204 title = status.getTitle(),
205 stylesheet = rootpath + 'default.css',
206 path_to_root = rootpath,
207 version = version,
208 time = time.strftime("%a %d %b %Y %H:%M:%S",
209 time.localtime(util.now())),
210 tz = locale_tz,
211 metatags = [],
212 pageTitle = self.getPageTitle(request),
213 welcomeurl = rootpath,
214 authz = self.getAuthz(request),
215 request = request,
216 alert_msg = request.args.get("alert_msg", [""])[0],
217 )
218
219
221 """A resource that performs some action, then redirects to a new URL."""
222
223 isLeaf = 1
224
227
237
239 d = defer.maybeDeferred(lambda : self.performAction(request))
240 def redirect(url):
241 if isinstance(url, tuple):
242 url, alert_msg = url
243 if alert_msg:
244 url += "?alert_msg="+urllib.quote(alert_msg, safe='')
245 request.redirect(url)
246 request.write("see <a href='%s'>%s</a>" % (url,url))
247 try:
248 request.finish()
249 except RuntimeError:
250
251
252 log.msg("http client disconnected before results were sent")
253 d.addCallback(redirect)
254
255 def fail(f):
256 request.processingFailed(f)
257 return None
258 d.addErrback(fail)
259 return server.NOT_DONE_YET
260
262
263 contentType = "text/html; charset=utf-8"
264 pageTitle = "Buildbot"
265 addSlash = False
266
268 if self.addSlash and path == "" and len(request.postpath) == 0:
269 return self
270 return resource.Resource.getChild(self, path, request)
271
272
273 - def content(self, req, context):
274 """
275 Generate content using the standard layout and the result of the C{body}
276 method.
277
278 This is suitable for the case where a resource just wants to generate
279 the body of a page. It depends on another method, C{body}, being
280 defined to accept the request object and return a C{str}. C{render}
281 will call this method and to generate the response body.
282 """
283 body = self.body(req)
284 context['content'] = body
285 template = req.site.buildbot_service.templates.get_template(
286 "empty.html")
287 return template.render(**context)
288
289
291
292
293
294
295
296 if hasattr(request, "channel"):
297
298 request.site.buildbot_service.registerChannel(request.channel)
299
300
301
302
303
304 if False and self.addSlash and request.prepath[-1] != '':
305
306
307
308
309
310
311
312
313
314
315
316 url = request.prePathURL()
317 scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
318 new_url = request.prepath[-1] + "/"
319 if query:
320 new_url += "?" + query
321 request.redirect(new_url)
322 return ''
323
324 ctx = self.getContext(request)
325
326 d = defer.maybeDeferred(lambda : self.content(request, ctx))
327 def handle(data):
328 if isinstance(data, unicode):
329 data = data.encode("utf-8")
330 request.setHeader("content-type", self.contentType)
331 if request.method == "HEAD":
332 request.setHeader("content-length", len(data))
333 return ''
334 return data
335 d.addCallback(handle)
336 def ok(data):
337 request.write(data)
338 try:
339 request.finish()
340 except RuntimeError:
341
342
343 log.msg("http client disconnected before results were sent")
344 def fail(f):
345 request.processingFailed(f)
346 return None
347 d.addCallbacks(ok, fail)
348 return server.NOT_DONE_YET
349
355 - def content(self, request, cxt):
356 cxt['content'] = self.bodyHTML
357 cxt['pageTitle'] = self.pageTitle
358 template = request.site.buildbot_service.templates.get_template("empty.html")
359 return template.render(**cxt)
360
362 """This variant of the static.DirectoryLister uses a template
363 for rendering."""
364
365 pageTitle = 'BuildBot'
366
368 cxt = self.getContext(request)
369
370 if self.dirs is None:
371 directory = os.listdir(self.path)
372 directory.sort()
373 else:
374 directory = self.dirs
375
376 dirs, files = self._getFilesAndDirectories(directory)
377
378 cxt['path'] = cgi.escape(urllib.unquote(request.uri))
379 cxt['directories'] = dirs
380 cxt['files'] = files
381 template = request.site.buildbot_service.templates.get_template("directory.html")
382 data = template.render(**cxt)
383 if isinstance(data, unicode):
384 data = data.encode("utf-8")
385 return data
386
388 """This class adds support for templated directory
389 views."""
390
392 return DirectoryLister(self.path,
393 self.listNames(),
394 self.contentTypes,
395 self.contentEncodings,
396 self.defaultType)
397
398
399 MINUTE = 60
400 HOUR = 60*MINUTE
401 DAY = 24*HOUR
402 WEEK = 7*DAY
403 MONTH = 30*DAY
404
406 if int(num) == 1:
407 return "%d %s" % (num, word)
408 else:
409 return "%d %s" % (num, words)
410
412 if age <= 90:
413 return "%s ago" % plural("second", "seconds", age)
414 if age < 90*MINUTE:
415 return "about %s ago" % plural("minute", "minutes", age / MINUTE)
416 if age < DAY:
417 return "about %s ago" % plural("hour", "hours", age / HOUR)
418 if age < 2*WEEK:
419 return "about %s ago" % plural("day", "days", age / DAY)
420 if age < 2*MONTH:
421 return "about %s ago" % plural("week", "weeks", age / WEEK)
422 return "a long time ago"
423
424
426 LINE_TIME_FORMAT = "%b %d %H:%M"
427
429 '''
430 Collect the data needed for each line display
431 '''
432 builder_name = build.getBuilder().getName()
433 results = build.getResults()
434 text = build.getText()
435 all_got_revision = build.getAllGotRevisions()
436 css_class = css_classes.get(results, "")
437 ss_list = build.getSourceStamps()
438 if ss_list:
439 repo = ss_list[0].repository
440 if all_got_revision:
441 if len(ss_list) == 1:
442 rev = all_got_revision.get(ss_list[0].codebase, "??")
443 else:
444 rev = "multiple rev."
445 else:
446 rev = "??"
447 else:
448 repo = 'unknown, no information in build'
449 rev = 'unknown'
450
451 if type(text) == list:
452 text = " ".join(text)
453
454 values = {'class': css_class,
455 'builder_name': builder_name,
456 'buildnum': build.getNumber(),
457 'results': css_class,
458 'text': " ".join(build.getText()),
459 'buildurl': path_to_build(req, build),
460 'builderurl': path_to_builder(req, build.getBuilder()),
461 'rev': rev,
462 'rev_repo' : repo,
463 'time': time.strftime(self.LINE_TIME_FORMAT,
464 time.localtime(build.getTimes()[0])),
465 'text': text,
466 'include_builder': include_builder
467 }
468 return values
469
471
472
473
474
475
476 if "trunk" in branches:
477 return branches + [None]
478 return branches
479
480
481
482
483 -def createJinjaEnv(revlink=None, changecommentlink=None,
484 repositories=None, projects=None, jinja_loaders=None):
485 ''' Create a jinja environment changecommentlink is used to
486 render HTML in the WebStatus and for mail changes
487
488 @type changecommentlink: C{None}, tuple (2 or 3 strings), dict (string -> 2- or 3-tuple) or callable
489 @param changecommentlink: see changelinkfilter()
490
491 @type revlink: C{None}, format-string, dict (repository -> format string) or callable
492 @param revlink: see revlinkfilter()
493
494 @type repositories: C{None} or dict (string -> url)
495 @param repositories: an (optinal) mapping from repository identifiers
496 (as given by Change sources) to URLs. Is used to create a link
497 on every place where a repository is listed in the WebStatus.
498
499 @type projects: C{None} or dict (string -> url)
500 @param projects: similar to repositories, but for projects.
501 '''
502
503
504 assert not hasattr(sys, "frozen"), 'Frozen config not supported with jinja (yet)'
505
506 all_loaders = [jinja2.FileSystemLoader(os.path.join(os.getcwd(), 'templates'))]
507 if jinja_loaders:
508 all_loaders.extend(jinja_loaders)
509 all_loaders.append(jinja2.PackageLoader('buildbot.status.web', 'templates'))
510 loader = jinja2.ChoiceLoader(all_loaders)
511
512 env = jinja2.Environment(loader=loader,
513 extensions=['jinja2.ext.i18n'],
514 trim_blocks=True,
515 undefined=AlmostStrictUndefined)
516
517 env.install_null_translations()
518
519 env.tests['mapping'] = lambda obj : isinstance(obj, dict)
520
521 env.filters.update(dict(
522 urlencode = urllib.quote,
523 email = emailfilter,
524 user = userfilter,
525 shortrev = shortrevfilter(revlink, env),
526 revlink = revlinkfilter(revlink, env),
527 changecomment = changelinkfilter(changecommentlink),
528 repolink = dictlinkfilter(repositories),
529 projectlink = dictlinkfilter(projects)
530 ))
531
532 return env
533
535 ''' Escape & obfuscate e-mail addresses
536
537 replacing @ with <span style="display:none> reportedly works well against web-spiders
538 and the next level is to use rot-13 (or something) and decode in javascript '''
539
540 user = jinja2.escape(value)
541 obfuscator = jinja2.Markup('<span style="display:none">ohnoyoudont</span>@')
542 output = user.replace('@', obfuscator)
543 return output
544
545
547 ''' Hide e-mail address from user name when viewing changes
548
549 We still include the (obfuscated) e-mail so that we can show
550 it on mouse-over or similar etc
551 '''
552 r = re.compile('(.*) +<(.*)>')
553 m = r.search(value)
554 if m:
555 user = jinja2.escape(m.group(1))
556 email = emailfilter(m.group(2))
557 return jinja2.Markup('<div class="user">%s<div class="email">%s</div></div>' % (user, email))
558 else:
559 return emailfilter(value)
560
562 '''Helper function that returns suitable macros and functions
563 for building revision links depending on replacement mechanism
564 '''
565
566 assert not replace or callable(replace) or isinstance(replace, dict) or \
567 isinstance(replace, str) or isinstance(replace, unicode)
568
569 if not replace:
570 return lambda rev, repo: None
571 else:
572 if callable(replace):
573 return lambda rev, repo: replace(rev, repo)
574 elif isinstance(replace, dict):
575 def filter(rev, repo):
576 url = replace.get(repo)
577 if url:
578 return url % urllib.quote(rev)
579 else:
580 return None
581
582 return filter
583 else:
584 return lambda rev, repo: replace % urllib.quote(rev)
585
586 assert False, '_replace has a bad type, but we should never get here'
587
588
590 '''return macros for use with revision links, depending
591 on whether revlinks are configured or not'''
592
593 macros = templates.get_template("revmacros.html").module
594
595 if not replace:
596 id = macros.id
597 short = macros.shorten
598 else:
599 id = macros.id_replace
600 short = macros.shorten_replace
601
602 return (id, short)
603
604
606 ''' Returns a function which shortens the revisison string
607 to 12-chars (chosen as this is the Mercurial short-id length)
608 and add link if replacement string is set.
609
610 (The full id is still visible in HTML, for mouse-over events etc.)
611
612 @param replace: see revlinkfilter()
613 @param templates: a jinja2 environment
614 '''
615
616 url_f = _revlinkcfg(replace, templates)
617
618 def filter(rev, repo):
619 if not rev:
620 return u''
621
622 id_html, short_html = _revlinkmacros(replace, templates)
623 rev = unicode(rev)
624 url = url_f(rev, repo)
625 rev = jinja2.escape(rev)
626 shortrev = rev[:12]
627
628 if shortrev == rev:
629 if url:
630 return id_html(rev=rev, url=url)
631 else:
632 return rev
633 else:
634 if url:
635 return short_html(short=shortrev, rev=rev, url=url)
636 else:
637 return shortrev + '...'
638
639 return filter
640
641
643 ''' Returns a function which adds an url link to a
644 revision identifiers.
645
646 Takes same params as shortrevfilter()
647
648 @param replace: either a python format string with an %s,
649 or a dict mapping repositories to format strings,
650 or a callable taking (revision, repository) arguments
651 and return an URL (or None, if no URL is available),
652 or None, in which case revisions do not get decorated
653 with links
654
655 @param templates: a jinja2 environment
656 '''
657
658 url_f = _revlinkcfg(replace, templates)
659
660 def filter(rev, repo):
661 if not rev:
662 return u''
663
664 rev = unicode(rev)
665 url = url_f(rev, repo)
666 if url:
667 id_html, _ = _revlinkmacros(replace, templates)
668 return id_html(rev=rev, url=url)
669 else:
670 return jinja2.escape(rev)
671
672 return filter
673
674
676 ''' Returns function that does regex search/replace in
677 comments to add links to bug ids and similar.
678
679 @param changelink:
680 Either C{None}
681 or: a tuple (2 or 3 elements)
682 1. a regex to match what we look for
683 2. an url with regex refs (\g<0>, \1, \2, etc) that becomes the 'href' attribute
684 3. (optional) an title string with regex ref regex
685 or: a dict mapping projects to above tuples
686 (no links will be added if the project isn't found)
687 or: a callable taking (changehtml, project) args
688 (where the changetext is HTML escaped in the
689 form of a jinja2.Markup instance) and
690 returning another jinja2.Markup instance with
691 the same change text plus any HTML tags added to it.
692 '''
693
694 assert not changelink or isinstance(changelink, dict) or \
695 isinstance(changelink, tuple) or callable(changelink)
696
697 def replace_from_tuple(t):
698 search, url_replace = t[:2]
699 if len(t) == 3:
700 title_replace = t[2]
701 else:
702 title_replace = ''
703
704 search_re = re.compile(search)
705
706 def replacement_unmatched(text):
707 return jinja2.escape(text)
708 def replacement_matched(mo):
709
710 url = jinja2.escape(mo.expand(url_replace))
711 title = jinja2.escape(mo.expand(title_replace))
712 body = jinja2.escape(mo.group())
713 if title:
714 return '<a href="%s" title="%s">%s</a>' % (url, title, body)
715 else:
716 return '<a href="%s">%s</a>' % (url, body)
717
718 def filter(text, project):
719
720
721
722
723 html = []
724 last_idx = 0
725 for mo in search_re.finditer(text):
726 html.append(replacement_unmatched(text[last_idx:mo.start()]))
727 html.append(replacement_matched(mo))
728 last_idx = mo.end()
729 html.append(replacement_unmatched(text[last_idx:]))
730 return jinja2.Markup(''.join(html))
731
732 return filter
733
734 if not changelink:
735 return lambda text, project: jinja2.escape(text)
736
737 elif isinstance(changelink, dict):
738 def dict_filter(text, project):
739
740
741
742 t = changelink.get(project)
743 if t:
744 return replace_from_tuple(t)(text, project)
745 else:
746 return cgi.escape(text)
747
748 return dict_filter
749
750 elif isinstance(changelink, tuple):
751 return replace_from_tuple(changelink)
752
753 elif callable(changelink):
754 def callable_filter(text, project):
755 text = jinja2.escape(text)
756 return changelink(text, project)
757
758 return callable_filter
759
760 assert False, 'changelink has unsupported type, but that is checked before'
761
762
764 '''A filter that encloses the given value in a link tag
765 given that the value exists in the dictionary'''
766
767 assert not links or callable(links) or isinstance(links, dict)
768
769 if not links:
770 return jinja2.escape
771
772 def filter(key):
773 if callable(links):
774 url = links(key)
775 else:
776 url = links.get(key)
777
778 safe_key = jinja2.escape(key)
779
780 if url:
781 return jinja2.Markup(r'<a href="%s">%s</a>' % (url, safe_key))
782 else:
783 return safe_key
784
785 return filter
786
788 ''' An undefined that allows boolean testing but
789 fails properly on every other use.
790
791 Much better than the default Undefined, but not
792 fully as strict as StrictUndefined '''
795
796 _charsetRe = re.compile('charset=([^;]*)', re.I)
798 """Get the charset for an x-www-form-urlencoded request"""
799
800 hdr = req.getHeader('Content-Type')
801 if hdr:
802 mo = _charsetRe.search(hdr)
803 if mo:
804 return mo.group(1).strip()
805 return 'utf-8'
806