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