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