diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 7641d9be..b05c3648 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -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', diff --git a/runbot/controllers/frontend.py b/runbot/controllers/frontend.py index d5db1357..18eb5285 100644 --- a/runbot/controllers/frontend.py +++ b/runbot/controllers/frontend.py @@ -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/'], 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/' + ], 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/',], 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/',], 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/'], type='http', auth="public", website=True, sitemap=False) def build_stats(self, build_id, search=None, **post): """Build statistics""" diff --git a/runbot/models/__init__.py b/runbot/models/__init__.py index c2b05ba8..b7acd3b0 100644 --- a/runbot/models/__init__.py +++ b/runbot/models/__init__.py @@ -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 diff --git a/runbot/models/batch.py b/runbot/models/batch.py index d7631a51..0b1de10b 100644 --- a/runbot/models/batch.py +++ b/runbot/models/batch.py @@ -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') diff --git a/runbot/models/build_error.py b/runbot/models/build_error.py index f3e5bc4d..7b752b13 100644 --- a/runbot/models/build_error.py +++ b/runbot/models/build_error.py @@ -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) diff --git a/runbot/models/event.py b/runbot/models/event.py index d4c97be1..38b3facd 100644 --- a/runbot/models/event.py +++ b/runbot/models/event.py @@ -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" diff --git a/runbot/models/res_users.py b/runbot/models/res_users.py new file mode 100644 index 00000000..3077c28e --- /dev/null +++ b/runbot/models/res_users.py @@ -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") diff --git a/runbot/models/upgrade.py b/runbot/models/upgrade.py index 80d24f89..537774fa 100644 --- a/runbot/models/upgrade.py +++ b/runbot/models/upgrade.py @@ -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([]) diff --git a/runbot/security/ir.model.access.csv b/runbot/security/ir.model.access.csv index 6cada880..e5bdabbe 100644 --- a/runbot/security/ir.model.access.csv +++ b/runbot/security/ir.model.access.csv @@ -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 diff --git a/runbot/templates/build.xml b/runbot/templates/build.xml index 0bba87ed..555ce23f 100644 --- a/runbot/templates/build.xml +++ b/runbot/templates/build.xml @@ -296,6 +296,17 @@ + + + + + This error already is known. + + + + + + diff --git a/runbot/templates/build_error.xml b/runbot/templates/build_error.xml index a0700fc9..801c0810 100644 --- a/runbot/templates/build_error.xml +++ b/runbot/templates/build_error.xml @@ -1,140 +1,134 @@ + + -
-
-
-                          
-                        
-
-
- + + + + + + + +
diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml index 2a69e2ab..9b04c373 100644 --- a/runbot/templates/utils.xml +++ b/runbot/templates/utils.xml @@ -101,6 +101,11 @@