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
361
362
363 have_DirectoryLister = False
364 if hasattr(static, 'DirectoryLister'):
365 have_DirectoryLister = True
367 """This variant of the static.DirectoryLister uses a template
368 for rendering."""
369
370 pageTitle = 'BuildBot'
371
373 cxt = self.getContext(request)
374
375 if self.dirs is None:
376 directory = os.listdir(self.path)
377 directory.sort()
378 else:
379 directory = self.dirs
380
381 dirs, files = self._getFilesAndDirectories(directory)
382
383 cxt['path'] = cgi.escape(urllib.unquote(request.uri))
384 cxt['directories'] = dirs
385 cxt['files'] = files
386 template = request.site.buildbot_service.templates.get_template("directory.html")
387 data = template.render(**cxt)
388 if isinstance(data, unicode):
389 data = data.encode("utf-8")
390 return data
391
393 """This class adds support for templated directory
394 views."""
395
397 if have_DirectoryLister:
398 return DirectoryLister(self.path,
399 self.listNames(),
400 self.contentTypes,
401 self.contentEncodings,
402 self.defaultType)
403 else:
404 return static.Data("""
405 Directory Listings require Twisted-9.0.0 or later
406 """, "text/plain")
407
408
409 MINUTE = 60
410 HOUR = 60*MINUTE
411 DAY = 24*HOUR
412 WEEK = 7*DAY
413 MONTH = 30*DAY
414
416 if int(num) == 1:
417 return "%d %s" % (num, word)
418 else:
419 return "%d %s" % (num, words)
420
422 if age <= 90:
423 return "%s ago" % plural("second", "seconds", age)
424 if age < 90*MINUTE:
425 return "about %s ago" % plural("minute", "minutes", age / MINUTE)
426 if age < DAY:
427 return "about %s ago" % plural("hour", "hours", age / HOUR)
428 if age < 2*WEEK:
429 return "about %s ago" % plural("day", "days", age / DAY)
430 if age < 2*MONTH:
431 return "about %s ago" % plural("week", "weeks", age / WEEK)
432 return "a long time ago"
433
434
436 LINE_TIME_FORMAT = "%b %d %H:%M"
437
439 '''
440 Collect the data needed for each line display
441 '''
442 builder_name = build.getBuilder().getName()
443 results = build.getResults()
444 text = build.getText()
445 rev = str(build.getProperty("got_revision", "??"))
446 css_class = css_classes.get(results, "")
447 repo = build.getSourceStamp().repository
448
449 if type(text) == list:
450 text = " ".join(text)
451
452 values = {'class': css_class,
453 'builder_name': builder_name,
454 'buildnum': build.getNumber(),
455 'results': css_class,
456 'text': " ".join(build.getText()),
457 'buildurl': path_to_build(req, build),
458 'builderurl': path_to_builder(req, build.getBuilder()),
459 'rev': rev,
460 'rev_repo' : repo,
461 'time': time.strftime(self.LINE_TIME_FORMAT,
462 time.localtime(build.getTimes()[0])),
463 'text': text,
464 'include_builder': include_builder
465 }
466 return values
467
469
470
471
472
473
474 if "trunk" in branches:
475 return branches + [None]
476 return branches
477
478
479
480
481 -def createJinjaEnv(revlink=None, changecommentlink=None,
482 repositories=None, projects=None):
483 ''' Create a jinja environment changecommentlink is used to
484 render HTML in the WebStatus and for mail changes
485
486 @type changecommentlink: C{None}, tuple (2 or 3 strings), dict (string -> 2- or 3-tuple) or callable
487 @param changecommentlink: see changelinkfilter()
488
489 @type revlink: C{None}, format-string, dict (repository -> format string) or callable
490 @param revlink: see revlinkfilter()
491
492 @type repositories: C{None} or dict (string -> url)
493 @param repositories: an (optinal) mapping from repository identifiers
494 (as given by Change sources) to URLs. Is used to create a link
495 on every place where a repository is listed in the WebStatus.
496
497 @type projects: C{None} or dict (string -> url)
498 @param projects: similar to repositories, but for projects.
499 '''
500
501
502 assert not hasattr(sys, "frozen"), 'Frozen config not supported with jinja (yet)'
503
504 default_loader = jinja2.PackageLoader('buildbot.status.web', 'templates')
505 root = os.path.join(os.getcwd(), 'templates')
506 loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(root),
507 default_loader])
508 env = jinja2.Environment(loader=loader,
509 extensions=['jinja2.ext.i18n'],
510 trim_blocks=True,
511 undefined=AlmostStrictUndefined)
512
513 env.install_null_translations()
514
515 env.filters.update(dict(
516 urlencode = urllib.quote,
517 email = emailfilter,
518 user = userfilter,
519 shortrev = shortrevfilter(revlink, env),
520 revlink = revlinkfilter(revlink, env),
521 changecomment = changelinkfilter(changecommentlink),
522 repolink = dictlinkfilter(repositories),
523 projectlink = dictlinkfilter(projects)
524 ))
525
526 return env
527
529 ''' Escape & obfuscate e-mail addresses
530
531 replacing @ with <span style="display:none> reportedly works well against web-spiders
532 and the next level is to use rot-13 (or something) and decode in javascript '''
533
534 user = jinja2.escape(value)
535 obfuscator = jinja2.Markup('<span style="display:none">ohnoyoudont</span>@')
536 output = user.replace('@', obfuscator)
537 return output
538
539
541 ''' Hide e-mail address from user name when viewing changes
542
543 We still include the (obfuscated) e-mail so that we can show
544 it on mouse-over or similar etc
545 '''
546 r = re.compile('(.*) +<(.*)>')
547 m = r.search(value)
548 if m:
549 user = jinja2.escape(m.group(1))
550 email = emailfilter(m.group(2))
551 return jinja2.Markup('<div class="user">%s<div class="email">%s</div></div>' % (user, email))
552 else:
553 return emailfilter(value)
554
556 '''Helper function that returns suitable macros and functions
557 for building revision links depending on replacement mechanism
558 '''
559
560 assert not replace or callable(replace) or isinstance(replace, dict) or \
561 isinstance(replace, str) or isinstance(replace, unicode)
562
563 if not replace:
564 return lambda rev, repo: None
565 else:
566 if callable(replace):
567 return lambda rev, repo: replace(rev, repo)
568 elif isinstance(replace, dict):
569 def filter(rev, repo):
570 url = replace.get(repo)
571 if url:
572 return url % urllib.quote(rev)
573 else:
574 return None
575
576 return filter
577 else:
578 return lambda rev, repo: replace % urllib.quote(rev)
579
580 assert False, '_replace has a bad type, but we should never get here'
581
582
584 '''return macros for use with revision links, depending
585 on whether revlinks are configured or not'''
586
587 macros = templates.get_template("revmacros.html").module
588
589 if not replace:
590 id = macros.id
591 short = macros.shorten
592 else:
593 id = macros.id_replace
594 short = macros.shorten_replace
595
596 return (id, short)
597
598
600 ''' Returns a function which shortens the revisison string
601 to 12-chars (chosen as this is the Mercurial short-id length)
602 and add link if replacement string is set.
603
604 (The full id is still visible in HTML, for mouse-over events etc.)
605
606 @param replace: see revlinkfilter()
607 @param templates: a jinja2 environment
608 '''
609
610 url_f = _revlinkcfg(replace, templates)
611
612 def filter(rev, repo):
613 if not rev:
614 return u''
615
616 id_html, short_html = _revlinkmacros(replace, templates)
617 rev = unicode(rev)
618 url = url_f(rev, repo)
619 rev = jinja2.escape(rev)
620 shortrev = rev[:12]
621
622 if shortrev == rev:
623 if url:
624 return id_html(rev=rev, url=url)
625 else:
626 return rev
627 else:
628 if url:
629 return short_html(short=shortrev, rev=rev, url=url)
630 else:
631 return shortrev + '...'
632
633 return filter
634
635
637 ''' Returns a function which adds an url link to a
638 revision identifiers.
639
640 Takes same params as shortrevfilter()
641
642 @param replace: either a python format string with an %s,
643 or a dict mapping repositories to format strings,
644 or a callable taking (revision, repository) arguments
645 and return an URL (or None, if no URL is available),
646 or None, in which case revisions do not get decorated
647 with links
648
649 @param templates: a jinja2 environment
650 '''
651
652 url_f = _revlinkcfg(replace, templates)
653
654 def filter(rev, repo):
655 if not rev:
656 return u''
657
658 rev = unicode(rev)
659 url = url_f(rev, repo)
660 if url:
661 id_html, _ = _revlinkmacros(replace, templates)
662 return id_html(rev=rev, url=url)
663 else:
664 return jinja2.escape(rev)
665
666 return filter
667
668
670 ''' Returns function that does regex search/replace in
671 comments to add links to bug ids and similar.
672
673 @param changelink:
674 Either C{None}
675 or: a tuple (2 or 3 elements)
676 1. a regex to match what we look for
677 2. an url with regex refs (\g<0>, \1, \2, etc) that becomes the 'href' attribute
678 3. (optional) an title string with regex ref regex
679 or: a dict mapping projects to above tuples
680 (no links will be added if the project isn't found)
681 or: a callable taking (changehtml, project) args
682 (where the changetext is HTML escaped in the
683 form of a jinja2.Markup instance) and
684 returning another jinja2.Markup instance with
685 the same change text plus any HTML tags added to it.
686 '''
687
688 assert not changelink or isinstance(changelink, dict) or \
689 isinstance(changelink, tuple) or callable(changelink)
690
691 def replace_from_tuple(t):
692 search, url_replace = t[:2]
693 if len(t) == 3:
694 title_replace = t[2]
695 else:
696 title_replace = ''
697
698 search_re = re.compile(search)
699
700 def replacement_unmatched(text):
701 return jinja2.escape(text)
702 def replacement_matched(mo):
703
704 url = jinja2.escape(mo.expand(url_replace))
705 title = jinja2.escape(mo.expand(title_replace))
706 body = jinja2.escape(mo.group())
707 if title:
708 return '<a href="%s" title="%s">%s</a>' % (url, title, body)
709 else:
710 return '<a href="%s">%s</a>' % (url, body)
711
712 def filter(text, project):
713
714
715
716
717 html = []
718 last_idx = 0
719 for mo in search_re.finditer(text):
720 html.append(replacement_unmatched(text[last_idx:mo.start()]))
721 html.append(replacement_matched(mo))
722 last_idx = mo.end()
723 html.append(replacement_unmatched(text[last_idx:]))
724 return jinja2.Markup(''.join(html))
725
726 return filter
727
728 if not changelink:
729 return lambda text, project: jinja2.escape(text)
730
731 elif isinstance(changelink, dict):
732 def dict_filter(text, project):
733
734
735
736 t = changelink.get(project)
737 if t:
738 return replace_from_tuple(t)(text, project)
739 else:
740 return cgi.escape(text)
741
742 return dict_filter
743
744 elif isinstance(changelink, tuple):
745 return replace_from_tuple(changelink)
746
747 elif callable(changelink):
748 def callable_filter(text, project):
749 text = jinja2.escape(text)
750 return changelink(text, project)
751
752 return callable_filter
753
754 assert False, 'changelink has unsupported type, but that is checked before'
755
756
758 '''A filter that encloses the given value in a link tag
759 given that the value exists in the dictionary'''
760
761 assert not links or callable(links) or isinstance(links, dict)
762
763 if not links:
764 return jinja2.escape
765
766 def filter(key):
767 if callable(links):
768 url = links(key)
769 else:
770 url = links.get(key)
771
772 safe_key = jinja2.escape(key)
773
774 if url:
775 return jinja2.Markup(r'<a href="%s">%s</a>' % (url, safe_key))
776 else:
777 return safe_key
778
779 return filter
780
782 ''' An undefined that allows boolean testing but
783 fails properly on every other use.
784
785 Much better than the default Undefined, but not
786 fully as strict as StrictUndefined '''
789