diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 4a7c32cc..62fa803a 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -17,6 +17,7 @@ 'views/branch_views.xml', 'views/build_views.xml', 'views/host_views.xml', + 'views/build_error_views.xml', 'views/config_views.xml', 'views/res_config_settings_views.xml', 'templates/frontend.xml', @@ -27,5 +28,8 @@ 'templates/badge.xml', 'templates/branch.xml', 'data/runbot_build_config_data.xml', + 'data/build_parse.xml', + 'data/runbot_error_regex_data.xml', + 'data/error_link.xml', ], } diff --git a/runbot/data/build_parse.xml b/runbot/data/build_parse.xml new file mode 100644 index 00000000..c543bd59 --- /dev/null +++ b/runbot/data/build_parse.xml @@ -0,0 +1,12 @@ + + + Parse build logs + + + ir.actions.server + code + + records._parse_logs() + + + diff --git a/runbot/data/error_link.xml b/runbot/data/error_link.xml new file mode 100644 index 00000000..3917f0e6 --- /dev/null +++ b/runbot/data/error_link.xml @@ -0,0 +1,22 @@ + + + Link build errors + + + ir.actions.server + code + + records.link_errors() + + + + Re-clean build errors + + + ir.actions.server + code + + records.clean_content() + + + diff --git a/runbot/data/runbot_error_regex_data.xml b/runbot/data/runbot_error_regex_data.xml new file mode 100644 index 00000000..79bf8614 --- /dev/null +++ b/runbot/data/runbot_error_regex_data.xml @@ -0,0 +1,17 @@ + + + + + , line \d+, + cleaning + + + Module .+: \d+ failures, \d+ errors + filter + + + At least one test failed when loading the modules. + filter + + + diff --git a/runbot/models/__init__.py b/runbot/models/__init__.py index d7cc9322..863258e4 100644 --- a/runbot/models/__init__.py +++ b/runbot/models/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- -from . import repo, branch, build, event, build_dependency, build_config, ir_cron, host +from . import repo, branch, build, event, build_dependency, build_config, ir_cron, host, build_error from . import res_config_settings diff --git a/runbot/models/build.py b/runbot/models/build.py index e0a78471..76fb006a 100644 --- a/runbot/models/build.py +++ b/runbot/models/build.py @@ -104,6 +104,9 @@ class runbot_build(models.Model): ], default='soft', string='Source export path mode') + build_url = fields.Char('Build url', compute='_compute_build_url', store=False) + build_error_ids = fields.Many2many('runbot.build.error', 'runbot_build_error_ids_runbot_build_rel', string='Errors') + @api.depends('config_id') def _compute_log_list(self): # storing this field because it will be access trhoug repo viewn and keep track of the list at create for build in self: @@ -342,6 +345,10 @@ class runbot_build(models.Model): else: build.domain = "%s:%s" % (domain, build.port) + def _compute_build_url(self): + for build in self: + build.build_url = "/runbot/build/%s" % build.id + @api.depends('job_start', 'job_end', 'duplicate_id.job_time') def _compute_job_time(self): """Return the time taken by the tests""" @@ -988,6 +995,14 @@ class runbot_build(models.Model): return '3' return '' + def _parse_logs(self): + """ Parse build logs to classify errors """ + BuildError = self.env['runbot.build.error'] + # only parse logs from builds in error and not already scanned + builds_to_scan = self.search([('id', 'in', self.ids), ('local_result', '=', 'ko'), ('build_error_ids', '=', False)]) + ir_logs = self.env['ir.logging'].search([('level', '=', 'ERROR'), ('type', '=', 'server'), ('build_id', 'in', builds_to_scan.ids)]) + BuildError._parse_logs(ir_logs) + def read_file(self, file, mode='r'): file_path = self._path(file) try: diff --git a/runbot/models/build_error.py b/runbot/models/build_error.py new file mode 100644 index 00000000..4ab192b0 --- /dev/null +++ b/runbot/models/build_error.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +import hashlib +import logging +import re + +from collections import defaultdict +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class RunbotBuildError(models.Model): + + _name = "runbot.build.error" + _inherit = "mail.thread" + _rec_name = "id" + + content = fields.Text('Error message', required=True) + 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 + function = fields.Char('Function name') # func name in ir logging + fingerprint = fields.Char('Error fingerprint', index=True) + random = fields.Boolean('underterministic error', track_visibility='onchange') + responsible = fields.Many2one('res.users', 'Assigned fixer', track_visibility='onchange') + fixing_commit = fields.Char('Fixing commit', track_visibility='onchange') + build_ids = fields.Many2many('runbot.build', 'runbot_build_error_ids_runbot_build_rel', string='Affected builds') + branch_ids = fields.Many2many('runbot.branch', compute='_compute_branch_ids') + repo_ids = fields.Many2many('runbot.repo', compute='_compute_repo_ids') + active = fields.Boolean('Error is not fixed', default=True, track_visibility='onchange') + tag_ids = fields.Many2many('runbot.build.error.tag', string='Tags') + build_count = fields.Integer(compute='_compute_build_counts', string='Nb seen', stored=True) + parent_id = fields.Many2one('runbot.build.error', 'Linked to') + child_ids = fields.One2many('runbot.build.error', 'parent_id', string='Child Errors') + Children_build_ids = fields.Many2many(related='child_ids.build_ids', string='Children builds') + + @api.model + def create(self, vals): + cleaners = self.env['runbot.error.regex'].search([('re_type', '=', 'cleaning')]) + content = vals.get('content') + cleaned_content = cleaners.r_sub('%', content) + vals.update({'cleaned_content': cleaned_content, + 'fingerprint': self._digest(cleaned_content) + }) + return super().create(vals) + + @api.depends('build_ids') + def _compute_build_counts(self): + for build_error in self: + build_error.build_count = len(build_error.build_ids) + len(build_error.Children_build_ids) + + @api.depends('build_ids') + def _compute_branch_ids(self): + for build_error in self: + build_error.branch_ids = build_error.mapped('build_ids.branch_id') + + @api.depends('build_ids') + def _compute_repo_ids(self): + for build_error in self: + build_error.repo_ids = build_error.mapped('build_ids.repo_id') + + @api.depends('content') + def _compute_summary(self): + for build_error in self: + build_error.summary = build_error.content[:50] + + @api.model + def _digest(self, s): + """ + return a hash 256 digest of the string s + """ + return hashlib.sha256(s.encode()).hexdigest() + + @api.model + def _parse_logs(self, ir_logs): + + regexes = self.env['runbot.error.regex'].search([]) + search_regs = regexes.filtered(lambda r: r.re_type == 'filter') + cleaning_regs = regexes.filtered(lambda r: r.re_type == 'cleaning') + + hash_dict = defaultdict(list) + for log in ir_logs: + if search_regs.r_search(log.message): + continue + fingerprint = self._digest(cleaning_regs.r_sub('%', log.message)) + hash_dict[fingerprint].append(log) + + # add build ids to already detected errors + for build_error in self.env['runbot.build.error'].search([('fingerprint', 'in', list(hash_dict.keys()))]): + for build in {rec.build_id for rec in hash_dict[build_error.fingerprint]}: + build.build_error_ids += build_error + del hash_dict[build_error.fingerprint] + + # create an error for the remaining entries + for fingerprint, logs in hash_dict.items(): + self.env['runbot.build.error'].create({ + 'content': logs[0].message, + 'module_name': logs[0].name, + 'function': logs[0].func, + 'build_ids': [(6, False, [r.build_id.id for r in logs])], + }) + + def link_errors(self): + """ Link errors with the first one of the recordset + choosing parent in error with responsible, random bug and finally fisrt seen + """ + if len(self) < 2: + return + build_errors = self.search([('id', 'in', self.ids)], order='responsible asc, random desc, id asc') + build_errors[1:].write({'parent_id': build_errors[0].id}) + + def clean_content(self): + cleaning_regs = self.env['runbot.error.regex'].search([('re_type', '=', 'cleaning')]) + for build_error in self: + build_error.cleaned_content = cleaning_regs.r_sub('%', build_error.content) + + +class RunbotBuildErrorTag(models.Model): + + _name = "runbot.build.error.tag" + + name = fields.Char('Tag') + error_ids = fields.Many2many('runbot.build.error', string='Errors') + + +class RunbotErrorRegex(models.Model): + + _name = "runbot.error.regex" + _inherit = "mail.thread" + _rec_name = 'id' + _order = 'sequence, id' + + regex = fields.Char('Regular expression') + re_type = fields.Selection([('filter', 'Filter out'), ('cleaning', 'Cleaning')], string="Regex type") + sequence = fields.Integer('Sequence', default=100) + + def r_sub(self, replace, s): + """ replaces patterns from the recordset by replace in the given string """ + for c in self: + s = re.sub(c.regex, '%', s) + return s + + def r_search(self, s): + """ Return True if one of the regex is found in s """ + for filter in self: + if re.search(filter.regex, s): + return True + return False diff --git a/runbot/security/ir.model.access.csv b/runbot/security/ir.model.access.csv index bc2119a0..71d0332b 100644 --- a/runbot/security/ir.model.access.csv +++ b/runbot/security/ir.model.access.csv @@ -18,5 +18,13 @@ access_runbot_build_config_manager,runbot_build_config_manager,runbot.model_runb access_runbot_build_config_step_order_user,runbot_build_config_step_order_user,runbot.model_runbot_build_config_step_order,group_user,1,0,0,0 access_runbot_build_config_step_order_manager,runbot_build_config_step_order_manager,runbot.model_runbot_build_config_step_order,runbot.group_build_config_user,1,1,1,1 +access_runbot_build_error_user,runbot_build_error_user,runbot.model_runbot_build_error,group_user,1,0,0,0 +access_runbot_build_error_manager,runbot_build_error_manager,runbot.model_runbot_build_error,runbot.group_runbot_admin,1,1,1,1 +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_manager,runbot_build_error_tag_manager,runbot.model_runbot_build_error_tag,runbot.group_runbot_admin,1,1,1,1 + +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_host_user,runbot_host_user,runbot.model_runbot_host,group_user,1,0,0,0 -access_runbot_host_manager,runbot_host_manager,runbot.model_runbot_host,runbot.group_runbot_admin,1,1,1,1 +access_runbot_host_manager,runbot_host_manager,runbot.model_runbot_host,runbot.group_runbot_admin,1,1,1,1 \ No newline at end of file diff --git a/runbot/templates/build.xml b/runbot/templates/build.xml index 52252895..d2e0f6a0 100644 --- a/runbot/templates/build.xml +++ b/runbot/templates/build.xml @@ -99,7 +99,7 @@
  • Compare
  • -
  • Port:
  • +
  • View in backend
  • diff --git a/runbot/views/build_error_views.xml b/runbot/views/build_error_views.xml new file mode 100644 index 00000000..5c7ddca4 --- /dev/null +++ b/runbot/views/build_error_views.xml @@ -0,0 +1,172 @@ + + + + runbot.build.error.form + runbot.build.error + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    +
    +
    + + + runbot.build.error.tree + runbot.build.error + + + + + + + + + + + + + + runbot.build.error.log.filter + runbot.build.error + + + + + + + + + + + + + + + + + + + + + Build errors + runbot.build.error + tree,form + {'search_default_no_parent_error': True, 'search_default_random_error': True} + + + + runbot.error.regex.form + runbot.error.regex + +
    + + + + + + +
    + + +
    +
    +
    +
    + + + runbot.error.regex.tree + runbot.error.regex + + + + + + + + + + + runbot.error.regex.filter + runbot.error.regex + + + + + + + + + + + Errors regex + runbot.error.regex + tree,form + + + + + + + +
    +
    diff --git a/runbot/views/build_views.xml b/runbot/views/build_views.xml index 5454f06f..64aaa321 100644 --- a/runbot/views/build_views.xml +++ b/runbot/views/build_views.xml @@ -37,6 +37,7 @@ + @@ -77,6 +78,7 @@ runbot.build +