| 1 | # -*- coding: utf-8 -*- |
---|
| 2 | # |
---|
| 3 | # Copyright (C) 2005-2007 Christopher Lenz <cmlenz@gmx.de> |
---|
| 4 | # Copyright (C) 2007-2010 Edgewall Software |
---|
| 5 | # All rights reserved. |
---|
| 6 | # |
---|
| 7 | # This software is licensed as described in the file COPYING, which |
---|
| 8 | # you should have received as part of this distribution. The terms |
---|
| 9 | # are also available at http://bitten.edgewall.org/wiki/License. |
---|
| 10 | |
---|
1 | 11 | """Implementation of the Bitten web interface.""" |
---|
| 12 | |
---|
1 | 13 | import posixpath |
---|
1 | 14 | import re |
---|
1 | 15 | import time |
---|
1 | 16 | from StringIO import StringIO |
---|
1 | 17 | from datetime import datetime |
---|
| 18 | |
---|
1 | 19 | import pkg_resources |
---|
1 | 20 | from genshi.builder import tag |
---|
1 | 21 | from trac.attachment import AttachmentModule, Attachment |
---|
1 | 22 | from trac.core import * |
---|
1 | 23 | from trac.config import Option |
---|
1 | 24 | from trac.mimeview.api import Context |
---|
1 | 25 | from trac.perm import PermissionError |
---|
1 | 26 | from trac.resource import Resource |
---|
1 | 27 | from trac.timeline import ITimelineEventProvider |
---|
1 | 28 | from trac.util import escape, pretty_timedelta, format_datetime, shorten_line, \ |
---|
1 | 29 | Markup, arity |
---|
1 | 30 | from trac.util.datefmt import to_timestamp, to_datetime, utc |
---|
1 | 31 | from trac.util.html import html |
---|
1 | 32 | from trac.web import IRequestHandler, IRequestFilter, HTTPNotFound |
---|
1 | 33 | from trac.web.chrome import INavigationContributor, ITemplateProvider, \ |
---|
1 | 34 | add_link, add_stylesheet, add_ctxtnav, \ |
---|
1 | 35 | prevnext_nav, add_script, add_warning |
---|
1 | 36 | from trac.versioncontrol import NoSuchChangeset, NoSuchNode |
---|
1 | 37 | from trac.wiki import wiki_to_html, wiki_to_oneliner |
---|
1 | 38 | from bitten.api import ILogFormatter, IReportChartGenerator, IReportSummarizer |
---|
1 | 39 | from bitten.master import BuildMaster |
---|
1 | 40 | from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \ |
---|
1 | 41 | BuildLog, Report |
---|
1 | 42 | from bitten.queue import collect_changes |
---|
1 | 43 | from bitten.util import json |
---|
| 44 | |
---|
1 | 45 | _status_label = {Build.PENDING: 'pending', |
---|
1 | 46 | Build.IN_PROGRESS: 'in progress', |
---|
1 | 47 | Build.SUCCESS: 'completed', |
---|
1 | 48 | Build.FAILURE: 'failed'} |
---|
1 | 49 | _status_title = {Build.PENDING: 'Pending', |
---|
1 | 50 | Build.IN_PROGRESS: 'In Progress', |
---|
1 | 51 | Build.SUCCESS: 'Success', |
---|
1 | 52 | Build.FAILURE: 'Failure'} |
---|
1 | 53 | _step_status_label = {BuildStep.SUCCESS: 'success', |
---|
1 | 54 | BuildStep.FAILURE: 'failed', |
---|
1 | 55 | BuildStep.IN_PROGRESS: 'in progress'} |
---|
| 56 | |
---|
1 | 57 | def _get_build_data(env, req, build): |
---|
1 | 58 | platform = TargetPlatform.fetch(env, build.platform) |
---|
1 | 59 | data = {'id': build.id, 'name': build.slave, 'rev': build.rev, |
---|
1 | 60 | 'status': _status_label[build.status], |
---|
1 | 61 | 'platform': getattr(platform, 'name', 'unknown'), |
---|
1 | 62 | 'cls': _status_label[build.status].replace(' ', '-'), |
---|
1 | 63 | 'href': req.href.build(build.config, build.id), |
---|
1 | 64 | 'chgset_href': req.href.changeset(build.rev)} |
---|
1 | 65 | if build.started: |
---|
0 | 66 | data['started'] = format_datetime(build.started) |
---|
0 | 67 | data['started_delta'] = pretty_timedelta(build.started) |
---|
0 | 68 | data['duration'] = pretty_timedelta(build.started) |
---|
1 | 69 | if build.stopped: |
---|
0 | 70 | data['stopped'] = format_datetime(build.stopped) |
---|
0 | 71 | data['stopped_delta'] = pretty_timedelta(build.stopped) |
---|
0 | 72 | data['duration'] = pretty_timedelta(build.stopped, build.started) |
---|
1 | 73 | data['slave'] = { |
---|
1 | 74 | 'name': build.slave, |
---|
1 | 75 | 'ipnr': build.slave_info.get(Build.IP_ADDRESS), |
---|
1 | 76 | 'os_name': build.slave_info.get(Build.OS_NAME), |
---|
1 | 77 | 'os_family': build.slave_info.get(Build.OS_FAMILY), |
---|
1 | 78 | 'os_version': build.slave_info.get(Build.OS_VERSION), |
---|
1 | 79 | 'machine': build.slave_info.get(Build.MACHINE), |
---|
1 | 80 | 'processor': build.slave_info.get(Build.PROCESSOR) |
---|
1 | 81 | } |
---|
1 | 82 | return data |
---|
| 83 | |
---|
1 | 84 | def _has_permission(perm, repos, path, rev=None, raise_error=False): |
---|
4 | 85 | if hasattr(repos, 'authz'): |
---|
4 | 86 | if not repos.authz.has_permission(path): |
---|
0 | 87 | if not raise_error: |
---|
0 | 88 | return False |
---|
0 | 89 | repos.authz.assert_permission(path) |
---|
0 | 90 | else: |
---|
0 | 91 | node = repos.get_node(path, rev) |
---|
0 | 92 | if not node.can_view(perm): |
---|
0 | 93 | if not raise_error: |
---|
0 | 94 | return False |
---|
0 | 95 | raise PermissionError('BROWSER_VIEW', node.resource) |
---|
4 | 96 | return True |
---|
| 97 | |
---|
2 | 98 | class BittenChrome(Component): |
---|
1 | 99 | """Provides the Bitten templates and static resources.""" |
---|
| 100 | |
---|
1 | 101 | implements(INavigationContributor, ITemplateProvider) |
---|
| 102 | |
---|
| 103 | # INavigationContributor methods |
---|
| 104 | |
---|
1 | 105 | def get_active_navigation_item(self, req): |
---|
0 | 106 | pass |
---|
| 107 | |
---|
1 | 108 | def get_navigation_items(self, req): |
---|
| 109 | """Return the navigation item for access the build status overview from |
---|
| 110 | the Trac navigation bar.""" |
---|
0 | 111 | if 'BUILD_VIEW' in req.perm: |
---|
0 | 112 | status = '' |
---|
0 | 113 | if BuildMaster(self.env).quick_status: |
---|
0 | 114 | repos = self.env.get_repository(authname=req.authname) |
---|
0 | 115 | assert repos, 'No "(default)" Repository: Add a repository ' \ |
---|
0 | 116 | 'or alias named "(default)" to Trac.' |
---|
0 | 117 | for config in BuildConfig.select(self.env, |
---|
0 | 118 | include_inactive=False): |
---|
0 | 119 | prev_rev = None |
---|
0 | 120 | for platform, rev, build in collect_changes(repos, config): |
---|
0 | 121 | if rev != prev_rev: |
---|
0 | 122 | if prev_rev is not None: |
---|
0 | 123 | break |
---|
0 | 124 | prev_rev = rev |
---|
0 | 125 | if build: |
---|
0 | 126 | build_data = _get_build_data(self.env, req, build) |
---|
0 | 127 | if build_data['status'] == 'failed': |
---|
0 | 128 | status='bittenfailed' |
---|
0 | 129 | break |
---|
0 | 130 | if build_data['status'] == 'in progress': |
---|
0 | 131 | status='bitteninprogress' |
---|
0 | 132 | elif not status: |
---|
0 | 133 | if (build_data['status'] == 'completed'): |
---|
0 | 134 | status='bittencompleted' |
---|
0 | 135 | if not status: |
---|
0 | 136 | status='bittenpending' |
---|
0 | 137 | yield ('mainnav', 'build', |
---|
0 | 138 | tag.a('Build Status', href=req.href.build(), accesskey=5, |
---|
0 | 139 | class_=status)) |
---|
| 140 | |
---|
| 141 | # ITemplatesProvider methods |
---|
| 142 | |
---|
1 | 143 | def get_htdocs_dirs(self): |
---|
| 144 | """Return the directories containing static resources.""" |
---|
0 | 145 | return [('bitten', pkg_resources.resource_filename(__name__, 'htdocs'))] |
---|
| 146 | |
---|
1 | 147 | def get_templates_dirs(self): |
---|
| 148 | """Return the directories containing templates.""" |
---|
2 | 149 | return [pkg_resources.resource_filename(__name__, 'templates')] |
---|
| 150 | |
---|
| 151 | |
---|
2 | 152 | class BuildConfigController(Component): |
---|
1 | 153 | """Implements the web interface for build configurations.""" |
---|
| 154 | |
---|
1 | 155 | implements(IRequestHandler, IRequestFilter, INavigationContributor) |
---|
| 156 | |
---|
| 157 | # Configuration options |
---|
| 158 | |
---|
1 | 159 | chart_style = Option('bitten', 'chart_style', 'height: 220px; width: 220px;', doc= |
---|
1 | 160 | """Style attribute for charts. Mostly useful for setting the height and width.""") |
---|
| 161 | |
---|
| 162 | # INavigationContributor methods |
---|
| 163 | |
---|
1 | 164 | def get_active_navigation_item(self, req): |
---|
0 | 165 | return 'build' |
---|
| 166 | |
---|
1 | 167 | def get_navigation_items(self, req): |
---|
0 | 168 | return [] |
---|
| 169 | |
---|
| 170 | # IRequestHandler methods |
---|
| 171 | |
---|
1 | 172 | def match_request(self, req): |
---|
5 | 173 | match = re.match(r'/build(?:/([\w.-]+))?/?$', req.path_info) |
---|
5 | 174 | if match: |
---|
5 | 175 | if match.group(1): |
---|
4 | 176 | req.args['config'] = match.group(1) |
---|
5 | 177 | return True |
---|
| 178 | |
---|
1 | 179 | def process_request(self, req): |
---|
5 | 180 | req.perm.require('BUILD_VIEW') |
---|
| 181 | |
---|
5 | 182 | action = req.args.get('action') |
---|
5 | 183 | view = req.args.get('view') |
---|
5 | 184 | config = req.args.get('config') |
---|
| 185 | |
---|
5 | 186 | if config: |
---|
4 | 187 | data = self._render_config(req, config) |
---|
1 | 188 | elif view == 'inprogress': |
---|
0 | 189 | data = self._render_inprogress(req) |
---|
0 | 190 | else: |
---|
1 | 191 | data = self._render_overview(req) |
---|
| 192 | |
---|
4 | 193 | add_stylesheet(req, 'bitten/bitten.css') |
---|
4 | 194 | return 'bitten_config.html', data, None |
---|
| 195 | |
---|
| 196 | # IRequestHandler methods |
---|
| 197 | |
---|
1 | 198 | def pre_process_request(self, req, handler): |
---|
0 | 199 | return handler |
---|
| 200 | |
---|
1 | 201 | def post_process_request(self, req, template, data, content_type): |
---|
0 | 202 | if template: |
---|
0 | 203 | add_stylesheet(req, 'bitten/bitten.css') |
---|
| 204 | |
---|
0 | 205 | return template, data, content_type |
---|
| 206 | |
---|
| 207 | # Internal methods |
---|
| 208 | |
---|
1 | 209 | def _render_overview(self, req): |
---|
1 | 210 | data = {'title': 'Build Status'} |
---|
1 | 211 | show_all = False |
---|
1 | 212 | if req.args.get('show') == 'all': |
---|
0 | 213 | show_all = True |
---|
1 | 214 | data['show_all'] = show_all |
---|
| 215 | |
---|
1 | 216 | repos = self.env.get_repository(authname=req.authname) |
---|
1 | 217 | assert repos, 'No "(default)" Repository: Add a repository or alias ' \ |
---|
1 | 218 | 'named "(default)" to Trac.' |
---|
| 219 | |
---|
1 | 220 | configs = [] |
---|
1 | 221 | for config in BuildConfig.select(self.env, include_inactive=show_all): |
---|
0 | 222 | rev = config.max_rev or repos.youngest_rev |
---|
0 | 223 | try: |
---|
0 | 224 | if not _has_permission(req.perm, repos, config.path, rev=rev): |
---|
0 | 225 | continue |
---|
0 | 226 | except NoSuchNode: |
---|
0 | 227 | add_warning(req, "Configuration '%s' points to non-existing " |
---|
0 | 228 | "path '/%s' at revision '%s'. Configuration skipped." \ |
---|
0 | 229 | % (config.name, config.path, rev)) |
---|
0 | 230 | continue |
---|
| 231 | |
---|
0 | 232 | description = config.description |
---|
0 | 233 | if description: |
---|
0 | 234 | description = wiki_to_html(description, self.env, req) |
---|
| 235 | |
---|
0 | 236 | platforms_data = [] |
---|
0 | 237 | for platform in TargetPlatform.select(self.env, config=config.name): |
---|
0 | 238 | pd = { 'name': platform.name, |
---|
0 | 239 | 'id': platform.id, |
---|
0 | 240 | 'builds_pending': len(list(Build.select(self.env, |
---|
0 | 241 | config=config.name, status=Build.PENDING, |
---|
0 | 242 | platform=platform.id))), |
---|
0 | 243 | 'builds_inprogress': len(list(Build.select(self.env, |
---|
0 | 244 | config=config.name, status=Build.IN_PROGRESS, |
---|
0 | 245 | platform=platform.id))) |
---|
0 | 246 | } |
---|
0 | 247 | platforms_data.append(pd) |
---|
| 248 | |
---|
0 | 249 | config_data = { |
---|
0 | 250 | 'name': config.name, 'label': config.label or config.name, |
---|
0 | 251 | 'active': config.active, 'path': config.path, |
---|
0 | 252 | 'description': description, |
---|
0 | 253 | 'builds_pending' : len(list(Build.select(self.env, |
---|
0 | 254 | config=config.name, |
---|
0 | 255 | status=Build.PENDING))), |
---|
0 | 256 | 'builds_inprogress' : len(list(Build.select(self.env, |
---|
0 | 257 | config=config.name, |
---|
0 | 258 | status=Build.IN_PROGRESS))), |
---|
0 | 259 | 'href': req.href.build(config.name), |
---|
0 | 260 | 'builds': [], |
---|
0 | 261 | 'platforms': platforms_data |
---|
0 | 262 | } |
---|
0 | 263 | configs.append(config_data) |
---|
0 | 264 | if not config.active: |
---|
0 | 265 | continue |
---|
| 266 | |
---|
0 | 267 | prev_rev = None |
---|
0 | 268 | for platform, rev, build in collect_changes(repos, config): |
---|
0 | 269 | if rev != prev_rev: |
---|
0 | 270 | if prev_rev is None: |
---|
0 | 271 | chgset = repos.get_changeset(rev) |
---|
0 | 272 | config_data['youngest_rev'] = { |
---|
0 | 273 | 'id': rev, 'href': req.href.changeset(rev), |
---|
0 | 274 | 'display_rev': repos.normalize_rev(rev), |
---|
0 | 275 | 'author': chgset.author or 'anonymous', |
---|
0 | 276 | 'date': format_datetime(chgset.date), |
---|
0 | 277 | 'message': wiki_to_oneliner( |
---|
0 | 278 | shorten_line(chgset.message), self.env, req=req) |
---|
0 | 279 | } |
---|
0 | 280 | else: |
---|
0 | 281 | break |
---|
0 | 282 | prev_rev = rev |
---|
0 | 283 | if build: |
---|
0 | 284 | build_data = _get_build_data(self.env, req, build) |
---|
0 | 285 | build_data['platform'] = platform.name |
---|
0 | 286 | config_data['builds'].append(build_data) |
---|
0 | 287 | else: |
---|
0 | 288 | config_data['builds'].append({ |
---|
0 | 289 | 'platform': platform.name, 'status': 'pending' |
---|
0 | 290 | }) |
---|
| 291 | |
---|
1 | 292 | data['configs'] = sorted(configs, key=lambda x:x['label'].lower()) |
---|
1 | 293 | data['page_mode'] = 'overview' |
---|
| 294 | |
---|
1 | 295 | in_progress_builds = Build.select(self.env, status=Build.IN_PROGRESS) |
---|
1 | 296 | pending_builds = Build.select(self.env, status=Build.PENDING) |
---|
| 297 | |
---|
1 | 298 | data['builds_pending'] = len(list(pending_builds)) |
---|
1 | 299 | data['builds_inprogress'] = len(list(in_progress_builds)) |
---|
| 300 | |
---|
1 | 301 | add_link(req, 'views', req.href.build(view='inprogress'), |
---|
1 | 302 | 'In Progress Builds') |
---|
1 | 303 | add_ctxtnav(req, 'In Progress Builds', |
---|
1 | 304 | req.href.build(view='inprogress')) |
---|
1 | 305 | return data |
---|
| 306 | |
---|
1 | 307 | def _render_inprogress(self, req): |
---|
0 | 308 | data = {'title': 'In Progress Builds', |
---|
0 | 309 | 'page_mode': 'view-inprogress'} |
---|
| 310 | |
---|
0 | 311 | db = self.env.get_db_cnx() |
---|
| 312 | |
---|
0 | 313 | repos = self.env.get_repository(authname=req.authname) |
---|
0 | 314 | assert repos, 'No "(default)" Repository: Add a repository or alias ' \ |
---|
0 | 315 | 'named "(default)" to Trac.' |
---|
| 316 | |
---|
0 | 317 | configs = [] |
---|
0 | 318 | for config in BuildConfig.select(self.env, include_inactive=False): |
---|
0 | 319 | rev = config.max_rev or repos.youngest_rev |
---|
0 | 320 | try: |
---|
0 | 321 | if not _has_permission(req.perm, repos, config.path, rev=rev): |
---|
0 | 322 | continue |
---|
0 | 323 | except NoSuchNode: |
---|
0 | 324 | add_warning(req, "Configuration '%s' points to non-existing " |
---|
0 | 325 | "path '/%s' at revision '%s'. Configuration skipped." \ |
---|
0 | 326 | % (config.name, config.path, rev)) |
---|
0 | 327 | continue |
---|
| 328 | |
---|
0 | 329 | self.log.debug(config.name) |
---|
0 | 330 | if not config.active: |
---|
0 | 331 | continue |
---|
| 332 | |
---|
0 | 333 | in_progress_builds = Build.select(self.env, config=config.name, |
---|
0 | 334 | status=Build.IN_PROGRESS, db=db) |
---|
| 335 | |
---|
0 | 336 | current_builds = 0 |
---|
0 | 337 | builds = [] |
---|
| 338 | # sort correctly by revision. |
---|
0 | 339 | for build in sorted(in_progress_builds, |
---|
0 | 340 | cmp=lambda x, y: int(y.rev_time) - int(x.rev_time)): |
---|
0 | 341 | rev = build.rev |
---|
0 | 342 | build_data = _get_build_data(self.env, req, build) |
---|
0 | 343 | build_data['rev'] = rev |
---|
0 | 344 | build_data['rev_href'] = req.href.changeset(rev) |
---|
0 | 345 | platform = TargetPlatform.fetch(self.env, build.platform) |
---|
0 | 346 | build_data['platform'] = platform.name |
---|
0 | 347 | build_data['steps'] = [] |
---|
| 348 | |
---|
0 | 349 | for step in BuildStep.select(self.env, build=build.id, db=db): |
---|
0 | 350 | build_data['steps'].append({ |
---|
0 | 351 | 'name': step.name, |
---|
0 | 352 | 'description': step.description, |
---|
0 | 353 | 'duration': to_datetime(step.stopped or int(time.time()), utc) - \ |
---|
0 | 354 | to_datetime(step.started, utc), |
---|
0 | 355 | 'status': _step_status_label[step.status], |
---|
0 | 356 | 'cls': _step_status_label[step.status].replace(' ', '-'), |
---|
0 | 357 | 'errors': step.errors, |
---|
0 | 358 | 'href': build_data['href'] + '#step_' + step.name |
---|
0 | 359 | }) |
---|
| 360 | |
---|
0 | 361 | builds.append(build_data) |
---|
0 | 362 | current_builds += 1 |
---|
| 363 | |
---|
0 | 364 | if current_builds == 0: |
---|
0 | 365 | continue |
---|
| 366 | |
---|
0 | 367 | description = config.description |
---|
0 | 368 | if description: |
---|
0 | 369 | description = wiki_to_html(description, self.env, req) |
---|
0 | 370 | configs.append({ |
---|
0 | 371 | 'name': config.name, 'label': config.label or config.name, |
---|
0 | 372 | 'active': config.active, 'path': config.path, |
---|
0 | 373 | 'description': description, |
---|
0 | 374 | 'href': req.href.build(config.name), |
---|
0 | 375 | 'builds': builds |
---|
0 | 376 | }) |
---|
| 377 | |
---|
0 | 378 | data['configs'] = sorted(configs, key=lambda x:x['label'].lower()) |
---|
0 | 379 | return data |
---|
| 380 | |
---|
1 | 381 | def _render_config(self, req, config_name): |
---|
4 | 382 | db = self.env.get_db_cnx() |
---|
| 383 | |
---|
4 | 384 | config = BuildConfig.fetch(self.env, config_name, db=db) |
---|
4 | 385 | if not config: |
---|
1 | 386 | raise HTTPNotFound("Build configuration '%s' does not exist." \ |
---|
1 | 387 | % config_name) |
---|
| 388 | |
---|
3 | 389 | repos = self.env.get_repository(authname=req.authname) |
---|
3 | 390 | assert repos, 'No "(default)" Repository: Add a repository or alias ' \ |
---|
3 | 391 | 'named "(default)" to Trac.' |
---|
3 | 392 | rev = config.max_rev or repos.youngest_rev |
---|
3 | 393 | try: |
---|
3 | 394 | _has_permission(req.perm, repos, config.path, rev=rev, |
---|
3 | 395 | raise_error=True) |
---|
0 | 396 | except NoSuchNode: |
---|
0 | 397 | raise TracError("Permission checking against repository path %s " |
---|
0 | 398 | "at revision %s failed." % (config.path, rev)) |
---|
| 399 | |
---|
3 | 400 | data = {'title': 'Build Configuration "%s"' \ |
---|
3 | 401 | % config.label or config.name, |
---|
3 | 402 | 'page_mode': 'view_config'} |
---|
3 | 403 | add_link(req, 'up', req.href.build(), 'Build Status') |
---|
3 | 404 | description = config.description |
---|
3 | 405 | if description: |
---|
0 | 406 | description = wiki_to_html(description, self.env, req) |
---|
| 407 | |
---|
3 | 408 | pending_builds = list(Build.select(self.env, |
---|
3 | 409 | config=config.name, status=Build.PENDING)) |
---|
3 | 410 | inprogress_builds = list(Build.select(self.env, |
---|
3 | 411 | config=config.name, status=Build.IN_PROGRESS)) |
---|
| 412 | |
---|
3 | 413 | data['config'] = { |
---|
3 | 414 | 'name': config.name, 'label': config.label, 'path': config.path, |
---|
3 | 415 | 'min_rev': config.min_rev, |
---|
3 | 416 | 'min_rev_href': req.href.changeset(config.min_rev), |
---|
3 | 417 | 'max_rev': config.max_rev, |
---|
3 | 418 | 'max_rev_href': req.href.changeset(config.max_rev), |
---|
3 | 419 | 'active': config.active, 'description': description, |
---|
3 | 420 | 'browser_href': req.href.browser(config.path), |
---|
3 | 421 | 'builds_pending' : len(pending_builds), |
---|
3 | 422 | 'builds_inprogress' : len(inprogress_builds) |
---|
3 | 423 | } |
---|
| 424 | |
---|
3 | 425 | context = Context.from_request(req, config.resource) |
---|
3 | 426 | data['context'] = context |
---|
3 | 427 | data['config']['attachments'] = AttachmentModule(self.env).attachment_data(context) |
---|
| 428 | |
---|
3 | 429 | platforms = list(TargetPlatform.select(self.env, config=config_name, |
---|
3 | 430 | db=db)) |
---|
3 | 431 | data['config']['platforms'] = [ |
---|
3 | 432 | { 'name': platform.name, |
---|
3 | 433 | 'id': platform.id, |
---|
3 | 434 | 'builds_pending': len(list(Build.select(self.env, |
---|
3 | 435 | config=config.name, |
---|
3 | 436 | status=Build.PENDING, |
---|
3 | 437 | platform=platform.id))), |
---|
3 | 438 | 'builds_inprogress': len(list(Build.select(self.env, |
---|
3 | 439 | config=config.name, |
---|
3 | 440 | status=Build.IN_PROGRESS, |
---|
3 | 441 | platform=platform.id))) |
---|
3 | 442 | } |
---|
6 | 443 | for platform in platforms |
---|
6 | 444 | ] |
---|
| 445 | |
---|
3 | 446 | has_reports = False |
---|
3 | 447 | for report in Report.select(self.env, config=config.name, db=db): |
---|
0 | 448 | has_reports = True |
---|
0 | 449 | break |
---|
| 450 | |
---|
3 | 451 | if has_reports: |
---|
0 | 452 | chart_generators = [] |
---|
0 | 453 | report_categories = list(self._report_categories_for_config(config)) |
---|
0 | 454 | for generator in ReportChartController(self.env).generators: |
---|
0 | 455 | for category in generator.get_supported_categories(): |
---|
0 | 456 | if category in report_categories: |
---|
0 | 457 | chart_generators.append({ |
---|
0 | 458 | 'href': req.href.build(config.name, 'chart/' + category), |
---|
0 | 459 | 'category': category, |
---|
0 | 460 | 'style': self.config.get('bitten', 'chart_style'), |
---|
0 | 461 | }) |
---|
0 | 462 | data['config']['charts'] = chart_generators |
---|
| 463 | |
---|
3 | 464 | page = max(1, int(req.args.get('page', 1))) |
---|
3 | 465 | more = False |
---|
3 | 466 | data['page_number'] = page |
---|
| 467 | |
---|
3 | 468 | repos = self.env.get_repository(authname=req.authname) |
---|
3 | 469 | assert repos, 'No "(default)" Repository: Add a repository or alias ' \ |
---|
3 | 470 | 'named "(default)" to Trac.' |
---|
| 471 | |
---|
3 | 472 | builds_per_page = 12 * len(platforms) |
---|
3 | 473 | idx = 0 |
---|
3 | 474 | builds = {} |
---|
3 | 475 | revisions = [] |
---|
30 | 476 | for platform, rev, build in collect_changes(repos, config): |
---|
28 | 477 | if idx >= page * builds_per_page: |
---|
1 | 478 | more = True |
---|
1 | 479 | break |
---|
27 | 480 | elif idx >= (page - 1) * builds_per_page: |
---|
27 | 481 | if rev not in builds: |
---|
27 | 482 | revisions.append(rev) |
---|
27 | 483 | builds.setdefault(rev, {}) |
---|
27 | 484 | builds[rev].setdefault('href', req.href.changeset(rev)) |
---|
27 | 485 | builds[rev].setdefault('display_rev', repos.normalize_rev(rev)) |
---|
27 | 486 | if build and build.status != Build.PENDING: |
---|
0 | 487 | build_data = _get_build_data(self.env, req, build) |
---|
0 | 488 | build_data['steps'] = [] |
---|
0 | 489 | for step in BuildStep.select(self.env, build=build.id, |
---|
0 | 490 | db=db): |
---|
0 | 491 | build_data['steps'].append({ |
---|
0 | 492 | 'name': step.name, |
---|
0 | 493 | 'description': step.description, |
---|
0 | 494 | 'duration': to_datetime(step.stopped or int(time.time()), utc) - \ |
---|
0 | 495 | to_datetime(step.started, utc), |
---|
0 | 496 | 'status': _step_status_label[step.status], |
---|
0 | 497 | 'cls': _step_status_label[step.status].replace(' ', '-'), |
---|
| 498 | |
---|
0 | 499 | 'errors': step.errors, |
---|
0 | 500 | 'href': build_data['href'] + '#step_' + step.name |
---|
0 | 501 | }) |
---|
0 | 502 | builds[rev][platform.id] = build_data |
---|
27 | 503 | idx += 1 |
---|
3 | 504 | data['config']['builds'] = builds |
---|
3 | 505 | data['config']['revisions'] = revisions |
---|
| 506 | |
---|
3 | 507 | if page > 1: |
---|
0 | 508 | if page == 2: |
---|
0 | 509 | prev_href = req.href.build(config.name) |
---|
0 | 510 | else: |
---|
0 | 511 | prev_href = req.href.build(config.name, page=page - 1) |
---|
0 | 512 | add_link(req, 'prev', prev_href, 'Previous Page') |
---|
3 | 513 | if more: |
---|
1 | 514 | next_href = req.href.build(config.name, page=page + 1) |
---|
1 | 515 | add_link(req, 'next', next_href, 'Next Page') |
---|
3 | 516 | if arity(prevnext_nav) == 4: # Trac 0.12 compat, see #450 |
---|
3 | 517 | prevnext_nav(req, 'Previous Page', 'Next Page') |
---|
3 | 518 | else: |
---|
0 | 519 | prevnext_nav (req, 'Page') |
---|
3 | 520 | return data |
---|
| 521 | |
---|
1 | 522 | def _report_categories_for_config(self, config): |
---|
| 523 | """Yields the categories of reports that exist for active builds |
---|
| 524 | of this configuration. |
---|
| 525 | """ |
---|
| 526 | |
---|
0 | 527 | db = self.env.get_db_cnx() |
---|
0 | 528 | cursor = db.cursor() |
---|
| 529 | |
---|
0 | 530 | cursor.execute("""SELECT DISTINCT report.category as category |
---|
0 | 531 | FROM bitten_build AS build |
---|
0 | 532 | JOIN bitten_report AS report ON (report.build=build.id) |
---|
0 | 533 | WHERE build.config=%s AND build.rev_time >= %s AND build.rev_time <= %s""", |
---|
0 | 534 | (config.name, |
---|
0 | 535 | config.min_rev_time(self.env), |
---|
0 | 536 | config.max_rev_time(self.env))) |
---|
| 537 | |
---|
0 | 538 | for (category,) in cursor: |
---|
0 | 539 | yield category |
---|
| 540 | |
---|
| 541 | |
---|
2 | 542 | class BuildController(Component): |
---|
1 | 543 | """Renders the build page.""" |
---|
1 | 544 | implements(INavigationContributor, IRequestHandler, ITimelineEventProvider) |
---|
| 545 | |
---|
1 | 546 | log_formatters = ExtensionPoint(ILogFormatter) |
---|
1 | 547 | report_summarizers = ExtensionPoint(IReportSummarizer) |
---|
| 548 | |
---|
| 549 | # INavigationContributor methods |
---|
| 550 | |
---|
1 | 551 | def get_active_navigation_item(self, req): |
---|
0 | 552 | return 'build' |
---|
| 553 | |
---|
1 | 554 | def get_navigation_items(self, req): |
---|
0 | 555 | return [] |
---|
| 556 | |
---|
| 557 | # IRequestHandler methods |
---|
| 558 | |
---|
1 | 559 | def match_request(self, req): |
---|
2 | 560 | match = re.match(r'/build/([\w.-]+)/(\d+)', req.path_info) |
---|
2 | 561 | if match: |
---|
2 | 562 | if match.group(1): |
---|
2 | 563 | req.args['config'] = match.group(1) |
---|
2 | 564 | if match.group(2): |
---|
2 | 565 | req.args['id'] = match.group(2) |
---|
2 | 566 | return True |
---|
| 567 | |
---|
1 | 568 | def process_request(self, req): |
---|
2 | 569 | req.perm.require('BUILD_VIEW') |
---|
| 570 | |
---|
2 | 571 | db = self.env.get_db_cnx() |
---|
2 | 572 | build_id = int(req.args.get('id')) |
---|
2 | 573 | build = Build.fetch(self.env, build_id, db=db) |
---|
2 | 574 | if not build: |
---|
1 | 575 | raise HTTPNotFound("Build '%s' does not exist." \ |
---|
1 | 576 | % build_id) |
---|
| 577 | |
---|
1 | 578 | if req.method == 'POST': |
---|
0 | 579 | if req.args.get('action') == 'invalidate': |
---|
0 | 580 | self._do_invalidate(req, build, db) |
---|
0 | 581 | req.redirect(req.href.build(build.config, build.id)) |
---|
| 582 | |
---|
1 | 583 | add_link(req, 'up', req.href.build(build.config), |
---|
1 | 584 | 'Build Configuration') |
---|
1 | 585 | data = {'title': 'Build %s - %s' % (build_id, |
---|
1 | 586 | _status_title[build.status]), |
---|
1 | 587 | 'page_mode': 'view_build', |
---|
1 | 588 | 'build': {}} |
---|
1 | 589 | config = BuildConfig.fetch(self.env, build.config, db=db) |
---|
1 | 590 | data['build']['config'] = { |
---|
1 | 591 | 'name': config.label or config.name, |
---|
1 | 592 | 'href': req.href.build(config.name) |
---|
1 | 593 | } |
---|
| 594 | |
---|
1 | 595 | context = Context.from_request(req, build.resource) |
---|
1 | 596 | data['context'] = context |
---|
1 | 597 | data['build']['attachments'] = AttachmentModule(self.env).attachment_data(context) |
---|
| 598 | |
---|
1 | 599 | formatters = [] |
---|
2 | 600 | for formatter in self.log_formatters: |
---|
1 | 601 | formatters.append(formatter.get_formatter(req, build)) |
---|
| 602 | |
---|
1 | 603 | summarizers = {} # keyed by report type |
---|
4 | 604 | for summarizer in self.report_summarizers: |
---|
3 | 605 | categories = summarizer.get_supported_categories() |
---|
6 | 606 | summarizers.update(dict([(cat, summarizer) for cat in categories])) |
---|
| 607 | |
---|
1 | 608 | data['build'].update(_get_build_data(self.env, req, build)) |
---|
1 | 609 | steps = [] |
---|
1 | 610 | for step in BuildStep.select(self.env, build=build.id, db=db): |
---|
0 | 611 | steps.append({ |
---|
0 | 612 | 'name': step.name, 'description': step.description, |
---|
0 | 613 | 'duration': pretty_timedelta(step.started, step.stopped or int(time.time())), |
---|
0 | 614 | 'status': _step_status_label[step.status], |
---|
0 | 615 | 'cls': _step_status_label[step.status].replace(' ', '-'), |
---|
0 | 616 | 'errors': step.errors, |
---|
0 | 617 | 'log': self._render_log(req, build, formatters, step), |
---|
0 | 618 | 'reports': self._render_reports(req, config, build, summarizers, |
---|
0 | 619 | step) |
---|
0 | 620 | }) |
---|
1 | 621 | data['build']['steps'] = steps |
---|
1 | 622 | data['build']['can_delete'] = ('BUILD_DELETE' in req.perm \ |
---|
0 | 623 | and build.status != build.PENDING) |
---|
| 624 | |
---|
1 | 625 | repos = self.env.get_repository(authname=req.authname) |
---|
1 | 626 | assert repos, 'No "(default)" Repository: Add a repository or alias ' \ |
---|
1 | 627 | 'named "(default)" to Trac.' |
---|
1 | 628 | _has_permission(req.perm, repos, config.path, rev=build.rev, raise_error=True) |
---|
1 | 629 | chgset = repos.get_changeset(build.rev) |
---|
1 | 630 | data['build']['chgset_author'] = chgset.author |
---|
1 | 631 | data['build']['display_rev'] = repos.normalize_rev(build.rev) |
---|
| 632 | |
---|
1 | 633 | add_script(req, 'common/js/folding.js') |
---|
1 | 634 | add_script(req, 'bitten/tabset.js') |
---|
1 | 635 | add_script(req, 'bitten/jquery.flot.js') |
---|
1 | 636 | add_stylesheet(req, 'bitten/bitten.css') |
---|
1 | 637 | return 'bitten_build.html', data, None |
---|
| 638 | |
---|
| 639 | # ITimelineEventProvider methods |
---|
| 640 | |
---|
1 | 641 | def get_timeline_filters(self, req): |
---|
0 | 642 | if 'BUILD_VIEW' in req.perm: |
---|
0 | 643 | yield ('build', 'Builds') |
---|
| 644 | |
---|
1 | 645 | def get_timeline_events(self, req, start, stop, filters): |
---|
0 | 646 | if 'build' not in filters: |
---|
0 | 647 | return |
---|
| 648 | |
---|
| 649 | # Attachments (will be rendered by attachment module) |
---|
0 | 650 | for event in AttachmentModule(self.env).get_timeline_events( |
---|
0 | 651 | req, Resource('build'), start, stop): |
---|
0 | 652 | yield event |
---|
| 653 | |
---|
0 | 654 | start = to_timestamp(start) |
---|
0 | 655 | stop = to_timestamp(stop) |
---|
| 656 | |
---|
0 | 657 | add_stylesheet(req, 'bitten/bitten.css') |
---|
| 658 | |
---|
0 | 659 | db = self.env.get_db_cnx() |
---|
0 | 660 | cursor = db.cursor() |
---|
0 | 661 | cursor.execute("SELECT b.id,b.config,c.label,c.path, b.rev,p.name," |
---|
0 | 662 | "b.stopped,b.status FROM bitten_build AS b" |
---|
0 | 663 | " INNER JOIN bitten_config AS c ON (c.name=b.config) " |
---|
0 | 664 | " INNER JOIN bitten_platform AS p ON (p.id=b.platform) " |
---|
0 | 665 | "WHERE b.stopped>=%s AND b.stopped<=%s " |
---|
0 | 666 | "AND b.status IN (%s, %s) ORDER BY b.stopped", |
---|
0 | 667 | (start, stop, Build.SUCCESS, Build.FAILURE)) |
---|
| 668 | |
---|
0 | 669 | repos = self.env.get_repository(authname=req.authname) |
---|
0 | 670 | assert repos, 'No "(default)" Repository: Add a repository or alias ' \ |
---|
0 | 671 | 'named "(default)" to Trac.' |
---|
| 672 | |
---|
0 | 673 | event_kinds = {Build.SUCCESS: 'successbuild', |
---|
0 | 674 | Build.FAILURE: 'failedbuild'} |
---|
| 675 | |
---|
0 | 676 | for id_, config, label, path, rev, platform, stopped, status in cursor: |
---|
0 | 677 | if not _has_permission(req.perm, repos, path, rev=rev): |
---|
0 | 678 | continue |
---|
0 | 679 | errors = [] |
---|
0 | 680 | if status == Build.FAILURE: |
---|
0 | 681 | for step in BuildStep.select(self.env, build=id_, |
---|
0 | 682 | status=BuildStep.FAILURE, |
---|
0 | 683 | db=db): |
---|
0 | 684 | errors += [(step.name, error) for error |
---|
0 | 685 | in step.errors] |
---|
0 | 686 | display_rev = repos.normalize_rev(rev) |
---|
0 | 687 | yield (event_kinds[status], to_datetime(stopped, utc), None, |
---|
0 | 688 | (id_, config, label, display_rev, platform, status, |
---|
0 | 689 | errors)) |
---|
| 690 | |
---|
1 | 691 | def render_timeline_event(self, context, field, event): |
---|
0 | 692 | id_, config, label, rev, platform, status, errors = event[3] |
---|
| 693 | |
---|
0 | 694 | if field == 'url': |
---|
0 | 695 | return context.href.build(config, id_) |
---|
| 696 | |
---|
0 | 697 | elif field == 'title': |
---|
0 | 698 | return tag('Build of ', tag.em('%s [%s]' % (label, rev)), |
---|
0 | 699 | ' on %s %s' % (platform, _status_label[status])) |
---|
| 700 | |
---|
0 | 701 | elif field == 'description': |
---|
0 | 702 | message = '' |
---|
0 | 703 | if context.req.args.get('format') == 'rss': |
---|
0 | 704 | if errors: |
---|
0 | 705 | buf = StringIO() |
---|
0 | 706 | prev_step = None |
---|
0 | 707 | for step, error in errors: |
---|
0 | 708 | if step != prev_step: |
---|
0 | 709 | if prev_step is not None: |
---|
0 | 710 | buf.write('</ul>') |
---|
0 | 711 | buf.write('<p>Step %s failed:</p><ul>' \ |
---|
0 | 712 | % escape(step)) |
---|
0 | 713 | prev_step = step |
---|
0 | 714 | buf.write('<li>%s</li>' % escape(error)) |
---|
0 | 715 | buf.write('</ul>') |
---|
0 | 716 | message = Markup(buf.getvalue()) |
---|
0 | 717 | else: |
---|
0 | 718 | if errors: |
---|
0 | 719 | steps = [] |
---|
0 | 720 | for step, error in errors: |
---|
0 | 721 | if step not in steps: |
---|
0 | 722 | steps.append(step) |
---|
0 | 723 | steps = [Markup('<em>%s</em>') % step for step in steps] |
---|
0 | 724 | if len(steps) < 2: |
---|
0 | 725 | message = steps[0] |
---|
0 | 726 | elif len(steps) == 2: |
---|
0 | 727 | message = Markup(' and ').join(steps) |
---|
0 | 728 | elif len(steps) > 2: |
---|
0 | 729 | message = Markup(', ').join(steps[:-1]) + ', and ' + \ |
---|
0 | 730 | steps[-1] |
---|
0 | 731 | message = Markup('Step%s %s failed') % ( |
---|
0 | 732 | len(steps) != 1 and 's' or '', message) |
---|
0 | 733 | return message |
---|
| 734 | |
---|
| 735 | # Internal methods |
---|
| 736 | |
---|
1 | 737 | def _do_invalidate(self, req, build, db): |
---|
0 | 738 | self.log.info('Invalidating build %d', build.id) |
---|
| 739 | |
---|
0 | 740 | for step in BuildStep.select(self.env, build=build.id, db=db): |
---|
0 | 741 | step.delete(db=db) |
---|
| 742 | |
---|
0 | 743 | build.slave = None |
---|
0 | 744 | build.started = 0 |
---|
0 | 745 | build.stopped = 0 |
---|
0 | 746 | build.last_activity = 0 |
---|
0 | 747 | build.status = Build.PENDING |
---|
0 | 748 | build.slave_info = {} |
---|
0 | 749 | build.update() |
---|
| 750 | |
---|
0 | 751 | Attachment.delete_all(self.env, 'build', build.resource.id, db) |
---|
| 752 | |
---|
0 | 753 | db.commit() |
---|
| 754 | |
---|
0 | 755 | req.redirect(req.href.build(build.config)) |
---|
| 756 | |
---|
1 | 757 | def _render_log(self, req, build, formatters, step): |
---|
0 | 758 | items = [] |
---|
0 | 759 | for log in BuildLog.select(self.env, build=build.id, step=step.name): |
---|
0 | 760 | for level, message in log.messages: |
---|
0 | 761 | for format in formatters: |
---|
0 | 762 | message = format(step, log.generator, level, message) |
---|
0 | 763 | items.append({'level': level, 'message': message}) |
---|
0 | 764 | return items |
---|
| 765 | |
---|
1 | 766 | def _render_reports(self, req, config, build, summarizers, step): |
---|
0 | 767 | reports = [] |
---|
0 | 768 | for report in Report.select(self.env, build=build.id, step=step.name): |
---|
0 | 769 | summarizer = summarizers.get(report.category) |
---|
0 | 770 | if summarizer: |
---|
0 | 771 | tmpl, data = summarizer.render_summary(req, config, build, |
---|
0 | 772 | step, report.category) |
---|
0 | 773 | reports.append({'category': report.category, |
---|
0 | 774 | 'template': tmpl, 'data': data}) |
---|
0 | 775 | else: |
---|
0 | 776 | tmpl = data = None |
---|
0 | 777 | return reports |
---|
| 778 | |
---|
| 779 | |
---|
2 | 780 | class ReportChartController(Component): |
---|
1 | 781 | implements(IRequestHandler) |
---|
| 782 | |
---|
1 | 783 | generators = ExtensionPoint(IReportChartGenerator) |
---|
| 784 | |
---|
| 785 | # IRequestHandler methods |
---|
1 | 786 | def match_request(self, req): |
---|
0 | 787 | match = re.match(r'/build/([\w.-]+)/chart/(\w+)', req.path_info) |
---|
0 | 788 | if match: |
---|
0 | 789 | req.args['config'] = match.group(1) |
---|
0 | 790 | req.args['category'] = match.group(2) |
---|
0 | 791 | return True |
---|
| 792 | |
---|
1 | 793 | def process_request(self, req): |
---|
0 | 794 | category = req.args.get('category') |
---|
0 | 795 | config = BuildConfig.fetch(self.env, name=req.args.get('config')) |
---|
| 796 | |
---|
0 | 797 | for generator in self.generators: |
---|
0 | 798 | if category in generator.get_supported_categories(): |
---|
0 | 799 | tmpl, data = generator.generate_chart_data(req, config, |
---|
0 | 800 | category) |
---|
0 | 801 | break |
---|
0 | 802 | else: |
---|
0 | 803 | raise TracError('Unknown report category "%s"' % category) |
---|
| 804 | |
---|
0 | 805 | data['dumps'] = json.to_json |
---|
| 806 | |
---|
0 | 807 | return tmpl, data, 'text/plain' |
---|
| 808 | |
---|
| 809 | |
---|
2 | 810 | class SourceFileLinkFormatter(Component): |
---|
| 811 | """Detects references to files in the build log and renders them as links |
---|
| 812 | to the repository browser. |
---|
1 | 813 | """ |
---|
| 814 | |
---|
1 | 815 | implements(ILogFormatter) |
---|
| 816 | |
---|
1 | 817 | _fileref_re = re.compile(r'(?P<prefix>-[A-Za-z])?(?P<path>[\w.-]+(?:[\\/][\w.-]+)+)(?P<line>:\d+)?') |
---|
| 818 | |
---|
1 | 819 | def get_formatter(self, req, build): |
---|
| 820 | """Return the log message formatter function.""" |
---|
6 | 821 | config = BuildConfig.fetch(self.env, name=build.config) |
---|
6 | 822 | repos = self.env.get_repository(authname=req.authname) |
---|
6 | 823 | assert repos, 'No "(default)" Repository: Add a repository or alias ' \ |
---|
6 | 824 | 'named "(default)" to Trac.' |
---|
6 | 825 | href = req.href.browser |
---|
6 | 826 | cache = {} |
---|
| 827 | |
---|
6 | 828 | def _replace(m): |
---|
8 | 829 | filepath = posixpath.normpath(m.group('path').replace('\\', '/')) |
---|
8 | 830 | if not cache.get(filepath) is True: |
---|
8 | 831 | parts = filepath.split('/') |
---|
8 | 832 | path = '' |
---|
16 | 833 | for part in parts: |
---|
12 | 834 | path = posixpath.join(path, part) |
---|
12 | 835 | if path not in cache: |
---|
9 | 836 | try: |
---|
9 | 837 | full_path = posixpath.join(config.path, path) |
---|
9 | 838 | full_path = posixpath.normpath(full_path) |
---|
9 | 839 | if full_path.startswith(config.path + "/") \ |
---|
1 | 840 | or full_path == config.path: |
---|
8 | 841 | repos.get_node(full_path, |
---|
8 | 842 | build.rev) |
---|
6 | 843 | cache[path] = True |
---|
6 | 844 | else: |
---|
1 | 845 | cache[path] = False |
---|
2 | 846 | except TracError: |
---|
2 | 847 | cache[path] = False |
---|
12 | 848 | if cache[path] is False: |
---|
4 | 849 | return m.group(0) |
---|
4 | 850 | link = href(config.path, filepath) |
---|
4 | 851 | if m.group('line'): |
---|
2 | 852 | link += '#L' + m.group('line')[1:] |
---|
4 | 853 | return Markup(tag.a(m.group(0), href=link)) |
---|
| 854 | |
---|
6 | 855 | def _formatter(step, type, level, message): |
---|
7 | 856 | buf = [] |
---|
7 | 857 | offset = 0 |
---|
15 | 858 | for mo in self._fileref_re.finditer(message): |
---|
8 | 859 | start, end = mo.span() |
---|
8 | 860 | if start > offset: |
---|
8 | 861 | buf.append(message[offset:start]) |
---|
8 | 862 | buf.append(_replace(mo)) |
---|
8 | 863 | offset = end |
---|
7 | 864 | if offset < len(message): |
---|
6 | 865 | buf.append(message[offset:]) |
---|
7 | 866 | return Markup("").join(buf) |
---|
| 867 | |
---|
6 | 868 | return _formatter |
---|