[IMP] runbot: add a runbot team model

With the increasing usage of runbot to test various things and to take
care of random bugs in tests, the need of a team dashboard arose.

This commit adds a `runbot.team` model. Internal users can be
linked to the team. Module wildcards can be used to automatically assign
build errors to a team at 'build.error` creation.

Also, an upgrade exception can be assigned to a team in order to display
it on a dashboard.

A dashboard model is used to create custom dashboards on the team
frontend page. By default, a dashboard is meant to display a list of
failed builds. The failed builds are selected by specifying a project, a
trigger category (e.g. nightly), a config and a domain (which select
failed builds by default).

The dashboard can be customized by specifying a custom view.

Each created team has a frontend page that displays all the team
dashboards and the errors assigned to the team.

A few other improvement also come with this commit:

 * The cleaned error is now in a tab on the build error form
 * Known errors are displayed as "known" on the build log page
 * The build form shows the config used for the build
This commit is contained in:
Christophe Monniez 2021-10-01 16:31:54 +02:00 committed by xdo
parent 2bbe346e75
commit 89dcb52215
18 changed files with 569 additions and 125 deletions

View File

@ -43,6 +43,7 @@
'views/bundle_views.xml',
'views/commit_views.xml',
'views/config_views.xml',
'views/dashboard_views.xml',
'views/dockerfile_views.xml',
'views/error_log_views.xml',
'views/host_views.xml',

View File

@ -58,7 +58,6 @@ def route(routes, **kw):
response.qcontext['title'] = 'Runbot %s' % project.name or ''
response.qcontext['nb_build_errors'] = nb_build_errors
response.qcontext['nb_assigned_errors'] = nb_assigned_errors
return response
return response_wrap
return decorator
@ -353,8 +352,9 @@ class Runbot(Controller):
return request.render(view_id if view_id else "runbot.monitoring", qctx)
@route(['/runbot/errors',
'/runbot/errors/page/<int:page>'], type='http', auth='user', website=True, sitemap=False)
def build_errors(self, error_id=None, sort=None, page=1, limit=20, **kwargs):
'/runbot/errors/page/<int:page>'
], type='http', auth='user', website=True, sitemap=False)
def build_errors(self, sort=None, page=1, limit=20, **kwargs):
sort_order_choices = {
'last_seen_date desc': 'Last seen date: Newer First',
'last_seen_date asc': 'Last seen date: Older First',
@ -390,6 +390,24 @@ class Runbot(Controller):
}
return request.render('runbot.build_error', qctx)
@route(['/runbot/teams', '/runbot/teams/<model("runbot.team"):team>',], type='http', auth='user', website=True, sitemap=False)
def team_dashboards(self, team=None, hide_empty=False, **kwargs):
teams = request.env['runbot.team'].search([]) if not team else None
qctx = {
'team': team,
'teams': teams,
'hide_empty': bool(hide_empty),
}
return request.render('runbot.team', qctx)
@route(['/runbot/dashboards/<model("runbot.dashboard"):dashboard>',], type='http', auth='user', website=True, sitemap=False)
def dashboards(self, dashboard=None, hide_empty=False, **kwargs):
qctx = {
'dashboard': dashboard,
'hide_empty': bool(hide_empty),
}
return request.render('runbot.dashboard_page', qctx)
@route(['/runbot/build/stats/<int:build_id>'], type='http', auth="public", website=True, sitemap=False)
def build_stats(self, build_id, search=None, **post):
"""Build statistics"""

View File

@ -16,6 +16,7 @@ from . import ir_ui_view
from . import project
from . import repo
from . import res_config_settings
from . import res_users
from . import runbot
from . import upgrade
from . import user

View File

@ -18,6 +18,7 @@ class Batch(models.Model):
commit_link_ids = fields.Many2many('runbot.commit.link')
commit_ids = fields.Many2many('runbot.commit', compute='_compute_commit_ids')
slot_ids = fields.One2many('runbot.batch.slot', 'batch_id')
all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids', help="Recursive builds")
state = fields.Selection([('preparing', 'Preparing'), ('ready', 'Ready'), ('done', 'Done'), ('skipped', 'Skipped')])
hidden = fields.Boolean('Hidden', default=False)
age = fields.Integer(compute='_compute_age', string='Build age')
@ -25,6 +26,12 @@ class Batch(models.Model):
log_ids = fields.One2many('runbot.batch.log', 'batch_id')
has_warning = fields.Boolean("Has warning")
@api.depends('slot_ids.build_id')
def _compute_all_build_ids(self):
all_builds = self.env['runbot.build'].search([('id', 'child_of', self.slot_ids.build_id.ids)])
for batch in self:
batch.all_build_ids = all_builds.filtered_domain([('id', 'child_of', batch.slot_ids.build_id.ids)])
@api.depends('commit_link_ids')
def _compute_commit_ids(self):
for batch in self:
@ -403,6 +410,7 @@ class BatchSlot(models.Model):
batch_id = fields.Many2one('runbot.batch', index=True)
trigger_id = fields.Many2one('runbot.trigger', index=True)
build_id = fields.Many2one('runbot.build', index=True)
all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids')
params_id = fields.Many2one('runbot.build.params', index=True, required=True)
link_type = fields.Selection([('created', 'Build created'), ('matched', 'Existing build matched'), ('rebuild', 'Rebuild')], required=True) # rebuild type?
active = fields.Boolean('Attached', default=True)
@ -412,6 +420,12 @@ class BatchSlot(models.Model):
# - only available on batch and replace for batch only?
# - create a new bundle batch will new linked build?
@api.depends('build_id')
def _compute_all_build_ids(self):
all_builds = self.env['runbot.build'].search([('id', 'child_of', self.build_id.ids)])
for slot in self:
slot.all_build_ids = all_builds.filtered_domain([('id', 'child_of', slot.build_id.ids)])
def fa_link_type(self):
return self._fa_link_type.get(self.link_type, 'exclamation-triangle')

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
import ast
import hashlib
import logging
import re
from collections import defaultdict
from fnmatch import fnmatch
from odoo import models, fields, api
from odoo.exceptions import ValidationError
@ -22,13 +24,17 @@ class BuildError(models.Model):
cleaned_content = fields.Text('Cleaned error message')
summary = fields.Char('Content summary', compute='_compute_summary', store=False)
module_name = fields.Char('Module name') # name in ir_logging
file_path = fields.Char('File Path') # path in ir logging
function = fields.Char('Function name') # func name in ir logging
fingerprint = fields.Char('Error fingerprint', index=True)
random = fields.Boolean('underterministic error', tracking=True)
responsible = fields.Many2one('res.users', 'Assigned fixer', tracking=True)
team_id = fields.Many2one('runbot.team', 'Assigned team')
fixing_commit = fields.Char('Fixing commit', tracking=True)
fixing_pr_id = fields.Many2one('runbot.branch', 'Fixing PR', tracking=True)
build_ids = fields.Many2many('runbot.build', 'runbot_build_error_ids_runbot_build_rel', string='Affected builds')
bundle_ids = fields.One2many('runbot.bundle', compute='_compute_bundle_ids')
version_ids = fields.One2many('runbot.version', compute='_compute_version_ids', string='Versions', search='_search_version')
trigger_ids = fields.Many2many('runbot.trigger', compute='_compute_trigger_ids')
active = fields.Boolean('Error is not fixed', default=True, tracking=True)
tag_ids = fields.Many2many('runbot.build.error.tag', string='Tags')
@ -57,6 +63,8 @@ class BuildError(models.Model):
vals.update({'cleaned_content': cleaned_content,
'fingerprint': self._digest(cleaned_content)
})
if not 'team_id' in vals and 'module_name' in vals:
vals.update({'team_id': self.env['runbot.team']._get_team(vals['module_name'])})
return super().create(vals)
def write(self, vals):
@ -76,6 +84,11 @@ class BuildError(models.Model):
top_parent_builds = build_error.build_ids.mapped(lambda rec: rec and rec.top_parent)
build_error.bundle_ids = top_parent_builds.mapped('slot_ids').mapped('batch_id.bundle_id')
@api.depends('build_ids', 'child_ids.build_ids')
def _compute_version_ids(self):
for build_error in self:
build_error.version_ids = build_error.build_ids.version_id
@api.depends('build_ids')
def _compute_trigger_ids(self):
for build_error in self:
@ -143,6 +156,7 @@ class BuildError(models.Model):
build_errors |= self.env['runbot.build.error'].create({
'content': logs[0].message,
'module_name': logs[0].name,
'file_path': logs[0].path,
'function': logs[0].func,
'build_ids': [(6, False, [r.build_id.id for r in logs])],
})
@ -184,6 +198,9 @@ class BuildError(models.Model):
def disabling_tags(self):
return ['-%s' % tag for tag in self.test_tags_list()]
def _search_version(self, operator, value):
return [('build_ids.version_id', operator, value)]
class BuildErrorTag(models.Model):
@ -218,3 +235,98 @@ class ErrorRegex(models.Model):
if re.search(filter.regex, s):
return True
return False
class RunbotTeam(models.Model):
_name = 'runbot.team'
_description = "Runbot Team"
_order = 'name, id'
name = fields.Char('Team', required=True)
user_ids = fields.Many2many('res.users', string='Team Members', domain=[('share', '=', False)])
dashboard_id = fields.Many2one('runbot.dashboard', String='Dashboard')
build_error_ids = fields.One2many('runbot.build.error', 'team_id', string='Team Errors')
path_glob = fields.Char('Module Wildcards',
help='Comma separated list of `fnmatch` wildcards used to assign errors automaticaly\n'
'Negative wildcards starting with a `-` can be used to discard some path\n'
'e.g.: `*website*,-*website_sale*`')
upgrade_exception_ids = fields.One2many('runbot.upgrade.exception', 'team_id', string='Team Upgrade Exceptions')
@api.model_create_single
def create(self, values):
if 'dashboard_id' not in values or values['dashboard_id'] == False:
dashboard = self.env['runbot.dashboard'].search([('name', '=', values['name'])])
if not dashboard:
dashboard = dashboard.create({'name': values['name']})
values['dashboard_id'] = dashboard.id
return super().create(values)
@api.model
def _get_team(self, module_name):
for team in self.env['runbot.team'].search([('path_glob', '!=', False)]):
if any([fnmatch(module_name, pattern.strip().strip('-')) for pattern in team.path_glob.split(',') if pattern.strip().startswith('-')]):
continue
if any([fnmatch(module_name, pattern.strip()) for pattern in team.path_glob.split(',') if not pattern.strip().startswith('-')]):
return team.id
return False
class RunbotDashboard(models.Model):
_name = 'runbot.dashboard'
_description = "Runbot Dashboard"
_order = 'name, id'
name = fields.Char('Team', required=True)
team_ids = fields.One2many('runbot.team', 'dashboard_id', string='Teams')
dashboard_tile_ids = fields.Many2many('runbot.dashboard.tile', string='Dashboards tiles')
class RunbotDashboardTile(models.Model):
_name = 'runbot.dashboard.tile'
_description = "Runbot Dashboard Tile"
_order = 'sequence, id'
sequence = fields.Integer('Sequence')
name = fields.Char('Name')
dashboard_ids = fields.Many2many('runbot.dashboard', string='Dashboards')
display_name = fields.Char(compute='_compute_display_name')
project_id = fields.Many2one('runbot.project', 'Project', help='Project to monitor', required=True,
default=lambda self: self.env.ref('runbot.main_project'))
category_id = fields.Many2one('runbot.category', 'Category', help='Trigger Category to monitor', required=True,
default=lambda self: self.env.ref('runbot.default_category'))
trigger_id = fields.Many2one('runbot.trigger', 'Trigger', help='Trigger to monitor in chosen category')
config_id = fields.Many2one('runbot.build.config', 'Config', help='Select a sub_build with this config')
domain_filter = fields.Char('Domain Filter', help='If present, will be applied on builds', default="[('global_result', '=', 'ko')]")
custom_template_id = fields.Many2one('ir.ui.view', help='Change for a custom Dasbord card template',
domain=[('type', '=', 'qweb')], default=lambda self: self.env.ref('runbot.default_dashboard_tile_view'))
sticky_bundle_ids = fields.Many2many('runbot.bundle', compute='_compute_sticky_bundle_ids', string='Sticky Bundles')
build_ids = fields.Many2many('runbot.build', compute='_compute_build_ids', string='Builds')
@api.depends('project_id', 'category_id', 'trigger_id', 'config_id')
def _compute_display_name(self):
for board in self:
names = [board.project_id.name, board.category_id.name, board.trigger_id.name, board.config_id.name, board.name]
board.display_name = ' / '.join([n for n in names if n])
@api.depends('project_id')
def _compute_sticky_bundle_ids(self):
sticky_bundles = self.env['runbot.bundle'].search([('sticky', '=', True)])
for dashboard in self:
dashboard.sticky_bundle_ids = sticky_bundles.filtered(lambda b: b.project_id == dashboard.project_id)
@api.depends('project_id', 'category_id', 'trigger_id', 'config_id', 'domain_filter')
def _compute_build_ids(self):
for dashboard in self:
last_done_batch_ids = dashboard.sticky_bundle_ids.with_context(category_id=dashboard.category_id.id).last_done_batch
if dashboard.trigger_id:
all_build_ids = last_done_batch_ids.slot_ids.filtered(lambda s: s.trigger_id == dashboard.trigger_id).all_build_ids
else:
all_build_ids = last_done_batch_ids.all_build_ids
domain = ast.literal_eval(dashboard.domain_filter) if dashboard.domain_filter else []
if dashboard.config_id:
domain.append(('config_id', '=', dashboard.config_id.id))
dashboard.build_ids = all_build_ids.filtered_domain(domain)

View File

@ -2,6 +2,8 @@
import logging
from collections import defaultdict
from ..common import pseudo_markdown
from odoo import models, fields, tools
from odoo.exceptions import UserError
@ -19,6 +21,7 @@ class runbot_event(models.Model):
build_id = fields.Many2one('runbot.build', 'Build', index=True, ondelete='cascade')
active_step_id = fields.Many2one('runbot.build.config.step', 'Active step', index=True)
type = fields.Selection(selection_add=TYPES, string='Type', required=True, index=True)
error_id = fields.Many2one('runbot.build.error', compute='_compute_known_error') # remember to never store this field
def init(self):
parent_class = super(runbot_event, self)
@ -80,6 +83,17 @@ FOR EACH ROW EXECUTE PROCEDURE runbot_set_logging_build();
return pseudo_markdown(self.message)
def _compute_known_error(self):
cleaning_regexes = self.env['runbot.error.regex'].search([('re_type', '=', 'cleaning')])
fingerprints = defaultdict(list)
for ir_logging in self:
ir_logging.error_id = False
if ir_logging.level == 'ERROR' and ir_logging.type == 'server':
fingerprints[self.env['runbot.build.error']._digest(cleaning_regexes.r_sub('%', ir_logging.message))].append(ir_logging)
for build_error in self.env['runbot.build.error'].search([('fingerprint', 'in', list(fingerprints.keys()))]):
for ir_logging in fingerprints[build_error.fingerprint]:
ir_logging.error_id = build_error.id
class RunbotErrorLog(models.Model):
_name = 'runbot.error.log'
_description = "Error log"

View File

@ -0,0 +1,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
runbot_team_ids = fields.Many2many('runbot.team', string="Runbot Teams")

View File

@ -11,6 +11,7 @@ class UpgradeExceptions(models.Model):
elements = fields.Text('Elements')
bundle_id = fields.Many2one('runbot.bundle', index=True)
info = fields.Text('Info')
team_id = fields.Many2one('runbot.team', 'Assigned team', index=True)
def _generate(self):
exceptions = self.search([])

View File

@ -25,6 +25,12 @@ access_runbot_build_error_manager,runbot_build_error_manager,runbot.model_runbot
access_runbot_build_error_tag_user,runbot_build_error_tag_user,runbot.model_runbot_build_error_tag,group_user,1,0,0,0
access_runbot_build_error_tag_admin,runbot_build_error_tag_admin,runbot.model_runbot_build_error_tag,runbot.group_runbot_admin,1,1,1,1
access_runbot_build_error_tag_manager,runbot_build_error_tag_manager,runbot.model_runbot_build_error_tag,runbot.group_runbot_error_manager,1,1,1,1
access_runbot_team_admin,runbot_team_admin,runbot.model_runbot_team,runbot.group_runbot_admin,1,1,1,1
access_runbot_team_user,runbot_team_user,runbot.model_runbot_team,group_user,1,0,0,0
access_runbot_dashboard_admin,runbot_dashboard_admin,runbot.model_runbot_dashboard,runbot.group_runbot_admin,1,1,1,1
access_runbot_dashboard_user,runbot_dashboard_user,runbot.model_runbot_dashboard,group_user,1,0,0,0
access_runbot_dashboard_tile_admin,runbot_dashboard_tile_admin,runbot.model_runbot_dashboard_tile,runbot.group_runbot_admin,1,1,1,1
access_runbot_dashboard_tile_user,runbot_dashboard_tile_user,runbot.model_runbot_dashboard_tile,group_user,1,0,0,0
access_runbot_error_regex_user,runbot_error_regex_user,runbot.model_runbot_error_regex,group_user,1,0,0,0
access_runbot_error_regex_manager,runbot_error_regex_manager,runbot.model_runbot_error_regex,runbot.group_runbot_admin,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
25 access_runbot_host_user access_runbot_dashboard_admin runbot_host_user runbot_dashboard_admin runbot.model_runbot_host runbot.model_runbot_dashboard group_user runbot.group_runbot_admin 1 0 1 0 1 0 1
26 access_runbot_host_manager access_runbot_dashboard_user runbot_host_manager runbot_dashboard_user runbot.model_runbot_host runbot.model_runbot_dashboard runbot.group_runbot_admin group_user 1 1 0 1 0 1 0
27 access_runbot_error_log_user access_runbot_dashboard_tile_admin runbot_error_log_user runbot_dashboard_tile_admin runbot.model_runbot_error_log runbot.model_runbot_dashboard_tile group_user runbot.group_runbot_admin 1 0 1 0 1 0 1
28 access_runbot_dashboard_tile_user runbot_dashboard_tile_user runbot.model_runbot_dashboard_tile group_user 1 0 0 0
29 access_runbot_error_regex_user runbot_error_regex_user runbot.model_runbot_error_regex group_user 1 0 0 0
30 access_runbot_error_regex_manager runbot_error_regex_manager runbot.model_runbot_error_regex runbot.group_runbot_admin 1 1 1 1
31 access_runbot_host_user runbot_host_user runbot.model_runbot_host group_user 1 0 0 0
32 access_runbot_host_manager runbot_host_manager runbot.model_runbot_host runbot.group_runbot_admin 1 1 1 1
33 access_runbot_error_log_user runbot_error_log_user runbot.model_runbot_error_log group_user 1 0 0 0
34 access_runbot_error_log_manager runbot_error_log_manager runbot.model_runbot_error_log runbot.group_runbot_admin 1 1 1 1
35 access_runbot_repo_hooktime runbot_repo_hooktime runbot.model_runbot_repo_hooktime group_user 1 0 0 0
36 access_runbot_repo_referencetime runbot_repo_referencetime runbot.model_runbot_repo_reftime group_user 1 0 0 0

View File

@ -296,6 +296,17 @@
</t>
</td>
</tr>
<t t-if="l.error_id">
<tr>
<td></td><td></td><td></td>
<td class="bg-info-light">
This error already is known.
<a groups="runbot.group_build_config_user" t-attf-href="/web#id={{l.error_id.id}}&amp;view_type=form&amp;model=runbot.build.error" title="View in Backend" target="new">
<i class="fa fa-list"/>
</a>
</td>
</tr>
</t>
</t>
</table>
</div>

View File

@ -1,140 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="runbot.build_error_cards">
<div t-if="build_errors" class="accordion" t-attf-id="accordion_{{accordion_id}}">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col">Last seen date</div>
<div class="col col-md-3">Module</div>
<div class="col col-md-3">Summary</div>
<div class="col">Nb Seen</div>
<div class="col">Random</div>
<div class="col">Assigned to</div>
<div class="col">&amp;nbsp;</div>
</div>
</div>
</div>
<t t-foreach="build_errors" t-as="build_error">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col"><t t-esc="build_error.last_seen_date" t-options='{"widget": "datetime"}'/></div>
<div class="col col-md-3"><t t-esc="build_error.module_name"/></div>
<div class="col col-md-3">
<button class="btn btn-link" type="button" data-toggle="collapse" t-attf-data-target="#collapse{{build_error.id}}" aria-expanded="true" aria-controls="collapseOne">
<i class="fa fa-minus"/>
</button>
<code><t t-esc="build_error.summary"/></code>
</div>
<div class="col">
<t t-esc="build_error.build_count"/>
</div>
<div class="col">
<i t-if="build_error.random" class="fa fa-random"/>
</div>
<div class="col"><t t-esc="build_error.responsible.name"/></div>
<div class="col">
<a groups="base.group_user" t-attf-href="/web/#id={{build_error.id}}&amp;view_type=form&amp;model=runbot.build.error" target="new" title="View in Backend">
<i class="fa fa-list"/>
</a>
<a t-att-href="build_error.last_seen_build_id.build_url" t-attf-title="View last affected build ({{build_error.last_seen_build_id.id}})"><i class="fa fa-external-link"/></a>
</div>
</div>
</div>
<div t-attf-id="collapse{{build_error.id}}" class="collapse" aria-labelledby="headingOne" t-attf-data-parent="#accordion_{{accordion_id}}">
<div class="card-body">
<pre class="pre-scrollable bg-danger-light"><t t-esc="build_error.content.strip()" /></pre>
</div>
</div>
</div>
</t>
</div>
</template>
<template id="runbot.build_error">
<t t-call='website.layout'>
<div class="container-fluid">
<div class="row">
<div class='col-md-12'>
<t t-if="current_user_errors">
<h3>Your assigned bugs Bugs on Runbot Builds</h3>
<div class="accordion" id="userErrorAccordion">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col">Last seen date</div>
<div class="col col-md-3">Module</div>
<div class="col col-md-3">Summary</div>
<div class="col">Nb Seen</div>
<div class="col">Random</div>
<div class="col">Assigned to</div>
<div class="col">&amp;nbsp;</div>
</div>
</div>
</div>
<t t-foreach="current_user_errors" t-as="build_error">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col"><t t-esc="build_error.last_seen_date" t-options='{"widget": "datetime"}'/></div>
<div class="col col-md-3"><t t-esc="build_error.module_name"/></div>
<div class="col col-md-3">
<button class="btn btn-link" type="button" data-toggle="collapse" t-attf-data-target="#collapse{{build_error.id}}" aria-expanded="true" aria-controls="collapseOne">
<i class="fa fa-minus"/>
</button>
<code><t t-esc="build_error.summary"/></code>
</div>
<div class="col">
<t t-esc="build_error.build_count"/>
</div>
<div class="col">
<i t-if="build_error.random" class="fa fa-random"/>
</div>
<div class="col"><t t-esc="build_error.responsible.name"/></div>
<div class="col">
<a groups="base.group_user" t-attf-href="/web/#id={{build_error.id}}&amp;view_type=form&amp;model=runbot.build.error" target="new" title="View in Backend">
<i class="fa fa-list"/>
</a>
<a t-att-href="build_error.last_seen_build_id.build_url" t-attf-title="View last affected build ({{build_error.last_seen_build_id.id}})"><i class="fa fa-external-link"/></a>
</div>
</div>
</div>
<div t-attf-id="collapse{{build_error.id}}" class="collapse" aria-labelledby="headingOne" data-parent="#userErrorAccordion">
<div class="card-body">
<pre class="pre-scrollable">
<code><t t-esc="build_error.content.strip()" /></code>
</pre>
</div>
</div>
</div>
</t>
</div>
<h3>Your assigned bug on Runbot Builds</h3>
<t t-call="runbot.build_error_cards">
<t t-set="build_errors" t-value="current_user_errors"/>
<t t-set="accordion_id">user_errors</t>
</t>
<h3>Current Bugs on Runbot Builds</h3>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="dropdown mr-auto">
<a role="button" href="#" class="dropdown-toggle btn btn-secondary" data-toggle="dropdown">
Sort By: <t t-esc="request.params.get('sort', '')"/>
</a>
<div class="dropdown-menu" aria-labelledby="sortMenuButton" role="menu">
<t t-foreach="sort_order_choices" t-as="sort_choice">
<a role="menuitem" class="dropdown-item" t-attf-href="/runbot/errors?sort={{sort_choice}}"><t t-esc="sort_order_choices[sort_choice]"/></a>
</t>
<t t-if="build_errors">
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="dropdown mr-auto">
<a role="button" href="#" class="dropdown-toggle btn btn-secondary" data-toggle="dropdown">
Sort By: <t t-esc="request.params.get('sort', '')"/>
</a>
<div class="dropdown-menu" aria-labelledby="sortMenuButton" role="menu">
<t t-foreach="sort_order_choices" t-as="sort_choice">
<a role="menuitem" class="dropdown-item" t-attf-href="/runbot/errors?sort={{sort_choice}}"><t t-esc="sort_order_choices[sort_choice]"/></a>
</t>
</div>
</div>
</div>
<span class="ml-auto">
<t t-call="website.pager" />
</span>
</nav>
</div>
<div class="accordion" id="errorAccordion">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col">Last seen date</div>
<div class="col col-md-3">Module</div>
<div class="col col-md-3">Summary</div>
<div class="col">Nb Seen</div>
<div class="col">Random</div>
<div class="col">Assigned to</div>
<div class="col">&amp;nbsp;</div>
</div>
</div>
<span class="ml-auto">
<t t-call="website.pager" />
</span>
</nav>
</div>
<t t-foreach="build_errors" t-as="build_error">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col"><t t-esc="build_error.last_seen_date" t-options='{"widget": "datetime"}'/></div>
<div class="col col-md-3"><t t-esc="build_error.module_name"/></div>
<div class="col col-md-3">
<button class="btn btn-link" type="button" data-toggle="collapse" t-attf-data-target="#collapse{{build_error.id}}" aria-expanded="true" aria-controls="collapseOne">
<i class="fa fa-minus"/>
</button>
<code><t t-esc="build_error.summary"/></code>
</div>
<div class="col">
<t t-esc="build_error.build_count"/>
</div>
<div class="col">
<i t-if="build_error.random" class="fa fa-random"/>
</div>
<div class="col"><t t-esc="build_error.responsible.name"/></div>
<div class="col">
<a groups="base.group_user" t-attf-href="/web/#id={{build_error.id}}&amp;view_type=form&amp;model=runbot.build.error" target="new" title="View in Backend">
<i class="fa fa-list"/>
</a>
<a t-att-href="build_error.last_seen_build_id.build_url" t-attf-title="View last affected build ({{build_error.last_seen_build_id.id}})"><i class="fa fa-external-link"/></a>
</div>
</div>
</div>
<t t-call="runbot.build_error_cards">
<t t-set="build_errors" t-value="build_errors"/>
<t t-set="accordion_id">all_errors</t>
</t>
</t>
</div>
</div>
</div>
</t>
</template>
<div t-attf-id="collapse{{build_error.id}}" class="collapse" aria-labelledby="headingOne" data-parent="#errorAccordion">
<div class="card-body">
<pre class="pre-scrollable">
<code><t t-esc="build_error.content.strip()" /></code>
</pre>
</div>
</div>
</div>
<template id="runbot.team">
<t t-call='website.layout'>
<div class="container-fluid bg-light">
<div class="row">
<div t-if="team" class='col-md-12'>
<div class="col-lg-12 text-center mb16">
<h2>Team <t t-esc="team.name.capitalize()"/>
<a groups="base.group_user" t-attf-href="/web/#id={{team.id}}&amp;view_type=form&amp;model=runbot.team" target="new" title="View in Backend">
<i class="fa fa-list"/>
</a>
</h2>
</div>
<div t-if="team.dashboard_id">
<h3 t-if="team.dashboard_id.dashboard_tile_ids">Dashboards</h3>
<t t-call="runbot.dashboard">
<t t-set="dashboard" t-value="team.dashboard_id"/>
</t>
</div>
<h3 t-if="team.build_error_ids">Team assigned Errors</h3>
<t t-call="runbot.build_error_cards">
<t t-set="build_errors" t-value="team.build_error_ids"/>
<t t-set="accordion_id">team_errors</t>
</t>
</div>
<!-- Display list of teams of no team is supplied -->
<div t-if="not team" class='col-md-12'>
<h3> Teams</h3>
<div class="row">
<div class="list-group list-group-horizontal">
<t t-foreach="teams" t-as="team">
<a t-attf-href="/runbot/teams/{{ team.id }}" class="list-group-item list-group-item-action"><t t-esc="team.name"/></a>
</t>
</div>
</div>
</div>
</div>
</div>

View File

@ -183,5 +183,47 @@
</div>
</t>
</template>
<template id="runbot.default_dashboard_tile_view">
<div class="col-sm-3">
<div class="card">
<div class="card-header">
<t t-esc="tile.display_name"/>
</div>
<div class="card-body">
<p t-if="not tile.build_ids" class="text-success my-0">No build found 👍</p>
<t t-foreach="tile.sticky_bundle_ids.sorted(lambda b: b.version_id.number, reverse=True)" t-as="bundle">
<t t-set="failed_builds" t-value="tile.build_ids.filtered(lambda b: b.top_parent.slot_ids.batch_id.bundle_id == bundle)"/>
<h4 class="card-title" t-if="failed_builds" t-esc="bundle.name"/>
<p t-foreach="failed_builds" t-as="build" class="my-0">
<a class="text-danger" t-attf-href="/runbot/build/{{build.id}}" target="new">
<t t-esc="build.description or build.id"/>
</a>
</p>
</t>
</div>
</div>
</div>
</template>
<template id="runbot.dashboard">
<div class="row">
<t t-foreach="dashboard.dashboard_tile_ids" t-as="tile">
<t t-if="tile.build_ids or not hide_empty" t-call="{{ tile.custom_template_id.id }}"/>
</t>
</div>
</template>
<template id="runbot.dashboard_page">
<t t-call="runbot.frontend_no_nav">
<t t-set="head">
<t t-if="refresh">
<meta http-equiv="refresh" t-att-content="refresh"/>
</t>
</t>
<t t-call="runbot.dashboard"/>
</t>
</template>
</data>
</odoo>

View File

@ -101,6 +101,11 @@
<div class="dropdown-menu js_usermenu" role="menu">
<a class="dropdown-item" id="o_logout" role="menuitem" t-attf-href="/web/session/logout?redirect=/">Logout</a>
<a class="dropdown-item" role="menuitem" t-attf-href="/web">Web</a>
<div t-if="user_id.runbot_team_ids" class="dropdown-divider"/>
<div t-if="user_id.runbot_team_ids" class="dropdown-header">Teams</div>
<a t-foreach="user_id.runbot_team_ids" t-as="team" class="dropdown-item" role="menuitem" t-attf-href="/runbot/teams/{{team.id}}">
<t t-esc="team.name.capitalize()"/>
</a>
</div>
</li>
</t>

View File

@ -29,12 +29,18 @@ class TestBuildError(RunbotCase):
def setUp(self):
super(TestBuildError, self).setUp()
self.BuildError = self.env['runbot.build.error']
self.BuildErrorTeam = self.env['runbot.team']
def test_build_scan(self):
IrLog = self.env['ir.logging']
ko_build = self.create_test_build({'local_result': 'ko'})
ok_build = self.create_test_build({'local_result': 'ok'})
error_team = self.BuildErrorTeam.create({
'name': 'test-error-team',
'path_glob': '*build-error-n*'
})
log = {'message': RTE_ERROR,
'build_id': ko_build.id,
'level': 'ERROR',
@ -54,6 +60,7 @@ class TestBuildError(RunbotCase):
build_error = self.BuildError.search([('build_ids', 'in', [ko_build.id])])
self.assertIn(ko_build, build_error.build_ids, 'The parsed build should be added to the runbot.build.error')
self.assertFalse(self.BuildError.search([('build_ids', 'in', [ok_build.id])]), 'A successful build should not associated to a runbot.build.error')
self.assertEqual(error_team, build_error.team_id)
# Test that build with same error is added to the errors
ko_build_same_error = self.create_test_build({'local_result': 'ko'})
@ -151,3 +158,36 @@ class TestBuildError(RunbotCase):
# test that test tags on fixed errors are not taken into account
self.assertNotIn('blah', self.BuildError.test_tags_list())
self.assertNotIn('-blah', self.BuildError.disabling_tags())
def test_build_error_team_wildcards(self):
website_team = self.BuildErrorTeam.create({
'name': 'website_test',
'path_glob': '*website*,-*website_sale*'
})
self.assertTrue(website_team.dashboard_id.exists())
self.assertFalse(self.BuildErrorTeam._get_team('odoo/addons/web_studio/tests/test_ui.py'))
self.assertFalse(self.BuildErrorTeam._get_team('odoo/addons/website_sale/tests/test_sale_process.py'))
self.assertEqual(website_team.id, self.BuildErrorTeam._get_team('odoo/addons/website_crm/tests/test_website_crm'))
self.assertEqual(website_team.id, self.BuildErrorTeam._get_team('odoo/addons/website/tests/test_ui'))
def test_dashboard_tile_simple(self):
self.additionnal_setup()
bundle = self.env['runbot.bundle'].search([('project_id', '=', self.project.id)])
bundle.last_batch.state = 'done'
bundle.flush()
bundle._compute_last_done_batch() # force the recompute
self.assertTrue(bool(bundle.last_done_batch.exists()))
# simulate a failed build that we want to monitor
failed_build = bundle.last_done_batch.slot_ids[0].build_id
failed_build.global_result = 'ko'
failed_build.flush()
team = self.env['runbot.team'].create({'name': 'Test team'})
dashboard = self.env['runbot.dashboard.tile'].create({
'project_id': self.project.id,
'category_id': bundle.last_done_batch.category_id.id,
})
self.assertEqual(dashboard.build_ids, failed_build)

View File

@ -11,15 +11,17 @@
<group name="build_error_group">
<field name="fingerprint" readonly="1"/>
<field name="content"/>
<field name="cleaned_content" groups="base.group_no_one"/>
<field name="module_name"/>
<field name="function"/>
<field name="random"/>
<field name="responsible"/>
<field name="team_id"/>
<field name="fixing_commit"/>
<field name="fixing_pr_id"/>
<field name="active"/>
<field name="parent_id" />
<field name="bundle_ids" widget="many2many_tags"/>
<field name="version_ids" widget="many2many_tags"/>
<field name="trigger_ids" widget="many2many_tags"/>
<field name="tag_ids" widget="many2many_tags"/>
<field name="first_seen_date"/>
@ -74,6 +76,11 @@
</tree>
</field>
</page>
<page string="Cleaned" groups="base.group_no_one">
<group name="build_error_group">
<field name="cleaned_content"/>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
@ -108,7 +115,9 @@
<field name="content"/>
<field name="module_name"/>
<field name="function"/>
<field name="version_ids"/>
<field name="responsible"/>
<field name="team_id"/>
<field name="fixing_commit"/>
<filter string="No Parent" name="no_parent_error" domain="[('parent_id', '=', False)]"/>
<separator/>
@ -202,5 +211,6 @@
sequence="20"
action="open_view_error_regex"
/>
</data>
</odoo>

View File

@ -42,6 +42,7 @@
<group>
<field name="description"/>
<field name="params_id"/>
<field name="config_id"/>
<field name="port" groups="base.group_no_one"/>
<field name="dest"/>
<field name="local_state"/>

View File

@ -0,0 +1,150 @@
<odoo>
<data>
<record id="team_form" model="ir.ui.view">
<field name="name">runbot.team.form</field>
<field name="model">runbot.team</field>
<field name="arch" type="xml">
<form>
<sheet>
<group name="team_group">
<field name="name"/>
<field name="dashboard_id"/>
<field name="path_glob"/>
</group>
<notebook>
<page string="Team Errors">
<field name="build_error_ids" nolabel="1" widget="many2many" options="{'not_delete': True, 'no_create': True}"/>
</page>
<page string="Team Members">
<field name="user_ids" nolabel="1" widget="many2many" options="{'not_delete': True, 'no_create': True}"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="team_tree" model="ir.ui.view">
<field name="name">runbot.team.tree</field>
<field name="model">runbot.team</field>
<field name="arch" type="xml">
<tree string="Runbot Teams">
<field name="name"/>
<field name="path_glob"/>
<field name="build_error_ids"/>
</tree>
</field>
</record>
<record id="dashboard_form" model="ir.ui.view">
<field name="name">runbot.dashboard.form</field>
<field name="model">runbot.dashboard</field>
<field name="arch" type="xml">
<form>
<sheet>
<group name="dashboard_group">
<field name="name"/>
<field name="team_ids"/>
<field name="dashboard_tile_ids"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dashboard_tree" model="ir.ui.view">
<field name="name">runbot.dashboard.tree</field>
<field name="model">runbot.dashboard</field>
<field name="arch" type="xml">
<tree string="Runbot Dashboards">
<field name="name"/>
<field name="team_ids"/>
<field name="dashboard_tile_ids"/>
</tree>
</field>
</record>
<record id="dashboard_tile_form" model="ir.ui.view">
<field name="name">runbot.dashboard.tile.form</field>
<field name="model">runbot.dashboard.tile</field>
<field name="arch" type="xml">
<form>
<sheet>
<group name="dashboard_tile_group">
<field name="name"/>
<field name="project_id"/>
<field name="category_id"/>
<field name="trigger_id"/>
<field name="config_id"/>
<field name="domain_filter" widget="domain" options="{'model': 'runbot.build', 'in_dialog': True}"/>
<field name="custom_template_id" groups="runbot.group_runbot_admin"/>
</group>
<notebook>
<page string="Builds Found">
<field name="build_ids" nolabel="1" widget="many2many" options="{'not_delete': True, 'no_create': True}"/>
</page>
<page string="Dashboards">
<field name="dashboard_ids" nolabel="1" widget="many2many" options="{'not_delete': True}"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="dashboard_tile_tree" model="ir.ui.view">
<field name="name">runbot.dashboard.tile.tree</field>
<field name="model">runbot.dashboard.tile</field>
<field name="arch" type="xml">
<tree string="Runbot Dashboards Tiles">
<field name="sequence" widget="handle"/>
<field name="project_id"/>
<field name="category_id"/>
<field name="trigger_id"/>
<field name="config_id"/>
<field name="name"/>
</tree>
</field>
</record>
<record id="open_view_runbot_team" model="ir.actions.act_window">
<field name="name">Runbot Teams</field>
<field name="res_model">runbot.team</field>
<field name="view_mode">tree,form</field>
</record>
<record id="open_view_runbot_dashboard" model="ir.actions.act_window">
<field name="name">Runbot Dashboards</field>
<field name="res_model">runbot.dashboard</field>
<field name="view_mode">tree,form</field>
</record>
<record id="open_view_runbot_dashboard_tile" model="ir.actions.act_window">
<field name="name">Runbot Dashboards Tiles</field>
<field name="res_model">runbot.dashboard.tile</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
name="Runbot Teams"
id="runbot_menu_team_tree"
parent="runbot_menu_manage_errors"
sequence="30"
action="open_view_runbot_team"
/>
<menuitem
name="Runbot Dashboards"
id="runbot_menu_runbot_dashboard_tree"
parent="runbot_menu_manage_errors"
sequence="40"
action="open_view_runbot_dashboard"
/>
<menuitem
name="Runbot Dashboard Tiles"
id="runbot_menu_runbot_dashboard_tile_tree"
parent="runbot_menu_manage_errors"
sequence="50"
action="open_view_runbot_dashboard_tile"
/>
</data>
</odoo>

View File

@ -19,6 +19,9 @@
<field name="bundle_id"/>
<field name="elements"/>
<field name="info"/>
<field name="create_date"/>
<field name="create_uid"/>
<field name="team_id"/>
</tree>
</field>
</record>
@ -34,6 +37,17 @@
</field>
</record>
<record id="upgrade_exception_search_view" model="ir.ui.view">
<field name="name">runbot.upgrade.exception.filter</field>
<field name="model">runbot.upgrade.exception</field>
<field name="arch" type="xml">
<search string="Search exceptions">
<field name="elements"/>
<field name="bundle_id"/>
</search>
</field>
</record>
<record id="open_view_upgrade_exception_tree" model="ir.actions.act_window">
<field name="name">Upgrade Exceptions</field>
<field name="res_model">runbot.upgrade.exception</field>