source: hudsontracplugin/0.10/HudsonTrac/HudsonTracPlugin.py

Last change on this file was 14253, checked in by Ryan J Ollos, 9 years ago

Removed print statement.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Id HeadURL Revision
File size: 16.8 KB
RevLine 
[3149]1# -*- coding: utf-8 -*-
2"""
[7911]3A Trac plugin which interfaces with the Hudson Continuous integration server
4
5You can configure this component via the
6[wiki:TracIni#hudson-section "[hudson]"]
7section in the trac.ini file.
8
9See also:
10 - http://hudson-ci.org/
11 - http://wiki.hudson-ci.org/display/HUDSON/Trac+Plugin
[3149]12"""
13
14import time
[5698]15import urllib2
[7895]16import base64
[4593]17from datetime import datetime
[14251]18
19from genshi.builder import tag
[3149]20from trac.core import *
[9471]21from trac.config import Option, BoolOption, ListOption
[6684]22from trac.perm import IPermissionRequestor
[6683]23from trac.util import Markup, format_datetime, pretty_timedelta
[7911]24from trac.util.text import unicode_quote
25from trac.web.chrome import INavigationContributor, ITemplateProvider
26from trac.web.chrome import add_stylesheet
[9470]27from trac.wiki.formatter import wiki_to_oneliner
[4593]28try:
29    from trac.timeline.api import ITimelineEventProvider
30except ImportError:
31    from trac.Timeline import ITimelineEventProvider
[9468]32try:
33    from ast import literal_eval
34except ImportError:
35    def literal_eval(str):
36        return eval(str, {"__builtins__":None}, {"True":True, "False":False})
[3149]37
38class HudsonTracPlugin(Component):
[7911]39    """
40    Display Hudson results in the timeline and an entry in the main navigation
41    bar.
42    """
[3149]43
[7911]44    implements(INavigationContributor, ITimelineEventProvider,
45               ITemplateProvider, IPermissionRequestor)
46
[6683]47    disp_mod = BoolOption('hudson', 'display_modules', 'false',
[9467]48                          'Display status of modules in the timeline too. ')
[6683]49    job_url  = Option('hudson', 'job_url', 'http://localhost/hudson/',
[7911]50                      'The url of the top-level hudson page if you want to '
51                      'display all jobs, or a job or module url (such as '
52                      'http://localhost/hudson/job/build_foo/) if you want '
53                      'only display builds from a single job or module. '
[6683]54                      'This must be an absolute url.')
[5698]55    username = Option('hudson', 'username', '',
[6683]56                      'The username to use to access hudson')
[5698]57    password = Option('hudson', 'password', '',
[11432]58                      'The password to use to access hudson - but see also '
59                      'the api_token field.')
60    api_token = Option('hudson', 'api_token', '',
61                       'The API Token to use to access hudson. This takes '
62                       'precendence over any password and is the preferred '
63                       'mechanism if you are running Jenkins 1.426 or later '
64                       'and Jenkins is enforcing authentication (as opposed '
65                       'to, for example, a proxy in front of Jenkins).')
[3149]66    nav_url  = Option('hudson', 'main_page', '/hudson/',
[7911]67                      'The url of the hudson main page to which the trac nav '
68                      'entry should link; if empty, no entry is created in '
[4843]69                      'the nav bar. This may be a relative url.')
[11431]70    tl_label = Option('hudson', 'timeline_opt_label', 'Hudson Builds',
71                      'The label for the timeline option to display builds')
[4592]72    disp_tab = BoolOption('hudson', 'display_in_new_tab', 'false',
[4843]73                          'Open hudson page in new tab/window')
[6006]74    alt_succ = BoolOption('hudson', 'alternate_success_icon', 'false',
[7911]75                          'Use an alternate success icon (green ball instead '
[6007]76                          'of blue)')
77    use_desc = BoolOption('hudson', 'display_build_descriptions', 'true',
[7911]78                          'Whether to display the build descriptions for '
79                          'each build instead of the canned "Build finished '
[6683]80                          'successfully" etc messages.')
[9469]81    disp_building = BoolOption('hudson', 'display_building', False,
82                               'Also show in-progress builds')
[9470]83    list_changesets = BoolOption('hudson', 'list_changesets', False,
84                                 'List the changesets for each build')
[9471]85    disp_culprit = ListOption('hudson', 'display_culprit', [], doc =
86                              'Display the culprit(s) for each build. This is '
87                              'a comma-separated list of zero or more of the '
88                              'following tokens: `starter`, `author`, '
89                              '`authors`, `culprit`, `culprits`. `starter` is '
90                              'the user that started the build, if any; '
91                              '`author` is the author of the first commit, if '
92                              'any; `authors` is the list of authors of all '
93                              'commits; `culprit` is the first of what hudson '
94                              'thinks are the culprits that caused the build; '
95                              'and `culprits` is the list of all culprits. If '
96                              'given a list, the first non-empty value is used.'
97                              ' Example: `starter,authors` (this would show '
98                              'who started the build if it was started '
99                              'manually, else list the authors of the commits '
100                              'that triggered the build if any, else show no '
101                              'author for the build).')
[3149]102
[5698]103    def __init__(self):
[9467]104        # get base api url
[7911]105        api_url = unicode_quote(self.job_url, '/%:@')
106        if api_url and api_url[-1] != '/':
[6683]107            api_url += '/'
[9468]108        api_url += 'api/python'
[6100]109
[9467]110        # set up http authentication
[11432]111        if self.username and self.api_token:
112            handlers = [
113                self.HTTPOpenHandlerBasicAuthNoChallenge(self.username,
114                                                         self.api_token)
115            ]
116        elif self.username and self.password:
117            pwd_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
118            pwd_mgr.add_password(None, api_url, self.username, self.password)
[5698]119
[11432]120            b_auth = urllib2.HTTPBasicAuthHandler(pwd_mgr)
121            d_auth = urllib2.HTTPDigestAuthHandler(pwd_mgr)
[5698]122
[11432]123            handlers = [ b_auth, d_auth, self.HudsonFormLoginHandler(self) ]
124        else:
125            handlers = []
[5698]126
[11432]127        self.url_opener = urllib2.build_opener(*handlers)
128        if handlers:
129            self.env.log.debug("registered auth-handlers for '%s', " \
130                               "username='%s'", api_url, self.username)
[6100]131
[9467]132        # construct tree=... parameter to query for the desired items
133        tree = '%(b)s'
134        if self.disp_mod:
135            tree += ',modules[%(b)s]'
136        if '/job/' not in api_url:
137            tree = 'jobs[' + tree + ']'
[6683]138
[9470]139        items = 'builds[building,timestamp,duration,result,description,url,' \
140                'fullDisplayName'
[9471]141
142        elems = []
[9470]143        if self.list_changesets:
[9471]144            elems.append('revision')
145            elems.append('id')
146        if 'author' in self.disp_culprit or 'authors' in self.disp_culprit:
147            elems.append('user')
148            elems.append('author[fullName]')
149        if elems:
150            items += ',changeSet[items[%s]]' % ','.join(elems)
151
152        if 'culprit' in self.disp_culprit or 'culprits' in self.disp_culprit:
153            items += ',culprits[fullName]'
154
155        if 'starter' in self.disp_culprit:
156            items += ',actions[causes[userName]]'
157
[9470]158        items += ']'
[6683]159
[9467]160        # assemble final url
[9470]161        tree = tree % {'b': items}
[9467]162        self.info_url = '%s?tree=%s' % (api_url, tree)
163
[7911]164        self.env.log.debug("Build-info url: '%s'", self.info_url)
[6683]165
[9472]166    def __get_info(self):
167        """Retrieve build information from Hudson"""
168        try:
169            local_exc = False
170            try:
171                resp = self.url_opener.open(self.info_url)
172                cset = resp.info().getparam('charset') or 'ISO-8859-1'
173
174                ct   = resp.info().gettype()
175                if ct != 'text/x-python':
176                    local_exc = True
177                    raise IOError(
178                        "Error getting build info from '%s': returned document "
179                        "has unexpected type '%s' (expected 'text/x-python'). "
180                        "The returned text is:\n%s" %
181                        (self.info_url, ct, unicode(resp.read(), cset)))
182
183                info = literal_eval(resp.read())
184
185                return info, cset
186            except Exception:
187                if local_exc:
188                    raise
189
190                import sys
191                self.env.log.exception("Error getting build info from '%s'",
192                                       self.info_url)
193                raise IOError(
194                    "Error getting build info from '%s': %s: %s. This most "
195                    "likely means you configured a wrong job_url, username, "
196                    "or password." %
197                    (self.info_url, sys.exc_info()[0].__name__,
198                     str(sys.exc_info()[1])))
199        finally:
200            self.url_opener.close()
201
202    def __find_all(self, d, paths):
203        """Find and return a list of all items with the given paths."""
204        if not isinstance(paths, basestring):
205            for path in paths:
206                for item in self.__find_all(d, path):
207                    yield item
208            return
209
210        parts = paths.split('.', 1)
211        key = parts[0]
212        if key in d:
213            if len(parts) > 1:
214                for item in self.__find_all(d[key], parts[1]):
215                    yield item
216            else:
217                yield d[key]
218        elif not isinstance(d, dict) and not isinstance(d, basestring):
219            for elem in d:
220                for item in self.__find_all(elem, paths):
221                    yield item
222
223    def __find_first(self, d, paths):
224        """Similar to __find_all, but return only the first item or None"""
225        l = list(self.__find_all(d, paths))
226        return len(l) > 0 and l[0] or None
227
228    def __extract_builds(self, info):
229        """Extract individual builds from the info returned by Hudson.
230        What we may get from Hudson is zero or more of the following:
231          {'jobs': [{'modules': [{'builds': [{'building': False, ...
232          {'jobs': [{'builds': [{'building': False, ...
233          {'modules': [{'builds': [{'building': False, ...
234          {'builds': [{'building': False, ...
235        """
236        p = ['builds', 'modules.builds', 'jobs.builds', 'jobs.modules.builds']
237        for arr in self.__find_all(info, p):
238            for item in arr:
239                yield item
240
[6684]241    # IPermissionRequestor methods 
242
243    def get_permission_actions(self):
244        return ['BUILD_VIEW']
245
[3149]246    # INavigationContributor methods
247
248    def get_active_navigation_item(self, req):
249        return 'builds'
250
251    def get_navigation_items(self, req):
[7278]252        if self.nav_url and req.perm.has_permission('BUILD_VIEW'):
[14251]253            yield ('mainnav', 'builds',
254                   tag.a('Builds', href=self.nav_url,
[14252]255                         target='hudson' if self.disp_tab else None))
[3149]256
257    # ITemplateProvider methods
258    def get_templates_dirs(self):
[7912]259        return []
[3149]260
261    def get_htdocs_dirs(self):
262        from pkg_resources import resource_filename
263        return [('HudsonTrac', resource_filename(__name__, 'htdocs'))]
264
265    # ITimelineEventProvider methods
266
267    def get_timeline_filters(self, req):
[7278]268        if req.perm.has_permission('BUILD_VIEW'):
[11431]269            yield ('build', self.tl_label)
[3149]270
[9470]271    def __fmt_changeset(self, rev, req):
272        # use format_to_oneliner and drop num_args hack when we drop Trac 0.10
273        # support
274        import inspect
275        num_args = len(inspect.getargspec(wiki_to_oneliner)[0])
276        if num_args > 5:
277            return wiki_to_oneliner('[%s]' % rev, self.env, req=req)
278        else:
279            return wiki_to_oneliner('[%s]' % rev, self.env)
280
[3149]281    def get_timeline_events(self, req, start, stop, filters):
[7278]282        if 'build' not in filters or not req.perm.has_permission('BUILD_VIEW'):
[6097]283            return
284
[6683]285        # Support both Trac 0.10 and 0.11
[4843]286        if isinstance(start, datetime): # Trac>=0.11
[6099]287            from trac.util.datefmt import to_timestamp
288            start = to_timestamp(start)
289            stop = to_timestamp(stop)
[4593]290
[6097]291        add_stylesheet(req, 'HudsonTrac/hudsontrac.css')
[3149]292
[6683]293        # get and parse the build-info
[9472]294        info, cset = self.__get_info()
[9468]295
[6683]296        # extract all build entries
[9472]297        for entry in self.__extract_builds(info):
[9469]298            # get result, optionally ignoring builds that are still running
299            if entry['building']:
300                if self.disp_building:
301                    result = 'IN-PROGRESS'
302                else:
303                    continue
304            else:
305                result = entry['result']
[3149]306
[9468]307            # get start/stop times
[9469]308            started = entry['timestamp'] / 1000
[9467]309            if started < start or started > stop:
310                continue
311
[9469]312            if result == 'IN-PROGRESS':
313                # we hope the clocks are close...
314                completed = time.time()
315            else:
316                completed = (entry['timestamp'] + entry['duration']) / 1000
317
[9468]318            # get message
[7911]319            message, kind = {
320                'SUCCESS': ('Build finished successfully',
321                            ('build-successful',
322                             'build-successful-alt')[self.alt_succ]),
323                'UNSTABLE': ('Build unstable', 'build-unstable'),
324                'ABORTED': ('Build aborted', 'build-aborted'),
[9469]325                'IN-PROGRESS': ('Build in progress',
326                                ('build-inprogress',
327                                 'build-inprogress-alt')[self.alt_succ]),
[7911]328                }.get(result, ('Build failed', 'build-failed'))
[3149]329
[6097]330            if self.use_desc:
[9468]331                message = entry['description'] and \
332                            unicode(entry['description'], cset) or message
[4594]333
[9470]334            # get changesets
335            changesets = ''
336            if self.list_changesets:
337                paths = ['changeSet.items.revision', 'changeSet.items.id']
[9472]338                revs  = [unicode(str(r), cset) for r in \
339                                                self.__find_all(entry, paths)]
[9470]340                if revs:
341                    revs = [self.__fmt_changeset(r, req) for r in revs]
342                    changesets = '<br/>Changesets: ' + ', '.join(revs)
343
[9471]344            # get author(s)
345            author = None
346            for c in self.disp_culprit:
347                author = {
[9472]348                    'starter':
349                        self.__find_first(entry, 'actions.causes.userName'),
350                    'author':
351                        self.__find_first(entry, ['changeSet.items.user',
[9471]352                                           'changeSet.items.author.fullName']),
[9472]353                    'authors':
354                        self.__find_all(entry, ['changeSet.items.user',
[9471]355                                           'changeSet.items.author.fullName']),
[9472]356                    'culprit':
357                        self.__find_first(entry, 'culprits.fullName'),
358                    'culprits':
359                        self.__find_all(entry, 'culprits.fullName'),
[9471]360                }.get(c)
361
362                if author and not isinstance(author, basestring):
363                    author = ', '.join(set(author))
364                if author:
365                    author = unicode(author, cset)
366                    break
367
[9468]368            # format response
[9469]369            if result == 'IN-PROGRESS':
[9470]370                comment = Markup("%s since %s, duration %s%s" % (
371                                 message, format_datetime(started),
372                                 pretty_timedelta(started, completed),
373                                 changesets))
[9469]374            else:
[9470]375                comment = Markup("%s at %s, duration %s%s" % (
376                                 message, format_datetime(completed),
377                                 pretty_timedelta(started, completed),
378                                 changesets))
[4594]379
[9468]380            href  = entry['url']
[7911]381            title = 'Build "%s" (%s)' % \
[9468]382                    (unicode(entry['fullDisplayName'], cset), result.lower())
[6007]383
[9471]384            yield kind, href, title, completed, author, comment
[3149]385
[7895]386    class HudsonFormLoginHandler(urllib2.BaseHandler):
387        def __init__(self, parent):
388            self.p = parent
389
390        def http_error_403(self, req, fp, code, msg, headers):
391            for h in self.p.url_opener.handlers:
392                if isinstance(h, self.p.HTTPOpenHandlerBasicAuthNoChallenge):
393                    return
394
395            self.p.url_opener.add_handler(
[7911]396                self.p.HTTPOpenHandlerBasicAuthNoChallenge(self.p.username,
397                                                           self.p.password))
398            self.p.env.log.debug(
399                "registered auth-handler for form-based authentication")
[7895]400
401            fp.close()
402            return self.p.url_opener.open(req)
403
404    class HTTPOpenHandlerBasicAuthNoChallenge(urllib2.BaseHandler):
405
406        auth_header = 'Authorization'
407
408        def __init__(self, username, password):
409            raw = "%s:%s" % (username, password)
410            self.auth = 'Basic %s' % base64.b64encode(raw).strip()
411
412        def default_open(self, req):
413            req.add_header(self.auth_header, self.auth)
414
Note: See TracBrowser for help on using the repository browser.