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
+