[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/bundle_views.xml',
'views/commit_views.xml', 'views/commit_views.xml',
'views/config_views.xml', 'views/config_views.xml',
'views/dashboard_views.xml',
'views/dockerfile_views.xml', 'views/dockerfile_views.xml',
'views/error_log_views.xml', 'views/error_log_views.xml',
'views/host_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['title'] = 'Runbot %s' % project.name or ''
response.qcontext['nb_build_errors'] = nb_build_errors response.qcontext['nb_build_errors'] = nb_build_errors
response.qcontext['nb_assigned_errors'] = nb_assigned_errors response.qcontext['nb_assigned_errors'] = nb_assigned_errors
return response return response
return response_wrap return response_wrap
return decorator return decorator
@ -353,8 +352,9 @@ class Runbot(Controller):
return request.render(view_id if view_id else "runbot.monitoring", qctx) return request.render(view_id if view_id else "runbot.monitoring", qctx)
@route(['/runbot/errors', @route(['/runbot/errors',
'/runbot/errors/page/<int:page>'], type='http', auth='user', website=True, sitemap=False) '/runbot/errors/page/<int:page>'
def build_errors(self, error_id=None, sort=None, page=1, limit=20, **kwargs): ], type='http', auth='user', website=True, sitemap=False)
def build_errors(self, sort=None, page=1, limit=20, **kwargs):
sort_order_choices = { sort_order_choices = {
'last_seen_date desc': 'Last seen date: Newer First', 'last_seen_date desc': 'Last seen date: Newer First',
'last_seen_date asc': 'Last seen date: Older First', 'last_seen_date asc': 'Last seen date: Older First',
@ -390,6 +390,24 @@ class Runbot(Controller):
} }
return request.render('runbot.build_error', qctx) 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) @route(['/runbot/build/stats/<int:build_id>'], type='http', auth="public", website=True, sitemap=False)
def build_stats(self, build_id, search=None, **post): def build_stats(self, build_id, search=None, **post):
"""Build statistics""" """Build statistics"""

View File

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

View File

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

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import ast
import hashlib import hashlib
import logging import logging
import re import re
from collections import defaultdict from collections import defaultdict
from fnmatch import fnmatch
from odoo import models, fields, api from odoo import models, fields, api
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
@ -22,13 +24,17 @@ class BuildError(models.Model):
cleaned_content = fields.Text('Cleaned error message') cleaned_content = fields.Text('Cleaned error message')
summary = fields.Char('Content summary', compute='_compute_summary', store=False) summary = fields.Char('Content summary', compute='_compute_summary', store=False)
module_name = fields.Char('Module name') # name in ir_logging 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 function = fields.Char('Function name') # func name in ir logging
fingerprint = fields.Char('Error fingerprint', index=True) fingerprint = fields.Char('Error fingerprint', index=True)
random = fields.Boolean('underterministic error', tracking=True) random = fields.Boolean('underterministic error', tracking=True)
responsible = fields.Many2one('res.users', 'Assigned fixer', 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_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') 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') 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') trigger_ids = fields.Many2many('runbot.trigger', compute='_compute_trigger_ids')
active = fields.Boolean('Error is not fixed', default=True, tracking=True) active = fields.Boolean('Error is not fixed', default=True, tracking=True)
tag_ids = fields.Many2many('runbot.build.error.tag', string='Tags') tag_ids = fields.Many2many('runbot.build.error.tag', string='Tags')
@ -57,6 +63,8 @@ class BuildError(models.Model):
vals.update({'cleaned_content': cleaned_content, vals.update({'cleaned_content': cleaned_content,
'fingerprint': self._digest(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) return super().create(vals)
def write(self, 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) 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') 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') @api.depends('build_ids')
def _compute_trigger_ids(self): def _compute_trigger_ids(self):
for build_error in self: for build_error in self:
@ -143,6 +156,7 @@ class BuildError(models.Model):
build_errors |= self.env['runbot.build.error'].create({ build_errors |= self.env['runbot.build.error'].create({
'content': logs[0].message, 'content': logs[0].message,
'module_name': logs[0].name, 'module_name': logs[0].name,
'file_path': logs[0].path,
'function': logs[0].func, 'function': logs[0].func,
'build_ids': [(6, False, [r.build_id.id for r in logs])], 'build_ids': [(6, False, [r.build_id.id for r in logs])],
}) })
@ -184,6 +198,9 @@ class BuildError(models.Model):
def disabling_tags(self): def disabling_tags(self):
return ['-%s' % tag for tag in self.test_tags_list()] 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): class BuildErrorTag(models.Model):
@ -218,3 +235,98 @@ class ErrorRegex(models.Model):
if re.search(filter.regex, s): if re.search(filter.regex, s):
return True return True
return False 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 import logging
from collections import defaultdict
from ..common import pseudo_markdown from ..common import pseudo_markdown
from odoo import models, fields, tools from odoo import models, fields, tools
from odoo.exceptions import UserError 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') build_id = fields.Many2one('runbot.build', 'Build', index=True, ondelete='cascade')
active_step_id = fields.Many2one('runbot.build.config.step', 'Active step', index=True) 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) 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): def init(self):
parent_class = super(runbot_event, 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) 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): class RunbotErrorLog(models.Model):
_name = 'runbot.error.log' _name = 'runbot.error.log'
_description = "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') elements = fields.Text('Elements')
bundle_id = fields.Many2one('runbot.bundle', index=True) bundle_id = fields.Many2one('runbot.bundle', index=True)
info = fields.Text('Info') info = fields.Text('Info')
team_id = fields.Many2one('runbot.team', 'Assigned team', index=True)
def _generate(self): def _generate(self):
exceptions = self.search([]) 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_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_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_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_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 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> </t>
</td> </td>
</tr> </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> </t>
</table> </table>
</div> </div>

View File

@ -1,140 +1,134 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<data> <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"> <template id="runbot.build_error">
<t t-call='website.layout'> <t t-call='website.layout'>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class='col-md-12'> <div class='col-md-12'>
<t t-if="current_user_errors"> <h3>Your assigned bug on Runbot Builds</h3>
<h3>Your assigned bugs Bugs on Runbot Builds</h3> <t t-call="runbot.build_error_cards">
<div class="accordion" id="userErrorAccordion"> <t t-set="build_errors" t-value="current_user_errors"/>
<div class="card"> <t t-set="accordion_id">user_errors</t>
<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>
</t> </t>
<h3>Current Bugs on Runbot Builds</h3> <h3>Current Bugs on Runbot Builds</h3>
<div class="container"> <t t-if="build_errors">
<nav class="navbar navbar-expand-lg navbar-light bg-light"> <div class="container">
<div class="dropdown mr-auto"> <nav class="navbar navbar-expand-lg navbar-light bg-light">
<a role="button" href="#" class="dropdown-toggle btn btn-secondary" data-toggle="dropdown"> <div class="dropdown mr-auto">
Sort By: <t t-esc="request.params.get('sort', '')"/> <a role="button" href="#" class="dropdown-toggle btn btn-secondary" data-toggle="dropdown">
</a> Sort By: <t t-esc="request.params.get('sort', '')"/>
<div class="dropdown-menu" aria-labelledby="sortMenuButton" role="menu"> </a>
<t t-foreach="sort_order_choices" t-as="sort_choice"> <div class="dropdown-menu" aria-labelledby="sortMenuButton" role="menu">
<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-foreach="sort_order_choices" t-as="sort_choice">
</t> <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>
</div> <span class="ml-auto">
<span class="ml-auto"> <t t-call="website.pager" />
<t t-call="website.pager" /> </span>
</span> </nav>
</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>
</div> </div>
<t t-foreach="build_errors" t-as="build_error"> <t t-call="runbot.build_error_cards">
<div class="card"> <t t-set="build_errors" t-value="build_errors"/>
<div class="card-header"> <t t-set="accordion_id">all_errors</t>
<div class="row"> </t>
<div class="col"><t t-esc="build_error.last_seen_date" t-options='{"widget": "datetime"}'/></div> </t>
<div class="col col-md-3"><t t-esc="build_error.module_name"/></div> </div>
<div class="col col-md-3"> </div>
<button class="btn btn-link" type="button" data-toggle="collapse" t-attf-data-target="#collapse{{build_error.id}}" aria-expanded="true" aria-controls="collapseOne"> </div>
<i class="fa fa-minus"/> </t>
</button> </template>
<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="#errorAccordion"> <template id="runbot.team">
<div class="card-body"> <t t-call='website.layout'>
<pre class="pre-scrollable"> <div class="container-fluid bg-light">
<code><t t-esc="build_error.content.strip()" /></code> <div class="row">
</pre> <div t-if="team" class='col-md-12'>
</div> <div class="col-lg-12 text-center mb16">
</div> <h2>Team <t t-esc="team.name.capitalize()"/>
</div> <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> </t>
</div> </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> </div>
</div> </div>

View File

@ -183,5 +183,47 @@
</div> </div>
</t> </t>
</template> </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> </data>
</odoo> </odoo>

View File

@ -101,6 +101,11 @@
<div class="dropdown-menu js_usermenu" role="menu"> <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" 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> <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> </div>
</li> </li>
</t> </t>

View File

@ -29,12 +29,18 @@ class TestBuildError(RunbotCase):
def setUp(self): def setUp(self):
super(TestBuildError, self).setUp() super(TestBuildError, self).setUp()
self.BuildError = self.env['runbot.build.error'] self.BuildError = self.env['runbot.build.error']
self.BuildErrorTeam = self.env['runbot.team']
def test_build_scan(self): def test_build_scan(self):
IrLog = self.env['ir.logging'] IrLog = self.env['ir.logging']
ko_build = self.create_test_build({'local_result': 'ko'}) ko_build = self.create_test_build({'local_result': 'ko'})
ok_build = self.create_test_build({'local_result': 'ok'}) 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, log = {'message': RTE_ERROR,
'build_id': ko_build.id, 'build_id': ko_build.id,
'level': 'ERROR', 'level': 'ERROR',
@ -54,6 +60,7 @@ class TestBuildError(RunbotCase):
build_error = self.BuildError.search([('build_ids', 'in', [ko_build.id])]) 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.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.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 # Test that build with same error is added to the errors
ko_build_same_error = self.create_test_build({'local_result': 'ko'}) 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 # 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.test_tags_list())
self.assertNotIn('-blah', self.BuildError.disabling_tags()) 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"> <group name="build_error_group">
<field name="fingerprint" readonly="1"/> <field name="fingerprint" readonly="1"/>
<field name="content"/> <field name="content"/>
<field name="cleaned_content" groups="base.group_no_one"/>
<field name="module_name"/> <field name="module_name"/>
<field name="function"/> <field name="function"/>
<field name="random"/> <field name="random"/>
<field name="responsible"/> <field name="responsible"/>
<field name="team_id"/>
<field name="fixing_commit"/> <field name="fixing_commit"/>
<field name="fixing_pr_id"/>
<field name="active"/> <field name="active"/>
<field name="parent_id" /> <field name="parent_id" />
<field name="bundle_ids" widget="many2many_tags"/> <field name="bundle_ids" widget="many2many_tags"/>
<field name="version_ids" widget="many2many_tags"/>
<field name="trigger_ids" widget="many2many_tags"/> <field name="trigger_ids" widget="many2many_tags"/>
<field name="tag_ids" widget="many2many_tags"/> <field name="tag_ids" widget="many2many_tags"/>
<field name="first_seen_date"/> <field name="first_seen_date"/>
@ -74,6 +76,11 @@
</tree> </tree>
</field> </field>
</page> </page>
<page string="Cleaned" groups="base.group_no_one">
<group name="build_error_group">
<field name="cleaned_content"/>
</group>
</page>
</notebook> </notebook>
</sheet> </sheet>
<div class="oe_chatter"> <div class="oe_chatter">
@ -108,7 +115,9 @@
<field name="content"/> <field name="content"/>
<field name="module_name"/> <field name="module_name"/>
<field name="function"/> <field name="function"/>
<field name="version_ids"/>
<field name="responsible"/> <field name="responsible"/>
<field name="team_id"/>
<field name="fixing_commit"/> <field name="fixing_commit"/>
<filter string="No Parent" name="no_parent_error" domain="[('parent_id', '=', False)]"/> <filter string="No Parent" name="no_parent_error" domain="[('parent_id', '=', False)]"/>
<separator/> <separator/>
@ -202,5 +211,6 @@
sequence="20" sequence="20"
action="open_view_error_regex" action="open_view_error_regex"
/> />
</data> </data>
</odoo> </odoo>

View File

@ -42,6 +42,7 @@
<group> <group>
<field name="description"/> <field name="description"/>
<field name="params_id"/> <field name="params_id"/>
<field name="config_id"/>
<field name="port" groups="base.group_no_one"/> <field name="port" groups="base.group_no_one"/>
<field name="dest"/> <field name="dest"/>
<field name="local_state"/> <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="bundle_id"/>
<field name="elements"/> <field name="elements"/>
<field name="info"/> <field name="info"/>
<field name="create_date"/>
<field name="create_uid"/>
<field name="team_id"/>
</tree> </tree>
</field> </field>
</record> </record>
@ -34,6 +37,17 @@
</field> </field>
</record> </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"> <record id="open_view_upgrade_exception_tree" model="ir.actions.act_window">
<field name="name">Upgrade Exceptions</field> <field name="name">Upgrade Exceptions</field>
<field name="res_model">runbot.upgrade.exception</field> <field name="res_model">runbot.upgrade.exception</field>