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
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
175 - def getStatus(self, request):
176 return request.site.buildbot_service.getStatus()
177
178 - def getTitle(self, request):
180
182
183 contentType = "text/html; charset=utf-8"
184 title = "Buildbot"
185 addSlash = False
186
188 if self.addSlash and path == "" and len(request.postpath) == 0:
189 return self
190 return resource.Resource.getChild(self, path, request)
191
193
194
195
196
197
198 if hasattr(request, "channel"):
199
200 request.site.buildbot_service.registerChannel(request.channel)
201
202
203
204
205
206 if False and self.addSlash and request.prepath[-1] != '':
207
208
209
210
211
212
213
214
215
216
217
218 url = request.prePathURL()
219 scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
220 new_url = request.prepath[-1] + "/"
221 if query:
222 new_url += "?" + query
223 request.redirect(new_url)
224 return ''
225
226 ctx = self.getContext(request)
227
228 data = self.content(request, ctx)
229 if isinstance(data, unicode):
230 data = data.encode("utf-8")
231 request.setHeader("content-type", self.contentType)
232 if request.method == "HEAD":
233 request.setHeader("content-length", len(data))
234 return ''
235 return data
236
238 return request.site.buildbot_service.authz
239
241 return request.site.buildbot_service.master
242
243
249 - def content(self, request, cxt):
250 cxt['content'] = self.bodyHTML
251 cxt['title'] = self.title
252 template = request.site.buildbot_service.templates.get_template("empty.html")
253 return template.render(**cxt)
254
255
256
257 have_DirectoryLister = False
258 if hasattr(static, 'DirectoryLister'):
259 have_DirectoryLister = True
261 """This variant of the static.DirectoryLister uses a template
262 for rendering."""
263
264 title = 'BuildBot'
265
267 cxt = self.getContext(request)
268
269 if self.dirs is None:
270 directory = os.listdir(self.path)
271 directory.sort()
272 else:
273 directory = self.dirs
274
275 dirs, files = self._getFilesAndDirectories(directory)
276
277 cxt['path'] = cgi.escape(urllib.unquote(request.uri))
278 cxt['directories'] = dirs
279 cxt['files'] = files
280 template = request.site.buildbot_service.templates.get_template("directory.html")
281 data = template.render(**cxt)
282 if isinstance(data, unicode):
283 data = data.encode("utf-8")
284 return data
285
287 """This class adds support for templated directory
288 views."""
289
291 if have_DirectoryLister:
292 return DirectoryLister(self.path,
293 self.listNames(),
294 self.contentTypes,
295 self.contentEncodings,
296 self.defaultType)
297 else:
298 return static.Data("""
299 Directory Listings require Twisted-9.0.0 or later
300 """, "text/plain")
301
302
303 MINUTE = 60
304 HOUR = 60*MINUTE
305 DAY = 24*HOUR
306 WEEK = 7*DAY
307 MONTH = 30*DAY
308
310 if int(num) == 1:
311 return "%d %s" % (num, word)
312 else:
313 return "%d %s" % (num, words)
314
316 if age <= 90:
317 return "%s ago" % plural("second", "seconds", age)
318 if age < 90*MINUTE:
319 return "about %s ago" % plural("minute", "minutes", age / MINUTE)
320 if age < DAY:
321 return "about %s ago" % plural("hour", "hours", age / HOUR)
322 if age < 2*WEEK:
323 return "about %s ago" % plural("day", "days", age / DAY)
324 if age < 2*MONTH:
325 return "about %s ago" % plural("week", "weeks", age / WEEK)
326 return "a long time ago"
327
328
330 LINE_TIME_FORMAT = "%b %d %H:%M"
331
333 '''
334 Collect the data needed for each line display
335 '''
336 builder_name = build.getBuilder().getName()
337 results = build.getResults()
338 text = build.getText()
339 try:
340 rev = build.getProperty("got_revision")
341 if rev is None:
342 rev = "??"
343 except KeyError:
344 rev = "??"
345 rev = str(rev)
346 css_class = css_classes.get(results, "")
347
348 if type(text) == list:
349 text = " ".join(text)
350
351 values = {'class': css_class,
352 'builder_name': builder_name,
353 'buildnum': build.getNumber(),
354 'results': css_class,
355 'text': " ".join(build.getText()),
356 'buildurl': path_to_build(req, build),
357 'builderurl': path_to_builder(req, build.getBuilder()),
358 'rev': rev,
359 'time': time.strftime(self.LINE_TIME_FORMAT,
360 time.localtime(build.getTimes()[0])),
361 'text': text,
362 'include_builder': include_builder
363 }
364 return values
365
367
368
369
370
371
372 if "trunk" in branches:
373 return branches + [None]
374 return branches
375
376
377
378
379 -def createJinjaEnv(revlink=None, changecommentlink=None,
380 repositories=None, projects=None):
381 ''' Create a jinja environment changecommentlink is used to
382 render HTML in the WebStatus and for mail changes
383
384 @type changecommentlink: C{None}, tuple (2 or 3 strings), dict (string -> 2- or 3-tuple) or callable
385 @param changecommentlink: see changelinkfilter()
386
387 @type revlink: C{None}, format-string, dict (repository -> format string) or callable
388 @param revlink: see revlinkfilter()
389
390 @type repositories: C{None} or dict (string -> url)
391 @param repositories: an (optinal) mapping from repository identifiers
392 (as given by Change sources) to URLs. Is used to create a link
393 on every place where a repository is listed in the WebStatus.
394
395 @type projects: C{None} or dict (string -> url)
396 @param projects: similar to repositories, but for projects.
397 '''
398
399
400 assert not hasattr(sys, "frozen"), 'Frozen config not supported with jinja (yet)'
401
402 default_loader = jinja2.PackageLoader('buildbot.status.web', 'templates')
403 root = os.path.join(os.getcwd(), 'templates')
404 loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(root),
405 default_loader])
406 env = jinja2.Environment(loader=loader,
407 extensions=['jinja2.ext.i18n'],
408 trim_blocks=True,
409 undefined=AlmostStrictUndefined)
410
411 env.install_null_translations()
412
413 env.filters.update(dict(
414 urlencode = urllib.quote,
415 email = emailfilter,
416 user = userfilter,
417 shortrev = shortrevfilter(revlink, env),
418 revlink = revlinkfilter(revlink, env),
419 changecomment = changelinkfilter(changecommentlink),
420 repolink = dictlinkfilter(repositories),
421 projectlink = dictlinkfilter(projects)
422 ))
423
424 return env
425
427 ''' Escape & obfuscate e-mail addresses
428
429 replacing @ with <span style="display:none> reportedly works well against web-spiders
430 and the next level is to use rot-13 (or something) and decode in javascript '''
431
432 user = jinja2.escape(value)
433 obfuscator = jinja2.Markup('<span style="display:none">ohnoyoudont</span>@')
434 output = user.replace('@', obfuscator)
435 return output
436
437
439 ''' Hide e-mail address from user name when viewing changes
440
441 We still include the (obfuscated) e-mail so that we can show
442 it on mouse-over or similar etc
443 '''
444 r = re.compile('(.*) +<(.*)>')
445 m = r.search(value)
446 if m:
447 user = jinja2.escape(m.group(1))
448 email = emailfilter(m.group(2))
449 return jinja2.Markup('<div class="user">%s<div class="email">%s</div></div>' % (user, email))
450 else:
451 return emailfilter(value)
452
454 '''Helper function that returns suitable macros and functions
455 for building revision links depending on replacement mechanism
456 '''
457
458 assert not replace or callable(replace) or isinstance(replace, dict) or \
459 isinstance(replace, str) or isinstance(replace, unicode)
460
461 if not replace:
462 return lambda rev, repo: None
463 else:
464 if callable(replace):
465 return lambda rev, repo: replace(rev, repo)
466 elif isinstance(replace, dict):
467 def filter(rev, repo):
468 url = replace.get(repo)
469 if url:
470 return url % urllib.quote(rev)
471 else:
472 return None
473
474 return filter
475 else:
476 return lambda rev, repo: replace % urllib.quote(rev)
477
478 assert False, '_replace has a bad type, but we should never get here'
479
480
482 '''return macros for use with revision links, depending
483 on whether revlinks are configured or not'''
484
485 macros = templates.get_template("revmacros.html").module
486
487 if not replace:
488 id = macros.id
489 short = macros.shorten
490 else:
491 id = macros.id_replace
492 short = macros.shorten_replace
493
494 return (id, short)
495
496
498 ''' Returns a function which shortens the revisison string
499 to 12-chars (chosen as this is the Mercurial short-id length)
500 and add link if replacement string is set.
501
502 (The full id is still visible in HTML, for mouse-over events etc.)
503
504 @param replace: see revlinkfilter()
505 @param templates: a jinja2 environment
506 '''
507
508 url_f = _revlinkcfg(replace, templates)
509
510 def filter(rev, repo):
511 if not rev:
512 return u''
513
514 id_html, short_html = _revlinkmacros(replace, templates)
515 rev = unicode(rev)
516 url = url_f(rev, repo)
517 rev = jinja2.escape(rev)
518 shortrev = rev[:12]
519
520 if shortrev == rev:
521 if url:
522 return id_html(rev=rev, url=url)
523 else:
524 return rev
525 else:
526 if url:
527 return short_html(short=shortrev, rev=rev, url=url)
528 else:
529 return shortrev + '...'
530
531 return filter
532
533
535 ''' Returns a function which adds an url link to a
536 revision identifiers.
537
538 Takes same params as shortrevfilter()
539
540 @param replace: either a python format string with an %s,
541 or a dict mapping repositories to format strings,
542 or a callable taking (revision, repository) arguments
543 and return an URL (or None, if no URL is available),
544 or None, in which case revisions do not get decorated
545 with links
546
547 @param templates: a jinja2 environment
548 '''
549
550 url_f = _revlinkcfg(replace, templates)
551
552 def filter(rev, repo):
553 if not rev:
554 return u''
555
556 rev = unicode(rev)
557 url = url_f(rev, repo)
558 if url:
559 id_html, _ = _revlinkmacros(replace, templates)
560 return id_html(rev=rev, url=url)
561 else:
562 return jinja2.escape(rev)
563
564 return filter
565
566
568 ''' Returns function that does regex search/replace in
569 comments to add links to bug ids and similar.
570
571 @param changelink:
572 Either C{None}
573 or: a tuple (2 or 3 elements)
574 1. a regex to match what we look for
575 2. an url with regex refs (\g<0>, \1, \2, etc) that becomes the 'href' attribute
576 3. (optional) an title string with regex ref regex
577 or: a dict mapping projects to above tuples
578 (no links will be added if the project isn't found)
579 or: a callable taking (changehtml, project) args
580 (where the changetext is HTML escaped in the
581 form of a jinja2.Markup instance) and
582 returning another jinja2.Markup instance with
583 the same change text plus any HTML tags added to it.
584 '''
585
586 assert not changelink or isinstance(changelink, dict) or \
587 isinstance(changelink, tuple) or callable(changelink)
588
589 def replace_from_tuple(t):
590 search, url_replace = t[:2]
591 if len(t) == 3:
592 title_replace = ' title="%s"' % t[2]
593 else:
594 title_replace = ''
595
596 search_re = re.compile(search)
597 link_replace_re = jinja2.Markup(r'<a href="%s"%s>\g<0></a>' % (url_replace, title_replace))
598
599 def filter(text, project):
600 text = jinja2.escape(text)
601 html = search_re.sub(link_replace_re, text)
602 return html
603
604 return filter
605
606 if not changelink:
607 return lambda text, project: jinja2.escape(text)
608
609 elif isinstance(changelink, dict):
610 def dict_filter(text, project):
611
612
613
614 t = changelink.get(project)
615 if t:
616 return replace_from_tuple(t)(text, project)
617 else:
618 return jinja2.escape(text)
619
620 return dict_filter
621
622 elif isinstance(changelink, tuple):
623 return replace_from_tuple(changelink)
624
625 elif callable(changelink):
626 def callable_filter(text, project):
627 text = jinja2.escape(text)
628 return changelink(text, project)
629
630 return callable_filter
631
632 assert False, 'changelink has unsupported type, but that is checked before'
633
634
636 '''A filter that encloses the given value in a link tag
637 given that the value exists in the dictionary'''
638
639 assert not links or callable(links) or isinstance(links, dict)
640
641 if not links:
642 return jinja2.escape
643
644 def filter(key):
645 if callable(links):
646 url = links(key)
647 else:
648 url = links.get(key)
649
650 safe_key = jinja2.escape(key)
651
652 if url:
653 return jinja2.Markup(r'<a href="%s">%s</a>' % (url, safe_key))
654 else:
655 return safe_key
656
657 return filter
658
660 ''' An undefined that allows boolean testing but
661 fails properly on every other use.
662
663 Much better than the default Undefined, but not
664 fully as strict as StrictUndefined '''
667