From f238e14d2adec85db36ea29e07d3b31f9253f444 Mon Sep 17 00:00:00 2001 From: Christophe Monniez Date: Wed, 6 May 2026 11:41:43 +0200 Subject: [PATCH] [IMP] runbot: update test tags version bounds on manual parse When parsing build errors, it may happen that the error appears in a version lower or higher than the current test tags bounds. With this commit, when a build error log is manually parsed (via the web interface or the server action), the min and max version bounds of the test tags are updated accordingly to include the new builds versions. --- runbot/controllers/frontend.py | 2 +- runbot/data/build_parse.xml | 2 +- runbot/models/build.py | 4 +- runbot/models/build_error.py | 18 ++++- runbot/tests/test_build_error.py | 114 +++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) diff --git a/runbot/controllers/frontend.py b/runbot/controllers/frontend.py index 6735fe586..181a2c184 100644 --- a/runbot/controllers/frontend.py +++ b/runbot/controllers/frontend.py @@ -670,7 +670,7 @@ def access_running(self, build_id, db_suffix=None, **kwargs): @route(['/runbot/parse_log/'], type='http', auth='user', sitemap=False) def parse_log(self, ir_log, **kwargs): - request.env['runbot.build.error']._parse_logs(ir_log) + request.env['runbot.build.error']._parse_logs(ir_log, update_tags=True) return werkzeug.utils.redirect('/runbot/build/%s' % ir_log.build_id.id) @route(['/runbot/bundle//triggers/'], type='http', auth='user', sitemap=False) diff --git a/runbot/data/build_parse.xml b/runbot/data/build_parse.xml index a164c660d..dd2279edb 100644 --- a/runbot/data/build_parse.xml +++ b/runbot/data/build_parse.xml @@ -6,7 +6,7 @@ ir.actions.server code - action = records._parse_logs() + action = records._parse_logs(update_tags=True) diff --git a/runbot/models/build.py b/runbot/models/build.py index ef77696e8..acd1326b2 100644 --- a/runbot/models/build.py +++ b/runbot/models/build.py @@ -1478,12 +1478,12 @@ def _get_py_version(self): return '3' return '' - def _parse_logs(self): + def _parse_logs(self, update_tags=False): """ Parse build logs to classify errors """ # only parse logs from builds in error and not already scanned builds_to_scan = self.filtered(lambda b: b.local_result in ('ko', 'killed', 'warn') and not b.build_error_link_ids) ir_logs = builds_to_scan.log_ids.filtered(lambda l: l.level in ('ERROR', 'WARNING', 'CRITICAL')) - return self.env['runbot.build.error']._parse_logs(ir_logs) + return self.env['runbot.build.error']._parse_logs(ir_logs, update_tags=update_tags) def _is_file(self, file, mode='r'): file_path = self._path(file) diff --git a/runbot/models/build_error.py b/runbot/models/build_error.py index 470e64774..2d07236fd 100644 --- a/runbot/models/build_error.py +++ b/runbot/models/build_error.py @@ -549,6 +549,17 @@ def _onchange_test_tags(self): self.tags_min_version_id = min(self.version_ids, key=lambda rec: rec.number) self.tags_max_version_id = max(self.version_ids, key=lambda rec: rec.number) + def _update_version_tags(self): + for error in self: + if not (error.test_tags and error.version_ids): + continue + new_min = min(error.version_ids, key=lambda rec: rec.number) + new_max = max(error.version_ids, key=lambda rec: rec.number) + if not error.tags_min_version_id or new_min.number < error.tags_min_version_id.number: + error.tags_min_version_id = new_min + if not error.tags_max_version_id or new_max.number > error.tags_max_version_id.number: + error.tags_max_version_id = new_max + @api.onchange('customer') def _onchange_customer(self): if not self.responsible: @@ -789,7 +800,7 @@ def action_copy_canonical_tag(self): record._onchange_test_tags() @api.model - def _parse_logs(self, ir_logs): + def _parse_logs(self, ir_logs, update_tags=False): if not ir_logs: return None regexes = self.env['runbot.error.regex'].search([]) @@ -845,6 +856,11 @@ def _parse_logs(self, ir_logs): 'log_date': rec.create_date, }) + if update_tags: + errors_to_update = build_error_contents.error_id.filtered('test_tags') + for error in errors_to_update: + error._update_version_tags() + if build_error_contents: window_action = { "type": "ir.actions.act_window", diff --git a/runbot/tests/test_build_error.py b/runbot/tests/test_build_error.py index 0217e3182..8d4a27ec6 100644 --- a/runbot/tests/test_build_error.py +++ b/runbot/tests/test_build_error.py @@ -257,6 +257,120 @@ def test_duplicate_breaking_pr(self): error_a.breaking_pr_id = False self.assertEqual(error_a.duplicate_breaking_pr_count, 0) + def test_onchange_test_tags(self): + error = self.BuildError.create({}) + error_content = self.BuildErrorContent.create({'content': 'very bad trip', 'error_id': error.id}) + build_13 = self.create_test_build({'local_result': 'ko'}) + self.BuildErrorLink.create({ + 'build_id': build_13.id, + 'error_content_id': error_content.id, + }) + self.assertEqual(error.tags_min_version_id.id, False) + self.assertEqual(error.tags_max_version_id.id, False) + + error.test_tags = '/account' + error._onchange_test_tags() + self.assertEqual(error.tags_min_version_id, self.version_13) + self.assertEqual(error.tags_max_version_id, self.version_13) + + version_14 = self.Version._get('14.0') + params_14 = self.create_params({'version_id': version_14.id}) + build_14 = self.create_test_build({'local_result': 'ko', 'params_id': params_14.id}) + self.BuildErrorLink.create({ + 'build_id': build_14.id, + 'error_content_id': error_content.id, + }) + error._onchange_test_tags() + self.assertEqual(error.tags_min_version_id, self.version_13) + self.assertEqual(error.tags_max_version_id, version_14) + + def test_parse_logs_updates_version_bounds(self): + build_13 = self.create_test_build({'local_result': 'ko', 'local_state': 'done'}) + log_data = { + 'name': 'test-parse-logs', + 'type': 'server', + 'path': 'runbot', + 'level': 'ERROR', + 'func': 'test_trip', + 'line': 242, + 'message': 'Error Very Bad Trip', + 'build_id': build_13.id, + } + log_13 = self.IrLog.create(log_data) + + action = self.BuildError._parse_logs(log_13, update_tags=True) + self.assertEqual(action.get('type'), 'ir.actions.act_window') + error_content = self.BuildErrorContent.browse(action.get('res_id')) + error = error_content.error_id + error.test_tags = '/account' + error._update_version_tags() + self.assertEqual(error.tags_min_version_id, self.version_13) + self.assertEqual(error.tags_max_version_id, self.version_13) + + # Scanning a newer build extends the max without updating the min + version_14 = self.Version._get('14.0') + params_14 = self.create_params({'version_id': version_14.id}) + build_14 = self.create_test_build({'local_result': 'ko', 'local_state': 'done', 'params_id': params_14.id}) + log_data.update({'build_id': build_14.id}) + log_14 = self.IrLog.create(log_data) + self.BuildError._parse_logs(log_14, update_tags=True) + self.assertIn(build_14, error.build_ids) + self.assertEqual(error.tags_min_version_id, self.version_13) + self.assertEqual(error.tags_max_version_id, version_14) + + def test_parse_logs_no_update_without_flag(self): + build_13 = self.create_test_build({'local_result': 'ko', 'local_state': 'done'}) + log_13 = self.IrLog.create({ + 'name': 'test-parse-logs', + 'type': 'server', + 'path': 'runbot', + 'level': 'ERROR', + 'func': 'test_trip', + 'line': 242, + 'message': 'Error Very Bad Trip', + 'build_id': build_13.id, + }) + action = self.BuildError._parse_logs(log_13) + self.assertEqual(action.get('type'), 'ir.actions.act_window') + error_content = self.BuildErrorContent.browse(action.get('res_id')) + error = error_content.error_id + error.test_tags = '/discuss' + self.assertFalse(error.tags_min_version_id) + self.assertFalse(error.tags_max_version_id) + + def test_update_version_tags_no_update_inside_bounds(self): + version_14 = self.Version._get('14.0') + version_16 = self.Version._get('16.0') + params_14 = self.create_params({'version_id': version_14.id}) + params_16 = self.create_params({'version_id': version_16.id}) + build_14 = self.create_test_build({'local_result': 'ko', 'params_id': params_14.id}) + build_16 = self.create_test_build({'local_result': 'ko', 'params_id': params_16.id}) + + error = self.BuildError.create({}) + error_content = self.BuildErrorContent.create({'content': 'inside bounds test', 'error_id': error.id}) + link_14 = self.BuildErrorLink.create({'build_id': build_14.id, 'error_content_id': error_content.id}) + link_16 = self.BuildErrorLink.create({'build_id': build_16.id, 'error_content_id': error_content.id}) + + error.test_tags = '/account' + error._update_version_tags() + self.assertEqual(error.tags_min_version_id, version_14) + self.assertEqual(error.tags_max_version_id, version_16) + + link_14.unlink() + self.assertNotIn(version_14, error.version_ids) + # There is only version 16.0 on the error now but if we update the tags, it should leave the min at 14.0 + error._update_version_tags() + self.assertEqual(error.tags_min_version_id, version_14) + self.assertEqual(error.tags_max_version_id, version_16) + + link_14 = self.BuildErrorLink.create({'build_id': build_14.id, 'error_content_id': error_content.id}) + link_16.unlink() + self.assertNotIn(version_16, error.version_ids) + # Now there is only version 14.0 on the error but if we update the tags, it should leave the max at 16.0 + error._update_version_tags() + self.assertEqual(error.tags_min_version_id, version_14) + self.assertEqual(error.tags_max_version_id, version_16) + def test_relink_contents(self): build_a = self.create_test_build({'local_result': 'ko', 'local_state': 'done'}) error_content_a = self.BuildErrorContent.create({'content': 'foo bar'})