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